diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6f26434800c..9a99ed427ed0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,10 +143,12 @@ jobs: - name: Build, type check, and lint # Mobile lives in a parallel mobile-verify workflow (path-filtered # to apps/mobile/** + packages/core/**) so it doesn't add - # ~50s of expo-lint + tsc to every web/desktop PR. Keep this - # filter in sync with the root package.json scripts, which also - # exclude @multica/mobile. - run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile' + # ~50s of expo-lint + tsc to every web/desktop PR. The CDK stack in + # infrastructure/ is excluded for the same reason: it has its own + # path-filtered workflow, it imports nothing from these packages, and + # no frontend change can affect its result. Keep this filter in sync + # with the root package.json scripts, which exclude the same two. + run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile' --filter='!@multica/infrastructure' - name: Check frontend quality uses: ./.github/actions/frontend-quality @@ -194,7 +196,7 @@ jobs: # job's responsibility -- frontend-build owns the `typecheck` task. # `test` reaches dependency sources through the hash-only # `^cache-inputs` edge (see turbo.json), so no `tsc` runs here. - run: pnpm exec turbo test --filter='!@multica/docs' --filter='!@multica/mobile' --filter='!@multica/views' + run: pnpm exec turbo test --filter='!@multica/docs' --filter='!@multica/mobile' --filter='!@multica/infrastructure' --filter='!@multica/views' # Split views across two runners. GitHub reports the matrix job's combined # result, so the aggregate gate can continue depending on this one job ID. diff --git a/.github/workflows/infrastructure.yml b/.github/workflows/infrastructure.yml new file mode 100644 index 000000000000..cdf7e402a26f --- /dev/null +++ b/.github/workflows/infrastructure.yml @@ -0,0 +1,171 @@ +name: Infrastructure + +# The agent runtime stack (infrastructure/). Validation runs on every change to +# it; deploying is a manual dispatch, because the thing it replaces is the +# machine the team's agents are currently running on. +# +# ci.yml does not cover this path: its frontend jobs typecheck the workspace, +# but `cdk synth` and the template assertions belong with the stack rather than +# in a job every frontend change pays for. +on: + pull_request: + branches: [main] + paths: + - "infrastructure/**" + - ".github/workflows/infrastructure.yml" + push: + branches: [main] + paths: + - "infrastructure/**" + - ".github/workflows/infrastructure.yml" + workflow_dispatch: + inputs: + action: + description: "diff shows what would change; deploy applies it" + type: choice + default: diff + options: [diff, deploy] + stack: + description: "Which stack to act on" + type: choice + default: Multica-AgentRuntime + options: [Multica-AgentRuntime, Multica-SelfHost] + allowed_emails: + description: "Multica-SelfHost only: comma-separated addresses allowed to sign up" + type: string + default: "" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # The script is embedded verbatim in the instance's user data, so a syntax + # error here is a machine that half-configures itself and never reports a + # failure anywhere a deploy can see. + - name: Check shell scripts + working-directory: infrastructure + run: | + for script in assets/bootstrap.sh scripts/*.sh; do + bash -n "$script" + done + + - name: Typecheck + run: pnpm --filter @multica/infrastructure typecheck + + - name: Test + run: pnpm --filter @multica/infrastructure test + + # No AWS credentials: the app leaves account and region unset when they + # are absent, so the templates synthesize region-agnostic. The self-host + # stack is behind a context flag and needs its signup allowlist, so it + # gets its own invocation rather than going unsynthesized. + - name: Synth + run: pnpm --filter @multica/infrastructure synth + + - name: Synth the self-host stack + working-directory: infrastructure + run: pnpm exec cdk synth Multica-SelfHost -c selfHost=true -c allowedEmails=ci@example.com --quiet + + # Needs, in repository settings: + # secret AWS_DEPLOY_ROLE_ARN an OIDC role allowed to assume cdk-hnb659fds-* + # variable AWS_REGION the target region + # variable MULTICA_DAEMON_ID the deployed runtime's daemon id, which names + # its runtime rows and so is not committed + # environment aws-agent-runtime + # + # Put required reviewers on that environment. An agent-runtime deploy replaces + # the instance whenever the bootstrap script changed, which kills any run in + # flight on it; a self-host deploy restarts the server. + deploy: + if: github.event_name == 'workflow_dispatch' + needs: validate + runs-on: ubuntu-latest + environment: aws-agent-runtime + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Assume the deployment role + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: multica-infrastructure-${{ github.run_id }} + + # Each stack needs a value that is deliberately not committed: the agent + # runtime's daemon id names this installation's runtime rows, and the + # self-host signup allowlist is what keeps an internet-facing server from + # letting strangers create accounts. Both live outside the repository and + # arrive here instead. + - name: Resolve stack context + id: context + env: + STACK: ${{ inputs.stack }} + ALLOWED_EMAILS: ${{ inputs.allowed_emails }} + DAEMON_ID: ${{ vars.MULTICA_DAEMON_ID }} + run: | + if [ "$STACK" = "Multica-SelfHost" ]; then + if [ -z "$ALLOWED_EMAILS" ]; then + echo "::error::allowed_emails is required for Multica-SelfHost: port 443 is open to the internet, so without an allowlist anyone who finds the URL can sign up." + exit 1 + fi + echo "args=-c selfHost=true -c allowedEmails=$ALLOWED_EMAILS" >> "$GITHUB_OUTPUT" + else + if [ -z "$DAEMON_ID" ]; then + echo "::error::Repository variable MULTICA_DAEMON_ID is unset. Deploying the agent runtime without it mints a new daemon id, and the existing runtimes lose their agent bindings and public visibility." + exit 1 + fi + echo "args=-c daemonId=$DAEMON_ID" >> "$GITHUB_OUTPUT" + fi + + # Only for a diff run: `cdk deploy` prints the same change set itself. + - name: Diff + if: inputs.action == 'diff' + working-directory: infrastructure + run: pnpm exec cdk diff ${{ inputs.stack }} ${{ steps.context.outputs.args }} + + # --require-approval broadening, not never: a change that widens a + # security group or an IAM policy fails the job instead of applying + # itself, and then goes through ./scripts/deploy.sh with someone reading + # the diff. + - name: Deploy + if: inputs.action == 'deploy' + working-directory: infrastructure + run: pnpm exec cdk deploy ${{ inputs.stack }} ${{ steps.context.outputs.args }} --require-approval broadening --progress events diff --git a/AGENTS.md b/AGENTS.md index 212ef9c3d289..ef1eba237e1e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ Multica is a task management platform where people and agents collaborate on iss | `apps/desktop/` | Electron and desktop-only UI/state. Application navigation goes through `apps/desktop/src/renderer/src/platform/`. | | `apps/mobile/` | Independent Expo/React Native client: owns UI, state, hooks, providers, i18n, build, and release. Shares core types and pure utilities, including platform-independent schemas. | | `apps/docs/` | Fumadocs documentation site | +| `infrastructure/` | AWS CDK stack for the shared agent runtime machine. A workspace package for dependency management only; it imports nothing from the other packages and nothing imports it. | - Dependency direction is `views -> core + ui`; core and ui remain independent. Shared packages export raw TypeScript compiled by consuming apps. - Extract logic used by both web and desktop into the appropriate shared package. Keep framework/Electron APIs in the app layer; inject platform-specific UI through props/slots. @@ -39,11 +40,12 @@ Run these from the repository root: | Scope | Checks | | --- | --- | -| Frontend excluding mobile | `pnpm typecheck`, `pnpm lint`, `pnpm test` | +| Frontend excluding mobile and infrastructure | `pnpm typecheck`, `pnpm lint`, `pnpm test` | | Go backend | `make test` | | End-to-end | `pnpm exec playwright test` | | Combined web/backend verification | `make check` | | Mobile | Commands in [apps/mobile/AGENTS.md](apps/mobile/AGENTS.md#verification) | +| Infrastructure | `pnpm --filter @multica/infrastructure typecheck`, `... test`, `... synth` | Docs-only changes can use link/reference checks and `git diff --check`; state that code tests were not run. diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore new file mode 100644 index 000000000000..2e496872ff1d --- /dev/null +++ b/infrastructure/.gitignore @@ -0,0 +1,6 @@ +# CDK synthesis output +cdk.out +.cdk.staging + +# Deploy-time context cache (account-specific, never shared) +cdk.context.json diff --git a/infrastructure/README.md b/infrastructure/README.md new file mode 100644 index 000000000000..a5d08894fc90 --- /dev/null +++ b/infrastructure/README.md @@ -0,0 +1,333 @@ +# Infrastructure + +Two independent stacks. Deploy either on its own. + +| Stack | What it is | Deploy with | +| --- | --- | --- | +| `Multica-AgentRuntime` | One machine running the Multica daemon and the Claude Code runtimes agents execute on | `./scripts/deploy.sh` | +| `Multica-SelfHost` | Multica itself — web, API and PostgreSQL — on a fixed public IP, with every row of data in this AWS account | `./scripts/selfhost-deploy.sh` | + +Nothing in CloudFormation ties them together. Run the agent runtime against +hosted multica.ai, or point it at your own server with +`-c serverUrl=https://`. + +--- + +# Agent runtime + +One EC2 machine that runs the Multica daemon and the Claude Code runtimes our +agents execute on. + +## What gets created + +| Resource | Why | +| --- | --- | +| VPC, one AZ, `10.20.0.0/24` | Somewhere to put the instance. One AZ: the box holds no state a replacement would not re-create. | +| Security group, no ingress rules | The machine listens on nothing. The daemon dials out to Multica; Session Manager dials out to SSM. | +| EC2 instance, `t4g.medium` (Graviton, arm64) | Runs the daemon as the non-root `multica` user. | +| IAM role | Session Manager, Bedrock inference on Anthropic models, read on this machine's own SSM parameters, write to its own log group. Nothing else. | +| CloudWatch log group, 14-day retention | Daemon log and cloud-init output, shipped by the CloudWatch agent. | + +There is no key pair and no SSH. Operators connect with Session Manager, which +also gives a single place to audit who went in. + +Roughly **$30/month** in `ap-south-1`: `t4g.medium` ~$24, 40 GiB gp3 ~$3, +public IPv4 ~$4, logs under $1. Bedrock usage is billed per token on top, and +only when an agent falls back to the `claude-bedrock` runtime. + +## The two runtimes + +The machine installs Claude Code once and exposes it through two wrappers on +`PATH`. Each is the `command` of a [custom runtime +profile](https://multica.ai/docs/daemon-runtimes#custom-runtime-profiles) in +the workspace, so agents pick between them the same way they pick any runtime. + +| Wrapper | Authentication | Role | +| --- | --- | --- | +| `claude-max` | `CLAUDE_CODE_OAUTH_TOKEN` from the team's Claude subscription | primary | +| `claude-bedrock` | `CLAUDE_CODE_USE_BEDROCK=1` plus the instance role | fallback | + +Each wrapper unsets the other's credentials, so which one a run used is never +ambiguous. Adding a third provider, model or region later is one more wrapper +and one more profile — no change to this stack. + +Multica has no automatic fallback between runtimes today: when the subscription +hits its limit the run fails with `provider_quota_limit` and someone switches +the agent to `claude-bedrock` and retries. The runtime fallback chain that +would automate this is application work, tracked separately. + +## Credentials + +Three values live in SSM Parameter Store as `SecureString` parameters under +`/multica/agent-runtime`, created by `scripts/bootstrap.sh` and never by +CloudFormation — no secret enters a template, a change set or the CDK context. + +| Parameter | What it is | +| --- | --- | +| `multica-token` | Multica personal access token (`mul_…`). The daemon logs in with it, so its owner owns the registered runtimes and is the one who can make them public. | +| `claude-oauth-token` | Output of `claude setup-token` on a machine signed in to the subscription. | +| `github-token` | Fine-grained PAT or GitHub App installation token, limited to the agents' repositories. | + +On the instance nothing is written to the root volume: + +- `multica-fetch-secrets` pulls all three into `/run/multica` (tmpfs) on every + service start, mode `0400`, owned by the `multica` user. +- The Multica PAT reaches `multica login` on stdin, not as an argument, so it + stays out of `/proc/*/cmdline`. +- Git reads the GitHub token through a credential helper that reads tmpfs, so + there is no `~/.git-credentials`. + +Rotating a credential is `aws ssm put-parameter --overwrite` followed by +`sudo systemctl restart multica-daemon`. + +## Deploying + +Requirements: Node.js 22+, the AWS CLI, credentials for the target account, and +the [Session Manager +plugin](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html) +for connecting afterwards. + +```bash +pnpm install # from the repository root +cd infrastructure +./scripts/bootstrap.sh # CDK bootstrap + the three SSM parameters +./scripts/deploy.sh # shows a diff, asks, then deploys +./scripts/connect.sh # Session Manager shell on the machine +``` + +`deploy.sh` leaves `--require-approval` at CDK's default, so a change that +widens a security group or an IAM policy stops and asks before it is applied. + +First boot takes a few minutes. Watch it with `sudo cloud-init status --long` +on the machine, or read the `bootstrap` log stream in CloudWatch. + +### Context overrides + +```bash +./scripts/deploy.sh -c instanceType=t4g.large -c natGateway=true +``` + +| Context key | Default | Notes | +| --- | --- | --- | +| `instanceType` | `t4g.medium` | Graviton families only; the AMI is arm64 and synth rejects an x86 type. | +| `volumeSizeGb` | `40` | Checkouts plus `node_modules` for every concurrent run. | +| `natGateway` | `false` | See below. | +| `serverUrl` | `https://api.multica.ai` | Point at a self-hosted server instead. | +| `parameterPrefix` | `/multica/agent-runtime` | Must match what `bootstrap.sh` wrote. | +| `cliVersion` | `latest` | Pin a release tag (`v0.6.1`) for a reproducible build. | +| `nodeMajorVersion` | `22` | Node.js major Claude Code runs on. | +| `logRetentionDays` | `14` | Must be a CloudWatch retention period. | +| `maxConcurrentTasks` | `4` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`. Parallel runs share this machine's CPU, disk and the same subscription quota. | +| `daemonId` | empty, supplied at deploy | `MULTICA_DAEMON_ID`. Multica keys runtimes by it, so pinning it lets a replacement instance take over the same runtimes, with their visibility and agent bindings. Empty lets the daemon mint its own on first boot. | + +### Public subnet or NAT gateway + +By default the instance sits in a public subnet with a public IP, no inbound +rules, and no key pair. It reaches npm, GitHub, the Anthropic API and Multica +directly. A NAT gateway would add about $33/month plus data processing for a +box that already accepts no inbound traffic. + +`-c natGateway=true` moves it to a private subnet behind a NAT gateway if a +policy requires no instance to carry a public IP. Session Manager keeps working +either way, since the agent dials out. + +## Workspace setup after the first deploy + +The stack provisions the machine; the runtimes, agents and skills are workspace +configuration. Once the daemon is online (Multica → **Runtimes** shows +`multica-agent-runtime`): + +1. Create the two custom runtime profiles, from any machine signed in to the + workspace: + + ```bash + multica runtime profile create --runtime-type claude \ + --command-name claude-max --display-name "Claude (subscription)" + multica runtime profile create --runtime-type claude \ + --command-name claude-bedrock --display-name "Claude (Bedrock)" + ``` + + Only the machines that resolve the command on `PATH` register the runtime, + so these appear on this box and nowhere else. + +2. Mark both runtimes public, so every member's agents can use them. Only the + runtime's owner — the identity behind `multica-token` — can do that. + +3. Create the shared agents, bind the repositories to projects, and attach the + team's conventions as skills. + +## Operating it + +```bash +./scripts/connect.sh # Session Manager shell +sudo systemctl status multica-daemon +sudo tail -f /opt/multica/.multica/daemon.log # daemon log; CloudWatch stream /daemon +sudo tail -f /var/log/multica/daemon.log # unit output (secrets, login); stream /service +sudo systemctl restart multica-daemon # re-reads every credential +``` + +Changing `assets/bootstrap.sh` replaces the instance on the next deploy +(`userDataCausesReplacement`), which is the point: that script is the machine's +entire configuration, and an edit that only updated the launch template would +leave the running box behind. + +## Accepted risks + +- Agent runs skip approval prompts, so anyone who can assign an issue to a + shared agent can run commands on this machine. That is acceptable for a + trusted team on a box that holds no production credentials — the instance + role reaches Bedrock and its own parameters, nothing else. +- All shared agents draw on one Claude subscription, so expect to fall back to + Bedrock regularly. +- One instance, one AZ. If it is replaced, in-flight runs are lost; the issues + stay in Multica and can be reassigned. + +--- + +# Self-hosted Multica + +Multica running on your own infrastructure: the Go backend, the Next.js web +app and PostgreSQL, from this repository's own `docker-compose.selfhost.yml`, +behind Caddy on a fixed public IP. Every issue, comment, agent and run lives in +the PostgreSQL volume this stack creates, in this AWS account. Nothing is sent +to multica.ai — the backend's first-party deployment telemetry is off unless +`-c telemetry=true` turns it back on. + +## What gets created + +| Resource | Why | +| --- | --- | +| VPC, one AZ, `10.30.0.0/24` | Somewhere to put the instance. | +| Elastic IP | A fixed address the team can bookmark, and the hostname the certificate is issued for. | +| Security group | 443 for the app, 80 for the ACME challenge and the HTTPS redirect. Nothing else — PostgreSQL is never published, and the app's own ports stay on loopback inside the instance. | +| EC2 instance, `t4g.medium` | Runs Compose and Caddy. | +| EBS data volume, encrypted | PostgreSQL's data directory and uploaded attachments. Separate from the instance and **retained when the stack is deleted**. | +| Daily snapshots | Data Lifecycle Manager, 7 kept by default. | +| CloudWatch log group | Bootstrap output. | + +About **$33/month** in `ap-south-1`: `t4g.medium` ~$24, 30 GiB root + 20 GiB +data ~$5, Elastic IP ~$4, snapshots and logs under $1. + +## HTTPS without owning a domain + +A bare IP cannot have a trusted certificate, so by default the server answers +on `https://.sslip.io`. sslip.io is public wildcard DNS that resolves +any `1.2.3.4.sslip.io` to `1.2.3.4`, which gives Caddy a real hostname to get a +Let's Encrypt certificate for. You still reach the box at its own public IP; +only the name in the URL comes from elsewhere. + +To use your own name instead, point an A record at the Elastic IP and redeploy +with `-c domainName=multica.example.com`. + +## Deploying + +```bash +cd infrastructure +./scripts/selfhost-setup.sh # CDK bootstrap, and an optional Resend key +./scripts/selfhost-deploy.sh you@example.com,teammate@example.com +``` + +Two stores, one rule: **what CloudFormation generates lives in Secrets Manager, +what a human supplies lives in Parameter Store.** The JWT secret and the +database password are never read or typed by anyone, so the stack generates +them — a first deploy needs nothing created by hand, and neither value passes +through a shell history on its way in. The only human-supplied value is the +optional `resend-api-key` parameter. The database password is retained when +the stack is deleted, alongside the volume it opens. + +The allowlist argument is required. Port 443 is open to the internet, so +without `ALLOWED_EMAILS` anyone who found the URL could sign up; synth refuses +to build the stack without it. + +First boot pulls three images and completes an ACME challenge, so give it a few +minutes. The stack's `Url` output is where to go. + +### Logging in + +Multica authenticates with emailed verification codes. + +- With a Resend API key stored by `selfhost-setup.sh`, codes are emailed. +- Without one, the backend prints each code to its own log: + `./scripts/selfhost-apply.sh --code`. + +The guide's `MULTICA_DEV_VERIFICATION_CODE` shortcut is deliberately never set +here — a fixed code on an internet-facing host means anyone who knows an email +address can log in as its owner. + +## Operating it + +```bash +./scripts/selfhost-apply.sh --status # containers and proxy +./scripts/selfhost-apply.sh --logs # bootstrap log +./scripts/selfhost-apply.sh --code # latest login code +./scripts/selfhost-apply.sh # re-run the bootstrap in place +MULTICA_STACK=Multica-SelfHost ./scripts/connect.sh # a shell on the box +``` + +Unlike the agent runtime, this stack does **not** replace the instance when the +bootstrap script changes: the database volume is attached to it, and replacing +an instance out from under an attached volume is how people lose databases. +`cdk deploy` updates the launch template, and `selfhost-apply.sh` runs the new +script on the live machine. + +### Pointing agents at it + +```bash +./scripts/deploy.sh -c serverUrl=https:// +``` + +The daemon then registers its runtimes against your server instead of +multica.ai, and the PAT in `/multica/agent-runtime/multica-token` has to come +from your server's settings page. + +### Context overrides + +| Context key | Default | Notes | +| --- | --- | --- | +| `allowedEmails` | — | **Required.** Comma-separated addresses that may sign up. | +| `domainName` | sslip.io on the Elastic IP | Your own hostname; point an A record at the Elastic IP first. | +| `selfHostInstanceType` | `t4g.medium` | Graviton only. | +| `dataVolumeSizeGb` | `20` | PostgreSQL plus attachments. | +| `rootVolumeSizeGb` | `30` | OS and the three container images. | +| `snapshotRetentionDays` | `7` | Daily snapshots kept. | +| `imageTag` | `latest` | GHCR tag; pin a release for a reproducible deploy. | +| `telemetry` | `false` | `true` re-enables the first-party deployment telemetry. | + +## If you delete the stack + +The data volume is retained, so `cdk destroy` leaves the database behind along +with its snapshots and the SSM parameters. Attach the volume to a new instance, +or snapshot and delete it by hand once you are sure. + +## Layout + +``` +infrastructure/ +├── bin/app.ts CDK app: both stacks +├── lib/config.ts Context parsing and validation +├── lib/agent-runtime-stack.ts Agent runtime: VPC, SG, role, instance, logs +├── lib/self-host-stack.ts Self-host: EIP, SG, instance, data volume, snapshots +├── lib/bootstrap.ts Renders a script from assets/ +├── lib/retention.ts CloudWatch retention mapping +├── assets/bootstrap.sh Agent runtime first boot +├── assets/selfhost-bootstrap.sh Self-host first boot +├── test/ Assertions on the synthesized templates +└── scripts/ + ├── bootstrap.sh deploy.sh connect.sh destroy.sh agent runtime + └── selfhost-setup.sh selfhost-deploy.sh selfhost-apply.sh +``` + +`pnpm --filter @multica/infrastructure test` runs the template assertions — +no AWS credentials, no deploy. They cover the claims that are easy to get wrong +by hand. + +For the agent runtime: no inbound rules, no key pair, one security group, an +instance role that reaches only Bedrock, Parameter Store and its own log group, +an encrypted root volume, IMDSv2, and a bootstrap that starts the daemon as a +non-root user without writing a credential to disk. + +For the self-host: only 80 and 443 open and never 5432, 3000, 8080 or 22; a +database volume that is encrypted, separate from the instance and retained on +delete; daily snapshots; telemetry off; the signup allowlist reaching the +backend; the WebSocket route ahead of the catch-all; and no fixed development +verification code. diff --git a/infrastructure/assets/bootstrap.sh b/infrastructure/assets/bootstrap.sh new file mode 100644 index 000000000000..1e1c9a686a92 --- /dev/null +++ b/infrastructure/assets/bootstrap.sh @@ -0,0 +1,272 @@ +#!/bin/bash +# Bootstrap for the Multica agent runtime machine (Amazon Linux 2023, arm64). +# +# Rendered by lib/bootstrap.ts, which substitutes the marked placeholders +# below at synth time. Keeping the script in its own file lets it stay readable +# and syntax-checkable (the workflow runs `bash -n` over it). cloud-init +# runs it once, as root, on first boot; output lands in +# /var/log/cloud-init-output.log and is shipped to CloudWatch Logs. +# +# What it installs: +# - Node.js and Claude Code, the only agent CLI this machine runs +# - the Multica CLI, logged in from an SSM parameter and run as a systemd +# service under a non-root user +# - two wrapper commands, claude-max and claude-bedrock, that the workspace's +# custom runtime profiles resolve on PATH +# +# No credential is written to the root volume. Tokens are pulled from SSM +# Parameter Store into tmpfs (/run/multica) on every service start, owned by the +# runtime user and mode 0400, and git reads its token through a credential +# helper rather than from ~/.git-credentials. +set -euo pipefail + +REGION="@@REGION@@" +PARAM_PREFIX="@@PARAM_PREFIX@@" +SERVER_URL="@@SERVER_URL@@" +CLI_VERSION="@@CLI_VERSION@@" +NODE_MAJOR="@@NODE_MAJOR@@" +LOG_GROUP="@@LOG_GROUP@@" +MAX_CONCURRENT_TASKS="@@MAX_CONCURRENT_TASKS@@" + +RUNTIME_USER="multica" +RUNTIME_HOME="/opt/multica" + +log() { echo "[multica-bootstrap] $*"; } +trap 'log "FAILED on line ${LINENO}"' ERR + +log "Installing base packages" +dnf -y install git tar gzip jq amazon-cloudwatch-agent + +log "Installing Node.js ${NODE_MAJOR}" +curl -fsSL "https://rpm.nodesource.com/setup_${NODE_MAJOR}.x" -o /tmp/nodesource-setup.sh +bash /tmp/nodesource-setup.sh +rm -f /tmp/nodesource-setup.sh +dnf -y install nodejs + +log "Installing Claude Code" +npm install -g @anthropic-ai/claude-code + +log "Installing the Multica CLI" +if [ "${CLI_VERSION}" = "latest" ]; then + cli_tag="$(curl -fsSI https://github.com/multica-ai/multica/releases/latest | + grep -i '^location:' | sed 's|.*/tag/||' | tr -d '\r\n')" +else + cli_tag="${CLI_VERSION}" +fi +if [ -z "${cli_tag}" ]; then + log "could not resolve a Multica CLI release tag" + exit 1 +fi +cli_semver="${cli_tag#v}" +curl -fsSL \ + "https://github.com/multica-ai/multica/releases/download/${cli_tag}/multica-cli-${cli_semver}-linux-arm64.tar.gz" \ + -o /tmp/multica.tar.gz +tar -xzf /tmp/multica.tar.gz -C /tmp multica +install -m 0755 /tmp/multica /usr/local/bin/multica +rm -f /tmp/multica /tmp/multica.tar.gz +log "Installed Multica CLI ${cli_tag}" + +log "Creating the ${RUNTIME_USER} service account" +id -u "${RUNTIME_USER}" >/dev/null 2>&1 || + useradd --system --create-home --home-dir "${RUNTIME_HOME}" --shell /bin/bash "${RUNTIME_USER}" +install -d -m 0755 -o "${RUNTIME_USER}" -g "${RUNTIME_USER}" "${RUNTIME_HOME}/workspaces" + +# --------------------------------------------------------------------------- +# Runtime wrappers +# +# Each one is the `command` of a custom runtime profile in the Multica +# workspace. The daemon resolves it on PATH and appends its own protocol +# arguments after it. The heredocs are quoted, so nothing in a wrapper body is +# expanded while it is written; the only values baked in are the synth +# placeholders, which are already substituted by the time this runs. +# --------------------------------------------------------------------------- +log "Writing the runtime wrappers" +cat >/usr/local/bin/claude-max <<'WRAPPER' +#!/bin/bash +# Claude Code on the team's Claude subscription. Primary runtime. +set -euo pipefail +CLAUDE_CODE_OAUTH_TOKEN="$(cat /run/multica/claude-oauth-token)" +export CLAUDE_CODE_OAUTH_TOKEN +# Leave no ambiguity about which credential wins: no Bedrock, no Vertex, no key. +unset CLAUDE_CODE_USE_BEDROCK CLAUDE_CODE_USE_VERTEX ANTHROPIC_API_KEY +exec claude "$@" +WRAPPER +chmod 0755 /usr/local/bin/claude-max + +cat >/usr/local/bin/claude-bedrock <<'WRAPPER' +#!/bin/bash +# Claude Code on Amazon Bedrock through the instance role. Fallback runtime for +# when the subscription has hit its limit. +set -euo pipefail +export CLAUDE_CODE_USE_BEDROCK=1 +export AWS_REGION="@@REGION@@" +unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY +exec claude "$@" +WRAPPER +chmod 0755 /usr/local/bin/claude-bedrock + +# --------------------------------------------------------------------------- +# Credentials +# --------------------------------------------------------------------------- +log "Writing the credential helpers" +install -d -m 0755 /usr/local/libexec + +cat >/usr/local/libexec/multica-fetch-secrets <<'FETCH' +#!/bin/bash +# Pulls the runtime's credentials from SSM Parameter Store into tmpfs. Runs as +# root from systemd ExecStartPre, before the daemon starts, on every start. +set -euo pipefail +REGION="@@REGION@@" +PARAM_PREFIX="@@PARAM_PREFIX@@" +RUNTIME_USER="multica" + +install -d -m 0750 -o "${RUNTIME_USER}" -g "${RUNTIME_USER}" /run/multica + +fetch() { + local name="$1" dest="$2" value + value="$(aws ssm get-parameter --region "${REGION}" --name "${PARAM_PREFIX}/${name}" \ + --with-decryption --query Parameter.Value --output text)" + if [ -z "${value}" ] || [ "${value}" = "None" ]; then + echo "SSM parameter ${PARAM_PREFIX}/${name} is empty" >&2 + exit 1 + fi + # printf rather than redirecting the command output: --output text appends a + # newline that would otherwise travel into the token. + printf '%s' "${value}" >"${dest}.tmp" + chown "${RUNTIME_USER}:${RUNTIME_USER}" "${dest}.tmp" + chmod 0400 "${dest}.tmp" + mv "${dest}.tmp" "${dest}" +} + +fetch multica-token /run/multica/multica-token +fetch claude-oauth-token /run/multica/claude-oauth-token +fetch github-token /run/multica/github-token +FETCH +chmod 0750 /usr/local/libexec/multica-fetch-secrets + +cat >/usr/local/libexec/multica-git-credential <<'GITCRED' +#!/bin/bash +# git credential helper. Serves the GitHub token from tmpfs so it never lands +# in ~/.git-credentials on the root volume. +set -euo pipefail +[ "${1:-}" = "get" ] || exit 0 +printf 'username=x-access-token\npassword=%s\n' "$(cat /run/multica/github-token)" +GITCRED +chmod 0755 /usr/local/libexec/multica-git-credential + +git config --system credential.https://github.com.helper /usr/local/libexec/multica-git-credential +git config --system user.name "Multica Agent" +git config --system user.email "multica-agent@users.noreply.github.com" + +cat >/usr/local/libexec/multica-login <<'LOGIN' +#!/bin/bash +# Points the CLI at the server and signs in with the PAT from tmpfs. Runs as the +# runtime user from systemd ExecStartPre; re-running it is an idempotent +# refresh. The token arrives on stdin, not argv, so it stays out of +# /proc/*/cmdline. +set -euo pipefail +SERVER_URL="@@SERVER_URL@@" +multica config set server_url "${SERVER_URL}" +multica login --token /etc/systemd/system/multica-daemon.service <<'UNIT' +[Unit] +Description=Multica agent daemon +Documentation=https://github.com/multica-ai/multica/blob/main/CLI_AND_DAEMON.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=multica +Group=multica +WorkingDirectory=/opt/multica +Environment=HOME=/opt/multica +Environment=PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin +Environment=MULTICA_WORKSPACES_ROOT=/opt/multica/workspaces +Environment=MULTICA_DAEMON_MAX_CONCURRENT_TASKS=@@MAX_CONCURRENT_TASKS@@ +Environment=MULTICA_DAEMON_DEVICE_NAME=multica-agent-runtime +# Pinned so a replacement instance takes over the same runtime rows (and their +# visibility and agent bindings). Empty falls back to ~/.multica/daemon.id. +Environment=MULTICA_DAEMON_ID=@@DAEMON_ID@@ +# "+" runs this one as root: it writes the tmpfs credentials that the service +# user then reads. +ExecStartPre=+/usr/local/libexec/multica-fetch-secrets +ExecStartPre=/usr/local/libexec/multica-login +ExecStart=/usr/local/bin/multica daemon start --foreground +Restart=always +RestartSec=10 +NoNewPrivileges=true +LogsDirectory=multica +StandardOutput=append:/var/log/multica/daemon.log +StandardError=append:/var/log/multica/daemon.log + +[Install] +WantedBy=multi-user.target +UNIT + +# The unit's own output: credential fetch, login, and anything the daemon +# prints before its logger is up. Without a terminal the daemon logs to +# ${RUNTIME_HOME}/.multica/daemon.log, which it rotates itself. +cat >/etc/logrotate.d/multica-daemon <<'ROTATE' +/var/log/multica/daemon.log { + daily + rotate 7 + missingok + notifempty + compress + copytruncate +} +ROTATE + +log "Configuring the CloudWatch agent" +install -d -m 0755 /opt/aws/amazon-cloudwatch-agent/etc +cat >/opt/aws/amazon-cloudwatch-agent/etc/multica.json <.sslip.io to , so a deployment with no domain of +# its own still gets a real hostname and therefore a real certificate. +if [ -n "${DOMAIN_NAME}" ]; then + PUBLIC_HOST="${DOMAIN_NAME}" +else + PUBLIC_HOST="${PUBLIC_IP}.sslip.io" +fi +PUBLIC_URL="https://${PUBLIC_HOST}" +log "Public URL will be ${PUBLIC_URL}" + +log "Installing base packages" +dnf -y install docker jq amazon-cloudwatch-agent + +log "Installing the Docker Compose plugin ${COMPOSE_VERSION}" +install -d -m 0755 /usr/libexec/docker/cli-plugins +curl -fsSL \ + "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-aarch64" \ + -o /usr/libexec/docker/cli-plugins/docker-compose +chmod 0755 /usr/libexec/docker/cli-plugins/docker-compose + +systemctl enable --now docker + +# --------------------------------------------------------------------------- +# Database volume +# +# The attachment is a separate EBS volume so that replacing this instance does +# not take the database with it. NVMe renames devices, so find it by the +# attachment name EC2 reports rather than by a /dev path. +# --------------------------------------------------------------------------- +log "Preparing the data volume" +# On Nitro the volume is an NVMe device; AL2023's udev rules also create the +# requested name as a symlink to it, and `test -b` follows symlinks. The wait +# is because the attachment can land after cloud-init has already started. +data_device="" +for _ in $(seq 1 30); do + if [ -b "${DATA_DEVICE_NAME}" ]; then + data_device="$(readlink -f "${DATA_DEVICE_NAME}")" + break + fi + sleep 2 +done +if [ -z "${data_device}" ]; then + log "data volume ${DATA_DEVICE_NAME} never appeared" + exit 1 +fi +log "Data volume is ${data_device}" + +# blkid exits non-zero on an unformatted device, which is exactly how a brand +# new volume looks. Only an empty device is formatted; a volume carrying an +# existing database is mounted as-is. +if ! blkid "${data_device}" >/dev/null 2>&1; then + log "Formatting ${data_device} (first boot for this volume)" + mkfs.ext4 -L multica-data "${data_device}" +fi + +install -d -m 0755 "${DATA_DIR}" +data_uuid="$(blkid -s UUID -o value "${data_device}")" +if ! grep -q "${data_uuid}" /etc/fstab; then + printf 'UUID=%s %s ext4 defaults,nofail 0 2\n' "${data_uuid}" "${DATA_DIR}" >>/etc/fstab +fi +mount -a +install -d -m 0700 "${DATA_DIR}/postgres" +install -d -m 0755 "${DATA_DIR}/uploads" + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +# Two stores, one rule: what CloudFormation generates lives in Secrets Manager, +# what a human supplies lives in Parameter Store. Nobody ever sees or types the +# JWT secret or the database password, so the stack generates them and a first +# deploy needs nothing created by hand. +log "Reading the generated secrets" +read_secret() { + aws secretsmanager get-secret-value --region "${REGION}" --secret-id "$1" \ + --query SecretString --output text +} + +jwt_secret="$(read_secret "${JWT_SECRET_ID}")" +postgres_password="$(read_secret "${DB_PASSWORD_ID}")" + +# Email is genuinely optional: without it the backend prints each login code to +# its own log, which scripts/selfhost-login-code.sh reads. get-parameters (not +# get-parameter) reports a missing name instead of failing the call. +resend_api_key="$(aws ssm get-parameters --region "${REGION}" \ + --names "${PARAM_PREFIX}/resend-api-key" --with-decryption \ + --query 'Parameters[0].Value' --output text)" +if [ "${resend_api_key}" = "None" ]; then + resend_api_key="" + log "No resend-api-key parameter; login codes will go to the backend log" +fi + +install -d -m 0750 "${APP_DIR}" +log "Downloading the compose file" +aws s3 cp "${COMPOSE_URL}" "${APP_DIR}/docker-compose.yml" --region "${REGION}" + +# The compose file publishes to 127.0.0.1 only and takes every setting from the +# environment. Written 0600: it holds the JWT secret and the database password. +log "Writing ${APP_DIR}/.env" +umask 077 +cat >"${APP_DIR}/.env" <"${APP_DIR}/docker-compose.override.yml" </dev/null 2>&1 || + useradd --system --home-dir /var/lib/caddy --create-home --shell /usr/sbin/nologin caddy +install -d -m 0755 /etc/caddy + +cat >"/etc/caddy/Caddyfile" </etc/systemd/system/caddy.service <<'UNIT' +[Unit] +Description=Caddy +Documentation=https://caddyserver.com/docs/ +After=network-online.target +Wants=network-online.target + +[Service] +Type=notify +User=caddy +Group=caddy +ExecStart=/usr/local/bin/caddy run --environ --config /etc/caddy/Caddyfile +ExecReload=/usr/local/bin/caddy reload --config /etc/caddy/Caddyfile --force +TimeoutStopSec=5s +LimitNOFILE=1048576 +PrivateTmp=true +ProtectSystem=full +# Binding 80 and 443 as a non-root user. +AmbientCapabilities=CAP_NET_BIND_SERVICE +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +UNIT + +# --------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------- +log "Configuring the CloudWatch agent" +install -d -m 0755 /opt/aws/amazon-cloudwatch-agent/etc +cat >/opt/aws/amazon-cloudwatch-agent/etc/multica.json </etc/systemd/system/multica.service <<'UNIT' +[Unit] +Description=Multica self-hosted stack +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=true +WorkingDirectory=/opt/multica +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl daemon-reload +systemctl enable multica.service +systemctl enable --now caddy.service + +log "Bootstrap complete: ${PUBLIC_URL}" diff --git a/infrastructure/bin/app.ts b/infrastructure/bin/app.ts new file mode 100644 index 000000000000..fed13f5ab829 --- /dev/null +++ b/infrastructure/bin/app.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; + +import { AgentRuntimeStack } from '../lib/agent-runtime-stack'; +import { resolveConfig, resolveSelfHostConfig } from '../lib/config'; +import { SelfHostStack } from '../lib/self-host-stack'; + +const app = new cdk.App(); + +// Region and account come from the ambient AWS configuration. Both stay +// undefined when no credentials are present, which is what lets `cdk synth` +// run in CI: the template is then region-agnostic. +const env = { + account: process.env.CDK_DEFAULT_ACCOUNT, + region: process.env.CDK_DEFAULT_REGION ?? process.env.AWS_REGION, +}; + +// The two stacks are independent and deploy separately. The runtime machine +// can point at multica.ai or at the self-hosted server through its serverUrl +// context value; nothing in CloudFormation ties them together, so neither one +// has to exist for the other to work. +const stacks: cdk.Stack[] = [ + new AgentRuntimeStack(app, 'Multica-AgentRuntime', { + env, + description: 'Shared machine that runs the Multica agent daemon and its Claude Code runtimes', + config: resolveConfig(app), + }), +]; + +// Self-hosting is opt-in: the stack requires an email allowlist, so building +// it unconditionally would fail every synth that only wants the agent runtime. +if (app.node.tryGetContext('selfHost') !== undefined) { + stacks.push( + new SelfHostStack(app, 'Multica-SelfHost', { + env, + description: 'Self-hosted Multica: web, API and PostgreSQL behind Caddy on a fixed public IP', + config: resolveSelfHostConfig(app), + }), + ); +} + +for (const stack of stacks) { + cdk.Tags.of(stack).add('Application', 'Multica'); + cdk.Tags.of(stack).add('ManagedBy', 'CDK'); + cdk.Tags.of(stack).add('Component', stack.stackName.replace('Multica-', '')); +} + +app.synth(); diff --git a/infrastructure/cdk.json b/infrastructure/cdk.json new file mode 100644 index 000000000000..da8615f6986b --- /dev/null +++ b/infrastructure/cdk.json @@ -0,0 +1,68 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/app.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false + } +} diff --git a/infrastructure/lib/agent-runtime-stack.ts b/infrastructure/lib/agent-runtime-stack.ts new file mode 100644 index 000000000000..68f284e50fda --- /dev/null +++ b/infrastructure/lib/agent-runtime-stack.ts @@ -0,0 +1,202 @@ +import * as cdk from 'aws-cdk-lib'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import type { Construct } from 'constructs'; + +import { renderScript } from './bootstrap'; +import type { AgentRuntimeConfig } from './config'; +import { resolveRetention } from './retention'; + +export interface AgentRuntimeStackProps extends cdk.StackProps { + config: AgentRuntimeConfig; +} + +/** + * The shared machine Multica agents run on. + * + * One EC2 instance, one IAM role, one log group, and a VPC to put them in. + * There is no load balancer, database, CDN or container registry here on + * purpose: Multica is used as a hosted service, and this stack only supplies + * the compute its daemon claims runs on. + * + * Access and credentials: + * - no inbound rules and no key pair; operators connect with Session Manager + * - the instance role can invoke Anthropic models on Bedrock and read this + * machine's own SSM parameters, and nothing else + * - the three credentials (Multica PAT, Claude OAuth token, GitHub token) + * are SecureString parameters created outside CloudFormation, so no secret + * value ever enters a template, a change set or the CDK context + */ +export class AgentRuntimeStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: AgentRuntimeStackProps) { + super(scope, id, props); + const { config } = props; + + // One AZ. The box holds no state worth preserving — a replacement + // re-checks-out every repository — so paying for a second subnet's worth of + // redundancy buys nothing. + const vpc = new ec2.Vpc(this, 'Vpc', { + ipAddresses: ec2.IpAddresses.cidr('10.20.0.0/24'), + maxAzs: 1, + natGateways: config.natGateway ? 1 : 0, + subnetConfiguration: config.natGateway + ? [ + { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 26 }, + { name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: 26 }, + ] + : [{ name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 26 }], + }); + + // Egress only. The machine listens on nothing: the daemon dials out to + // Multica over WebSocket and Session Manager dials out to SSM, so there is + // no ingress rule to write — not even one for SSH. + const securityGroup = new ec2.SecurityGroup(this, 'RuntimeSecurityGroup', { + vpc, + description: 'Multica agent runtime: outbound only, no listeners', + allowAllOutbound: true, + }); + + const logGroup = new logs.LogGroup(this, 'RuntimeLogs', { + logGroupName: `/multica/${this.stackName}`, + retention: resolveRetention(config.logRetentionDays), + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const role = new iam.Role(this, 'RuntimeRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + description: 'Multica agent runtime: Bedrock inference, own SSM parameters, own log group', + managedPolicies: [ + // Session Manager. This is the only way in, so it is also the only + // place to audit access from. + iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'), + ], + }); + + // The fallback runtime's entire AWS surface: invoke Anthropic models. + // Foundation-model ARNs stay region-wildcarded because a cross-region + // inference profile routes a request to whichever region has capacity, and + // the caller needs invoke rights on the model in each of them. + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'InvokeAnthropicOnBedrock', + actions: ['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream', 'bedrock:CountTokens'], + resources: [ + 'arn:aws:bedrock:*::foundation-model/anthropic.*', + `arn:aws:bedrock:*:${this.account}:inference-profile/*anthropic.*`, + ], + }), + ); + // Model discovery for the runtime's model picker. Read-only and listing + // only, which is why it is a separate statement from the invoke grant. + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'DiscoverBedrockModels', + actions: [ + 'bedrock:ListFoundationModels', + 'bedrock:GetFoundationModel', + 'bedrock:ListInferenceProfiles', + 'bedrock:GetInferenceProfile', + ], + resources: ['*'], + }), + ); + + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'ReadRuntimeCredentials', + actions: ['ssm:GetParameter', 'ssm:GetParameters'], + resources: [ + `arn:aws:ssm:${this.region}:${this.account}:parameter${config.parameterPrefix}/*`, + ], + }), + ); + // SecureString parameters are encrypted with the account's aws/ssm key. + // The ViaService condition keeps this from becoming a general decrypt + // grant: the role can only use the key through Parameter Store. + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'DecryptRuntimeCredentials', + actions: ['kms:Decrypt'], + resources: ['*'], + conditions: { StringEquals: { 'kms:ViaService': `ssm.${this.region}.amazonaws.com` } }, + }), + ); + + logGroup.grantWrite(role); + // The CloudWatch agent calls CreateLogGroup and DescribeLogStreams on + // start, before it writes anything. Without these it logs an access error + // and ships nothing — the gap the previous revision shipped with. + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'CloudWatchAgentBookkeeping', + actions: ['logs:CreateLogGroup', 'logs:DescribeLogStreams'], + resources: [logGroup.logGroupArn, `${logGroup.logGroupArn}:*`], + }), + ); + + const userData = ec2.UserData.custom( + renderScript('bootstrap.sh', { + REGION: this.region, + PARAM_PREFIX: config.parameterPrefix, + SERVER_URL: config.serverUrl, + CLI_VERSION: config.cliVersion, + NODE_MAJOR: config.nodeMajorVersion, + LOG_GROUP: logGroup.logGroupName, + MAX_CONCURRENT_TASKS: String(config.maxConcurrentTasks), + DAEMON_ID: config.daemonId, + }), + ); + + const instance = new ec2.Instance(this, 'Runtime', { + vpc, + vpcSubnets: { + subnetType: config.natGateway ? ec2.SubnetType.PRIVATE_WITH_EGRESS : ec2.SubnetType.PUBLIC, + }, + instanceType: new ec2.InstanceType(config.instanceType), + machineImage: ec2.MachineImage.latestAmazonLinux2023({ + cpuType: ec2.AmazonLinuxCpuType.ARM_64, + }), + securityGroup, + role, + userData, + // Bootstrap changes are the whole configuration of this machine, so they + // have to reach it. Without this a user data edit updates the launch + // template and nothing else. + userDataCausesReplacement: true, + requireImdsv2: true, + detailedMonitoring: false, + blockDevices: [ + { + deviceName: '/dev/xvda', + volume: ec2.BlockDeviceVolume.ebs(config.volumeSizeGb, { + volumeType: ec2.EbsDeviceVolumeType.GP3, + encrypted: true, + deleteOnTermination: true, + }), + }, + ], + }); + cdk.Tags.of(instance).add('Name', 'multica-agent-runtime'); + + // Outputs carry no exportName: nothing imports them, and an unnamespaced + // export is what collides with the next stack in the account. + new cdk.CfnOutput(this, 'InstanceId', { + value: instance.instanceId, + description: 'Agent runtime instance', + }); + new cdk.CfnOutput(this, 'ConnectCommand', { + value: `aws ssm start-session --region ${this.region} --target ${instance.instanceId}`, + description: 'Open a shell on the runtime machine', + }); + new cdk.CfnOutput(this, 'LogGroupName', { + value: logGroup.logGroupName, + description: 'Daemon and bootstrap logs', + }); + new cdk.CfnOutput(this, 'ParameterPrefix', { + value: config.parameterPrefix, + description: 'SSM prefix the runtime reads its credentials from', + }); + } +} + diff --git a/infrastructure/lib/bootstrap.ts b/infrastructure/lib/bootstrap.ts new file mode 100644 index 000000000000..b7ab3decd746 --- /dev/null +++ b/infrastructure/lib/bootstrap.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Reads a script from assets/ and substitutes its @@-marked placeholders. + * + * The scripts live in their own files rather than in template literals so they + * can be read, reviewed and syntax-checked as shell (see the workflow's + * `bash -n` step). Values may be CDK tokens: the rendered string goes through + * Fn::Base64, so a token resolves to a CloudFormation intrinsic and the + * instance receives the real value. + */ +export function renderScript(fileName: string, substitutions: Record): string { + const source = readFileSync(join(__dirname, '..', 'assets', fileName), 'utf8'); + const rendered = source.replace(/@@([A-Z_]+)@@/g, (_match, name: string) => { + const value = substitutions[name]; + if (value === undefined) { + throw new Error(`assets/${fileName} references unknown placeholder @@${name}@@`); + } + return value; + }); + + // A leftover marker means the asset and its substitution map have drifted + // apart, which would ship a machine that configures itself with the literal + // text. + const leftover = rendered.match(/@@\w+@@/); + if (leftover) { + throw new Error(`assets/${fileName} still contains ${leftover[0]} after rendering`); + } + return rendered; +} diff --git a/infrastructure/lib/config.ts b/infrastructure/lib/config.ts new file mode 100644 index 000000000000..b182796406d0 --- /dev/null +++ b/infrastructure/lib/config.ts @@ -0,0 +1,230 @@ +/** + * Configuration for the Multica agent runtime machine. + * + * There is one environment on purpose. Multica itself runs on + * https://multica.ai, so nothing here hosts the web app, the Go server, or a + * database — this stack only provides the Linux box that runs the Multica + * daemon and the Claude Code runtimes it registers. + * + * Every value is overridable through CDK context so a deploy can change the + * shape of the machine without a code change: + * + * cdk deploy -c instanceType=t4g.large -c natGateway=true + */ +import type { App } from 'aws-cdk-lib'; + +export interface AgentRuntimeConfig { + /** EC2 instance type. Graviton only — the AMI is arm64. */ + instanceType: string; + /** Root EBS volume size in GiB. Git checkouts plus node_modules add up. */ + volumeSizeGb: number; + /** + * When true the instance moves to a private subnet behind a NAT gateway. + * Default false: the instance sits in a public subnet with no inbound rules + * and reaches the internet directly, which saves the NAT gateway's ~$33/mo. + * Either way there is no SSH and no listener — access is Session Manager. + */ + natGateway: boolean; + /** Multica API the daemon logs in to. */ + serverUrl: string; + /** SSM Parameter Store prefix holding the runtime's credentials. */ + parameterPrefix: string; + /** Multica CLI release tag, or 'latest' to resolve the newest release. */ + cliVersion: string; + /** Node.js major version Claude Code runs on. */ + nodeMajorVersion: string; + /** CloudWatch Logs retention in days. */ + logRetentionDays: number; + /** MULTICA_DAEMON_MAX_CONCURRENT_TASKS for the daemon. */ + maxConcurrentTasks: number; + /** + * MULTICA_DAEMON_ID for the daemon. The server keys runtimes by daemon id, + * and without a pin the daemon mints a random one on first boot, so every + * instance replacement would register new, private runtimes and orphan the + * public ones agents are bound to. Empty leaves the daemon's own id. + */ + daemonId: string; +} + +const defaults: AgentRuntimeConfig = { + instanceType: 't4g.medium', + volumeSizeGb: 40, + natGateway: false, + serverUrl: 'https://api.multica.ai', + parameterPrefix: '/multica/agent-runtime', + cliVersion: 'latest', + nodeMajorVersion: '22', + logRetentionDays: 14, + maxConcurrentTasks: 4, + daemonId: '', +}; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** + * Graviton instance families. The machine image is arm64, so an x86 instance + * type would boot the wrong AMI architecture and fail at launch with a message + * that does not say why. Reject it at synth instead. + */ +const GRAVITON_FAMILY = /^[a-z]+\d+g[a-z]*$/; + +export function resolveConfig(app: App): AgentRuntimeConfig { + const config: AgentRuntimeConfig = { + instanceType: contextString(app, 'instanceType', defaults.instanceType), + volumeSizeGb: contextNumber(app, 'volumeSizeGb', defaults.volumeSizeGb), + natGateway: contextBoolean(app, 'natGateway', defaults.natGateway), + serverUrl: contextString(app, 'serverUrl', defaults.serverUrl), + parameterPrefix: contextString(app, 'parameterPrefix', defaults.parameterPrefix), + cliVersion: contextString(app, 'cliVersion', defaults.cliVersion), + nodeMajorVersion: contextString(app, 'nodeMajorVersion', defaults.nodeMajorVersion), + logRetentionDays: contextNumber(app, 'logRetentionDays', defaults.logRetentionDays), + maxConcurrentTasks: contextNumber(app, 'maxConcurrentTasks', defaults.maxConcurrentTasks), + daemonId: contextString(app, 'daemonId', defaults.daemonId), + }; + + const family = config.instanceType.split('.')[0] ?? ''; + if (!GRAVITON_FAMILY.test(family)) { + throw new Error( + `instanceType ${config.instanceType} is not a Graviton (arm64) type; the machine image is arm64. Use t4g/m7g/c7g/r7g or similar.`, + ); + } + if (!config.parameterPrefix.startsWith('/') || config.parameterPrefix.endsWith('/')) { + throw new Error(`parameterPrefix must start with "/" and not end with one, got ${config.parameterPrefix}`); + } + if (!/^https:\/\//.test(config.serverUrl)) { + throw new Error(`serverUrl must be https, got ${config.serverUrl}`); + } + if (config.volumeSizeGb < 20) { + throw new Error(`volumeSizeGb must be at least 20, got ${config.volumeSizeGb}`); + } + if (config.maxConcurrentTasks < 1) { + throw new Error(`maxConcurrentTasks must be at least 1, got ${config.maxConcurrentTasks}`); + } + if (config.daemonId !== '' && !UUID.test(config.daemonId)) { + throw new Error(`daemonId must be a lowercase UUID, got ${config.daemonId}`); + } + return config; +} + +function contextString(app: App, key: string, fallback: string): string { + const value = app.node.tryGetContext(key); + return value === undefined ? fallback : String(value); +} + +function contextNumber(app: App, key: string, fallback: number): number { + const value = app.node.tryGetContext(key); + if (value === undefined) { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`context ${key} must be a number, got ${String(value)}`); + } + return parsed; +} + +function contextBoolean(app: App, key: string, fallback: boolean): boolean { + const value = app.node.tryGetContext(key); + if (value === undefined) { + return fallback; + } + // `-c natGateway=true` arrives as the string "true". + return value === true || value === 'true' || value === '1'; +} + +/** + * Configuration for the self-hosted Multica server. + * + * This is the stack that runs Multica itself — the Go backend, the Next.js + * web app and PostgreSQL — from the repository's own + * `docker-compose.selfhost.yml`, behind Caddy on a fixed public IP. All issue, + * agent and run data lives in the PostgreSQL volume this stack creates, in + * this AWS account. + */ +export interface SelfHostConfig { + instanceType: string; + /** Root volume in GiB: OS plus the three container images. */ + rootVolumeSizeGb: number; + /** PostgreSQL volume in GiB. A separate volume, retained on stack delete. */ + dataVolumeSizeGb: number; + /** Daily snapshots of the data volume to keep. */ + snapshotRetentionDays: number; + /** + * Public hostname. Empty means derive it from the Elastic IP through + * sslip.io, which resolves .sslip.io to and so lets Caddy issue a + * real certificate without owning a domain. + */ + domainName: string; + /** Multica image tag from GHCR. */ + imageTag: string; + /** Who may create an account. Required: this host is on the open internet. */ + allowedEmails: string; + /** + * First-party deployment telemetry to telemetry.multica.ai. Off by default: + * a self-host exists so the data stays here. + */ + telemetry: boolean; + parameterPrefix: string; + logRetentionDays: number; + caddyVersion: string; + composeVersion: string; +} + +const selfHostDefaults: SelfHostConfig = { + instanceType: 't4g.medium', + rootVolumeSizeGb: 30, + dataVolumeSizeGb: 20, + snapshotRetentionDays: 7, + domainName: '', + imageTag: 'latest', + allowedEmails: '', + telemetry: false, + parameterPrefix: '/multica/self-host', + logRetentionDays: 14, + caddyVersion: '2.10.0', + composeVersion: 'v2.32.4', +}; + +export function resolveSelfHostConfig(app: App): SelfHostConfig { + const config: SelfHostConfig = { + instanceType: contextString(app, 'selfHostInstanceType', selfHostDefaults.instanceType), + rootVolumeSizeGb: contextNumber(app, 'rootVolumeSizeGb', selfHostDefaults.rootVolumeSizeGb), + dataVolumeSizeGb: contextNumber(app, 'dataVolumeSizeGb', selfHostDefaults.dataVolumeSizeGb), + snapshotRetentionDays: contextNumber(app, 'snapshotRetentionDays', selfHostDefaults.snapshotRetentionDays), + domainName: contextString(app, 'domainName', selfHostDefaults.domainName), + imageTag: contextString(app, 'imageTag', selfHostDefaults.imageTag), + allowedEmails: contextString(app, 'allowedEmails', selfHostDefaults.allowedEmails), + telemetry: contextBoolean(app, 'telemetry', selfHostDefaults.telemetry), + parameterPrefix: contextString(app, 'selfHostParameterPrefix', selfHostDefaults.parameterPrefix), + logRetentionDays: contextNumber(app, 'logRetentionDays', selfHostDefaults.logRetentionDays), + caddyVersion: contextString(app, 'caddyVersion', selfHostDefaults.caddyVersion), + composeVersion: contextString(app, 'composeVersion', selfHostDefaults.composeVersion), + }; + + const family = config.instanceType.split('.')[0] ?? ''; + if (!GRAVITON_FAMILY.test(family)) { + throw new Error( + `selfHostInstanceType ${config.instanceType} is not a Graviton (arm64) type; the machine image is arm64.`, + ); + } + // Port 443 is open to the internet, so an open signup form would let anyone + // who finds the URL create an account in the workspace. + if (!config.allowedEmails.trim()) { + throw new Error( + 'allowedEmails is required: this server is reachable from the internet, and without an allowlist anyone who finds the URL can sign up. Pass -c allowedEmails=you@example.com,teammate@example.com', + ); + } + if (config.domainName && !/^[a-z0-9.-]+$/i.test(config.domainName)) { + throw new Error(`domainName ${config.domainName} is not a hostname`); + } + if (!config.parameterPrefix.startsWith('/') || config.parameterPrefix.endsWith('/')) { + throw new Error(`selfHostParameterPrefix must start with "/" and not end with one, got ${config.parameterPrefix}`); + } + if (config.rootVolumeSizeGb < 20) { + throw new Error(`rootVolumeSizeGb must be at least 20, got ${config.rootVolumeSizeGb}`); + } + if (config.dataVolumeSizeGb < 10) { + throw new Error(`dataVolumeSizeGb must be at least 10, got ${config.dataVolumeSizeGb}`); + } + return config; +} diff --git a/infrastructure/lib/retention.ts b/infrastructure/lib/retention.ts new file mode 100644 index 000000000000..95a77a4fa486 --- /dev/null +++ b/infrastructure/lib/retention.ts @@ -0,0 +1,20 @@ +import * as logs from 'aws-cdk-lib/aws-logs'; + +/** + * Maps a day count to the RetentionDays enum CloudWatch accepts. + * + * Retention is not a free-form number. Silently rounding a rejected value to + * the nearest accepted one would hide the mistake until the API call failed + * mid-deploy, so an unusable value fails at synth and names the alternatives. + */ +export function resolveRetention(days: number): logs.RetentionDays { + const match = Object.entries(logs.RetentionDays).find(([, value]) => value === days); + if (!match) { + const allowed = Object.values(logs.RetentionDays) + .filter((value): value is number => typeof value === 'number') + .sort((a, b) => a - b) + .join(', '); + throw new Error(`logRetentionDays ${days} is not a CloudWatch retention period; allowed: ${allowed}`); + } + return match[1] as logs.RetentionDays; +} diff --git a/infrastructure/lib/self-host-stack.ts b/infrastructure/lib/self-host-stack.ts new file mode 100644 index 000000000000..13ab66e34780 --- /dev/null +++ b/infrastructure/lib/self-host-stack.ts @@ -0,0 +1,265 @@ +import { join } from 'node:path'; + +import * as cdk from 'aws-cdk-lib'; +import * as dlm from 'aws-cdk-lib/aws-dlm'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as s3assets from 'aws-cdk-lib/aws-s3-assets'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import type { Construct } from 'constructs'; + +import { renderScript } from './bootstrap'; +import type { SelfHostConfig } from './config'; +import { resolveRetention } from './retention'; + +export interface SelfHostStackProps extends cdk.StackProps { + config: SelfHostConfig; +} + +/** EC2 device name for the PostgreSQL volume. */ +const DATA_DEVICE = '/dev/sdf'; + +/** + * Multica, self-hosted. + * + * One instance running the repository's own `docker-compose.selfhost.yml` — + * PostgreSQL, the Go backend and the Next.js web app — behind Caddy, which + * terminates TLS and forwards to the loopback ports Compose publishes. A fixed + * Elastic IP makes the address stable, and with no domain of its own the host + * is reached through sslip.io so Let's Encrypt can still issue a certificate. + * + * Every row of issue, agent and run data is in the PostgreSQL volume this + * stack creates, in this account. The volume is separate from the instance and + * retained when the stack is deleted, and the backend's first-party telemetry + * is off unless `-c telemetry=true` turns it back on. + */ +export class SelfHostStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: SelfHostStackProps) { + super(scope, id, props); + const { config } = props; + + const vpc = new ec2.Vpc(this, 'Vpc', { + ipAddresses: ec2.IpAddresses.cidr('10.30.0.0/24'), + maxAzs: 1, + natGateways: 0, + subnetConfiguration: [{ name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: 26 }], + }); + + // 80 and 443 only. 80 is not decoration: Let's Encrypt's HTTP-01 challenge + // arrives there, and Caddy redirects everything else to HTTPS. The app's + // own ports (3000, 8080) stay bound to loopback inside the instance, and + // PostgreSQL is never published at all. + const securityGroup = new ec2.SecurityGroup(this, 'ServerSecurityGroup', { + vpc, + description: 'Multica self-host: HTTP and HTTPS only', + allowAllOutbound: true, + }); + securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'ACME challenge and HTTPS redirect'); + securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Multica web and API'); + + const logGroup = new logs.LogGroup(this, 'ServerLogs', { + logGroupName: `/multica/${this.stackName}`, + retention: resolveRetention(config.logRetentionDays), + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const role = new iam.Role(this, 'ServerRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + description: 'Multica self-host: own SSM parameters and own log group', + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')], + }); + // Two stores, one rule: what CloudFormation generates lives in Secrets + // Manager, what a human supplies lives in Parameter Store. + // + // Nobody ever reads or types these two, so generating them removes the + // step most likely to be skipped or fumbled on a first deploy, and keeps + // the values out of every shell history and terminal scrollback on the way + // in. Punctuation is excluded because the password is interpolated into a + // postgres:// URL, where an unescaped @ or / would silently point the + // backend at the wrong host. + const jwtSecret = new secretsmanager.Secret(this, 'JwtSecret', { + description: 'Multica self-host: signs session tokens', + generateSecretString: { excludePunctuation: true, passwordLength: 64 }, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + const databasePassword = new secretsmanager.Secret(this, 'DatabasePassword', { + description: 'Multica self-host: PostgreSQL password', + generateSecretString: { excludePunctuation: true, passwordLength: 32 }, + // Retained with the volume it belongs to: a database kept without its + // password is a database nobody can open. + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + jwtSecret.grantRead(role); + databasePassword.grantRead(role); + + // Human-supplied configuration, optional: the Resend API key. + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'ReadServerConfiguration', + actions: ['ssm:GetParameter', 'ssm:GetParameters'], + resources: [`arn:aws:ssm:${this.region}:${this.account}:parameter${config.parameterPrefix}/*`], + }), + ); + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'DecryptServerConfiguration', + actions: ['kms:Decrypt'], + resources: ['*'], + conditions: { StringEquals: { 'kms:ViaService': `ssm.${this.region}.amazonaws.com` } }, + }), + ); + logGroup.grantWrite(role); + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'CloudWatchAgentBookkeeping', + actions: ['logs:CreateLogGroup', 'logs:DescribeLogStreams'], + resources: [logGroup.logGroupArn, `${logGroup.logGroupArn}:*`], + }), + ); + + // The compose file ships as an asset rather than being fetched from a + // branch at boot, so the stack runs the reviewed file and `cdk diff` shows + // when it changes. + const composeFile = new s3assets.Asset(this, 'ComposeFile', { + path: join(__dirname, '..', '..', 'docker-compose.selfhost.yml'), + }); + composeFile.grantRead(role); + + // A fixed address the team can bookmark, and — through sslip.io — the + // hostname Caddy requests a certificate for. Allocated before the instance + // so the bootstrap can build the URL from it. + const address = new ec2.CfnEIP(this, 'Address', { domain: 'vpc' }); + + const userData = ec2.UserData.custom( + renderScript('selfhost-bootstrap.sh', { + REGION: this.region, + PARAM_PREFIX: config.parameterPrefix, + JWT_SECRET_ID: jwtSecret.secretArn, + DB_PASSWORD_ID: databasePassword.secretArn, + COMPOSE_URL: composeFile.s3ObjectUrl, + DOMAIN_NAME: config.domainName, + PUBLIC_IP: address.ref, + IMAGE_TAG: config.imageTag, + ALLOWED_EMAILS: config.allowedEmails, + // The backend reads any non-empty value as "do not send". + DO_NOT_TRACK: config.telemetry ? '' : '1', + LOG_GROUP: logGroup.logGroupName, + CADDY_VERSION: config.caddyVersion, + COMPOSE_VERSION: config.composeVersion, + DATA_DEVICE_NAME: DATA_DEVICE, + }), + ); + + const instance = new ec2.Instance(this, 'Server', { + vpc, + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, + instanceType: new ec2.InstanceType(config.instanceType), + machineImage: ec2.MachineImage.latestAmazonLinux2023({ cpuType: ec2.AmazonLinuxCpuType.ARM_64 }), + securityGroup, + role, + userData, + requireImdsv2: true, + detailedMonitoring: false, + blockDevices: [ + { + deviceName: '/dev/xvda', + volume: ec2.BlockDeviceVolume.ebs(config.rootVolumeSizeGb, { + volumeType: ec2.EbsDeviceVolumeType.GP3, + encrypted: true, + deleteOnTermination: true, + }), + }, + ], + // Deliberately NOT userDataCausesReplacement, unlike the agent runtime: + // replacing this instance while an EBS volume is attached to it is a way + // to lose the database. Bootstrap changes are applied with + // ./scripts/selfhost-apply.sh, which re-runs the script in place. + }); + cdk.Tags.of(instance).add('Name', 'multica-self-host'); + + // Separate from the instance and retained on stack delete: this is the + // database. Deleting the stack leaves it behind to be attached to a new + // instance or snapshotted and removed by hand. + const dataVolume = new ec2.Volume(this, 'DataVolume', { + availabilityZone: instance.instanceAvailabilityZone, + size: cdk.Size.gibibytes(config.dataVolumeSizeGb), + volumeType: ec2.EbsDeviceVolumeType.GP3, + encrypted: true, + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + cdk.Tags.of(dataVolume).add('Name', 'multica-self-host-data'); + // Tagged for the snapshot policy below to find it. + cdk.Tags.of(dataVolume).add('Backup', 'multica-self-host'); + + new ec2.CfnVolumeAttachment(this, 'DataVolumeAttachment', { + device: DATA_DEVICE, + instanceId: instance.instanceId, + volumeId: dataVolume.volumeId, + }); + + new ec2.CfnEIPAssociation(this, 'AddressAssociation', { + allocationId: address.attrAllocationId, + instanceId: instance.instanceId, + }); + + this.addSnapshotPolicy(config, dataVolume); + + const publicHost = config.domainName || `${address.ref}.sslip.io`; + new cdk.CfnOutput(this, 'Url', { + value: `https://${publicHost}`, + description: 'Open Multica here', + }); + new cdk.CfnOutput(this, 'PublicIp', { value: address.ref, description: 'Fixed public IP' }); + new cdk.CfnOutput(this, 'InstanceId', { value: instance.instanceId, description: 'Server instance' }); + new cdk.CfnOutput(this, 'DataVolumeId', { + value: dataVolume.volumeId, + description: 'PostgreSQL volume, retained when the stack is deleted', + }); + new cdk.CfnOutput(this, 'LogGroupName', { value: logGroup.logGroupName, description: 'Bootstrap logs' }); + } + + /** Daily snapshots of the database volume, through Data Lifecycle Manager. */ + private addSnapshotPolicy(config: SelfHostConfig, volume: ec2.IVolume): void { + const role = new iam.Role(this, 'SnapshotRole', { + assumedBy: new iam.ServicePrincipal('dlm.amazonaws.com'), + description: 'Multica self-host: daily snapshots of the database volume', + }); + role.addToPolicy( + new iam.PolicyStatement({ + sid: 'ManageSnapshots', + actions: [ + 'ec2:CreateSnapshot', + 'ec2:DeleteSnapshot', + 'ec2:DescribeVolumes', + 'ec2:DescribeSnapshots', + 'ec2:CreateTags', + ], + resources: ['*'], + }), + ); + + const policy = new dlm.CfnLifecyclePolicy(this, 'SnapshotPolicy', { + // DLM rejects anything outside [0-9A-Za-z _-] here, commas included. + description: `Multica self-host database ${config.snapshotRetentionDays} daily snapshots`, + state: 'ENABLED', + executionRoleArn: role.roleArn, + policyDetails: { + resourceTypes: ['VOLUME'], + targetTags: [{ key: 'Backup', value: 'multica-self-host' }], + schedules: [ + { + name: 'daily', + createRule: { interval: 24, intervalUnit: 'HOURS', times: ['03:00'] }, + retainRule: { count: config.snapshotRetentionDays }, + copyTags: true, + }, + ], + }, + }); + // The policy matches by tag, so it must not be created before the volume + // carries the tag it matches on. + policy.node.addDependency(volume); + } +} + diff --git a/infrastructure/package.json b/infrastructure/package.json new file mode 100644 index 000000000000..811eddf50291 --- /dev/null +++ b/infrastructure/package.json @@ -0,0 +1,26 @@ +{ + "name": "@multica/infrastructure", + "version": "0.0.0", + "description": "AWS CDK stack for the shared Multica agent runtime machine", + "private": true, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "cdk": "cdk", + "synth": "cdk synth", + "diff": "cdk diff", + "deploy": "cdk deploy", + "destroy": "cdk destroy" + }, + "dependencies": { + "aws-cdk-lib": "^2.170.0", + "constructs": "^10.4.2" + }, + "devDependencies": { + "@types/node": "catalog:", + "aws-cdk": "^2.1000.0", + "ts-node": "^10.9.2", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/infrastructure/scripts/bootstrap.sh b/infrastructure/scripts/bootstrap.sh new file mode 100755 index 000000000000..301736ad8667 --- /dev/null +++ b/infrastructure/scripts/bootstrap.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# One-time preparation of an AWS account for this stack: +# 1. bootstrap the CDK toolkit in the target region +# 2. create the three SecureString parameters the runtime reads at start +# +# The parameter values are deliberately not in CloudFormation or in CDK +# context. They are typed in here, encrypted by Parameter Store, and read by +# the instance role at runtime. +# +# Usage: ./scripts/bootstrap.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +PARAM_PREFIX="${MULTICA_PARAM_PREFIX:-/multica/agent-runtime}" + +command -v aws >/dev/null || { echo "aws CLI is required" >&2; exit 1; } +command -v npx >/dev/null || { echo "Node.js (npx) is required" >&2; exit 1; } + +account="$(aws sts get-caller-identity --query Account --output text)" +region="${AWS_REGION:-$(aws configure get region)}" +if [ -z "${region}" ]; then + echo "No region configured. Set AWS_REGION or run 'aws configure'." >&2 + exit 1 +fi + +echo "Account: ${account}" +echo "Region: ${region}" +echo + +echo "==> Bootstrapping the CDK toolkit" +npx cdk bootstrap "aws://${account}/${region}" + +put_parameter() { + local name="$1" description="$2" value + local path="${PARAM_PREFIX}/${name}" + + if aws ssm get-parameter --region "${region}" --name "${path}" >/dev/null 2>&1; then + echo "==> ${path} already exists, leaving it alone" + return + fi + + echo + echo "==> ${path}" + echo " ${description}" + read -rsp " Value (input hidden): " value + echo + if [ -z "${value}" ]; then + echo " Empty value; the daemon will not start without it." >&2 + exit 1 + fi + aws ssm put-parameter --region "${region}" --name "${path}" --type SecureString --value "${value}" >/dev/null + echo " stored" +} + +put_parameter multica-token \ + "Multica personal access token (mul_...), from https://multica.ai/settings?tab=tokens" +put_parameter claude-oauth-token \ + "Claude subscription token, from 'claude setup-token' on a signed-in machine" +put_parameter github-token \ + "GitHub token limited to the agents' repositories (fine-grained PAT or App installation token)" + +echo +echo "Done. Deploy with ./scripts/deploy.sh" diff --git a/infrastructure/scripts/connect.sh b/infrastructure/scripts/connect.sh new file mode 100755 index 000000000000..1565f485fba2 --- /dev/null +++ b/infrastructure/scripts/connect.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Opens a Session Manager shell on the runtime machine. There is no SSH path +# onto the box and no key pair, so this is the way in. +# +# Needs the Session Manager plugin for the AWS CLI: +# https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html +# +# Usage: ./scripts/connect.sh +set -euo pipefail + +STACK="${MULTICA_STACK:-Multica-AgentRuntime}" + +region="${AWS_REGION:-$(aws configure get region)}" +instance="$(aws cloudformation describe-stacks \ + --region "${region}" \ + --stack-name "${STACK}" \ + --query "Stacks[0].Outputs[?OutputKey=='InstanceId'].OutputValue" \ + --output text)" + +if [ -z "${instance}" ] || [ "${instance}" = "None" ]; then + echo "Could not read InstanceId from stack ${STACK} in ${region}." >&2 + exit 1 +fi + +echo "Connecting to ${instance}. Useful once inside:" +echo " sudo systemctl status multica-daemon" +echo " sudo tail -f /opt/multica/.multica/daemon.log # daemon log" +echo " sudo tail -f /var/log/multica/daemon.log # unit output (secrets, login)" +echo " sudo cloud-init status --long # bootstrap result" +echo +exec aws ssm start-session --region "${region}" --target "${instance}" diff --git a/infrastructure/scripts/deploy.sh b/infrastructure/scripts/deploy.sh new file mode 100755 index 000000000000..7d65c2483a3a --- /dev/null +++ b/infrastructure/scripts/deploy.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Deploys the agent runtime stack. +# +# Any extra arguments go through to `cdk deploy`, so context overrides work: +# ./scripts/deploy.sh -c instanceType=t4g.large +# +# Approval is left at CDK's default (`broadening`): a change that widens a +# security group or an IAM policy stops and asks. That is the one prompt worth +# keeping, so this script does not pass --require-approval never. +set -euo pipefail + +cd "$(dirname "$0")/.." + +command -v aws >/dev/null || { echo "aws CLI is required" >&2; exit 1; } + +region="${AWS_REGION:-$(aws configure get region)}" +echo "Account: $(aws sts get-caller-identity --query Account --output text)" +echo "Region: ${region}" +echo + +echo "==> Changes" +npx cdk diff "$@" + +echo +read -rp "Deploy these changes? (y/N) " reply +case "${reply}" in + y | Y) ;; + *) + echo "Cancelled." + exit 0 + ;; +esac + +npx cdk deploy --progress events "$@" diff --git a/infrastructure/scripts/destroy.sh b/infrastructure/scripts/destroy.sh new file mode 100755 index 000000000000..32ca6289e278 --- /dev/null +++ b/infrastructure/scripts/destroy.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Tears the runtime machine down. +# +# What this removes: the instance, its VPC, the IAM role and the log group. +# What it keeps: the SSM parameters, which are not CloudFormation resources. +# Delete those by hand if the account is being retired. +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "This destroys the agent runtime machine. In-flight agent runs are lost;" +echo "the issues they were working on stay in Multica and can be reassigned." +echo +read -rp "Type 'destroy' to continue: " reply +if [ "${reply}" != "destroy" ]; then + echo "Cancelled." + exit 0 +fi + +npx cdk destroy "$@" + +echo +echo "The SSM parameters under /multica/agent-runtime were left in place." diff --git a/infrastructure/scripts/selfhost-apply.sh b/infrastructure/scripts/selfhost-apply.sh new file mode 100755 index 000000000000..b9f333417ebf --- /dev/null +++ b/infrastructure/scripts/selfhost-apply.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Day-to-day operations on the running self-hosted server, over Session +# Manager. No SSH, no open port. +# +# ./scripts/selfhost-apply.sh re-run the bootstrap in place +# ./scripts/selfhost-apply.sh --logs tail the bootstrap log +# ./scripts/selfhost-apply.sh --code print the latest login code +# ./scripts/selfhost-apply.sh --status container and proxy status +# +# Why a re-run rather than an instance replacement: the database volume is +# attached to this instance, so the stack deliberately does not replace it when +# the bootstrap script changes. `cdk deploy` updates the launch template; this +# takes the new script from there and runs it on the live machine. The script +# is idempotent — it reformats nothing that already holds a filesystem. +set -euo pipefail + +cd "$(dirname "$0")/.." + +STACK="${MULTICA_SELFHOST_STACK:-Multica-SelfHost}" +region="${AWS_REGION:-$(aws configure get region)}" + +stack_output() { + aws cloudformation describe-stacks --region "${region}" --stack-name "${STACK}" \ + --query "Stacks[0].Outputs[?OutputKey=='$1'].OutputValue" --output text +} + +instance="$(stack_output InstanceId)" +if [ -z "${instance}" ] || [ "${instance}" = "None" ]; then + echo "Could not read InstanceId from stack ${STACK} in ${region}." >&2 + exit 1 +fi + +run_remote() { + local description="$1" command="$2" command_id + command_id="$(aws ssm send-command --region "${region}" \ + --instance-ids "${instance}" \ + --document-name AWS-RunShellScript \ + --comment "${description}" \ + --parameters "commands=[\"${command}\"]" \ + --query Command.CommandId --output text)" + + aws ssm wait command-executed --region "${region}" \ + --command-id "${command_id}" --instance-id "${instance}" 2>/dev/null || true + + aws ssm get-command-invocation --region "${region}" \ + --command-id "${command_id}" --instance-id "${instance}" \ + --query '[StandardOutputContent,StandardErrorContent]' --output text +} + +case "${1:-}" in + --logs) + run_remote "bootstrap log" "tail -n 200 /var/log/cloud-init-output.log" + ;; + --code) + # Only relevant when no Resend key is configured: the backend prints the + # verification code to its own log instead of emailing it. + run_remote "login code" \ + "cd /opt/multica && docker compose logs --tail 400 backend | grep -i 'verification code' | tail -5" + ;; + --status) + run_remote "status" \ + "cd /opt/multica && docker compose ps && systemctl is-active caddy multica" + ;; + "") + echo "Re-running the bootstrap on ${instance}." + echo "Containers restart, so the app is briefly unavailable." + read -rp "Continue? (y/N) " reply + case "${reply}" in + y | Y) ;; + *) echo "Cancelled."; exit 0 ;; + esac + + launch_template="$(aws ec2 describe-instances --region "${region}" \ + --instance-ids "${instance}" \ + --query 'Reservations[0].Instances[0].Tags[?Key==`aws:ec2launchtemplate:id`].Value' \ + --output text)" + if [ -z "${launch_template}" ] || [ "${launch_template}" = "None" ]; then + echo "Could not find the launch template for ${instance}." >&2 + exit 1 + fi + + user_data="$(aws ec2 describe-launch-template-versions --region "${region}" \ + --launch-template-id "${launch_template}" --versions '$Latest' \ + --query 'LaunchTemplateVersions[0].LaunchTemplateData.UserData' --output text)" + + # The script travels as base64 so quoting and newlines survive the trip + # through the SSM parameter. + run_remote "re-run bootstrap" \ + "echo '${user_data}' | base64 -d > /root/bootstrap.sh && bash /root/bootstrap.sh" + ;; + *) + echo "Unknown option: $1" >&2 + sed -n '2,10p' "$0" >&2 + exit 1 + ;; +esac diff --git a/infrastructure/scripts/selfhost-deploy.sh b/infrastructure/scripts/selfhost-deploy.sh new file mode 100755 index 000000000000..b99f0a996750 --- /dev/null +++ b/infrastructure/scripts/selfhost-deploy.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Deploys the self-hosted Multica server. +# +# Usage: +# ./scripts/selfhost-deploy.sh you@example.com,teammate@example.com +# ./scripts/selfhost-deploy.sh you@example.com -c domainName=multica.example.com +# +# The first argument is the signup allowlist, and it is required: port 443 is +# open to the internet, so without it anyone who finds the URL could create an +# account. Anything after it is passed through to `cdk deploy`. +# +# Approval stays at CDK's default, so a change that widens a security group or +# an IAM policy stops and asks. +set -euo pipefail + +cd "$(dirname "$0")/.." + +if [ $# -lt 1 ]; then + echo "Usage: $0 [cdk args...]" >&2 + echo " e.g. $0 you@example.com,teammate@example.com" >&2 + exit 1 +fi + +allowed_emails="$1" +shift + +region="${AWS_REGION:-$(aws configure get region)}" +echo "Account: $(aws sts get-caller-identity --query Account --output text)" +echo "Region: ${region}" +echo "Signup allowlist: ${allowed_emails}" +echo + +context=(-c selfHost=true -c "allowedEmails=${allowed_emails}") + +echo "==> Changes" +npx cdk diff Multica-SelfHost "${context[@]}" "$@" + +echo +read -rp "Deploy these changes? (y/N) " reply +case "${reply}" in + y | Y) ;; + *) + echo "Cancelled." + exit 0 + ;; +esac + +npx cdk deploy Multica-SelfHost --progress events "${context[@]}" "$@" + +echo +echo "First boot pulls three container images and asks Let's Encrypt for a" +echo "certificate, so give it a few minutes before the URL answers." +echo "Watch it with: ./scripts/selfhost-apply.sh --logs" diff --git a/infrastructure/scripts/selfhost-setup.sh b/infrastructure/scripts/selfhost-setup.sh new file mode 100755 index 000000000000..d22cf51f1fde --- /dev/null +++ b/infrastructure/scripts/selfhost-setup.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# One-time preparation for the self-hosted Multica server. +# +# There is very little to do here. The JWT secret and the database password are +# generated by the stack itself, in Secrets Manager, so nothing has to be +# created by hand before a first deploy. What is left is the CDK toolkit and +# one optional value a human has to supply. +# +# Usage: ./scripts/selfhost-setup.sh +set -euo pipefail + +cd "$(dirname "$0")/.." + +PARAM_PREFIX="${MULTICA_SELFHOST_PARAM_PREFIX:-/multica/self-host}" + +command -v aws >/dev/null || { echo "aws CLI is required" >&2; exit 1; } + +account="$(aws sts get-caller-identity --query Account --output text)" +region="${AWS_REGION:-$(aws configure get region)}" +if [ -z "${region}" ]; then + echo "No region configured. Set AWS_REGION or run 'aws configure'." >&2 + exit 1 +fi + +echo "Account: ${account}" +echo "Region: ${region}" +echo + +echo "==> Bootstrapping the CDK toolkit" +npx cdk bootstrap "aws://${account}/${region}" + +echo +echo "Optional: email delivery for login codes." +echo "Without it the backend prints each code to its own log, which" +echo "./scripts/selfhost-apply.sh --code reads for you." +read -rp "Store a Resend API key now? (y/N) " reply +case "${reply}" in + y | Y) + read -rsp " Resend API key (input hidden): " resend_key + echo + if [ -n "${resend_key}" ]; then + aws ssm put-parameter --region "${region}" --name "${PARAM_PREFIX}/resend-api-key" \ + --type SecureString --value "${resend_key}" --overwrite >/dev/null + echo " stored" + fi + ;; + *) echo " skipped" ;; +esac + +echo +echo "Done. Deploy with:" +echo " ./scripts/selfhost-deploy.sh you@example.com,teammate@example.com" diff --git a/infrastructure/test/agent-runtime-stack.test.ts b/infrastructure/test/agent-runtime-stack.test.ts new file mode 100644 index 000000000000..23e3ad687af8 --- /dev/null +++ b/infrastructure/test/agent-runtime-stack.test.ts @@ -0,0 +1,269 @@ +// @vitest-environment node +import { App } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { describe, expect, it } from 'vitest'; + +import cdkJson from '../cdk.json'; +import { AgentRuntimeStack } from '../lib/agent-runtime-stack'; +import { resolveConfig } from '../lib/config'; + +// Synthesizing is the expensive part of this file — seconds for the first one +// on a cold runner, because it stages the asset behind the default-security- +// group custom resource. Every case below asserts against one of four +// configurations, so each is built once and shared. +const templates = new Map(); + +function synth(context: Record = {}): { template: Template } { + const key = JSON.stringify(context); + const cached = templates.get(key); + if (cached) { + return { template: cached }; + } + + // cdk.json's feature flags decide what the deployed template looks like, so + // a test that skips them asserts against a template nobody deploys. + const app = new App({ context: { ...cdkJson.context, ...context } }); + const stack = new AgentRuntimeStack(app, 'Multica-AgentRuntime', { + env: { account: '123456789012', region: 'ap-south-1' }, + config: resolveConfig(app), + }); + + // Nothing is cached for a configuration that fails validation: those throw + // before this line, and re-running them is cheap. + const template = Template.fromStack(stack); + templates.set(key, template); + return { template }; +} + +function userData(template: Template): string { + const instances = template.findResources('AWS::EC2::Instance'); + const resource = Object.values(instances)[0]; + // User data renders as Fn::Base64 over an Fn::Join of literals and + // intrinsics; the literals are all we need to assert on. + const joined = resource?.Properties?.UserData?.['Fn::Base64']?.['Fn::Join']?.[1] ?? []; + return (joined as unknown[]).filter((part): part is string => typeof part === 'string').join(''); +} + +describe('config', () => { + it('rejects an x86 instance type, which would not boot the arm64 image', () => { + expect(() => synth({ instanceType: 't3.small' })).toThrow(/not a Graviton/); + }); + + it('rejects a parameter prefix that is not an absolute SSM path', () => { + expect(() => synth({ parameterPrefix: 'multica/agent-runtime' })).toThrow(/must start with/); + }); + + it('rejects a daemon id that is not a UUID', () => { + expect(() => synth({ daemonId: 'multica-agent-runtime' })).toThrow(/must be a lowercase UUID/); + }); + + it('rejects a retention period CloudWatch does not accept', () => { + expect(() => synth({ logRetentionDays: '13' })).toThrow(/not a CloudWatch retention period/); + }); +}); + +describe('network', () => { + it('opens no inbound port at all', () => { + const { template } = synth(); + const groups = template.findResources('AWS::EC2::SecurityGroup'); + for (const group of Object.values(groups)) { + expect(group.Properties?.SecurityGroupIngress).toBeUndefined(); + } + expect(Object.keys(template.findResources('AWS::EC2::SecurityGroupIngress'))).toHaveLength(0); + }); + + it('creates exactly one security group, used by the instance', () => { + const { template } = synth(); + // The previous revision declared three security groups in a network stack + // that nothing used, plus one more per stack that did. + expect(Object.keys(template.findResources('AWS::EC2::SecurityGroup'))).toHaveLength(1); + template.hasResourceProperties('AWS::EC2::Instance', { + SecurityGroupIds: Match.anyValue(), + }); + }); + + it('skips the NAT gateway by default', () => { + const { template } = synth(); + expect(Object.keys(template.findResources('AWS::EC2::NatGateway'))).toHaveLength(0); + }); + + it('adds a NAT gateway and a private subnet when asked', () => { + const { template } = synth({ natGateway: 'true' }); + expect(Object.keys(template.findResources('AWS::EC2::NatGateway'))).toHaveLength(1); + template.hasResourceProperties('AWS::EC2::Subnet', { + MapPublicIpOnLaunch: false, + }); + }); + + it('attaches no key pair, so there is no SSH path onto the box', () => { + const { template } = synth(); + const instances = Object.values(template.findResources('AWS::EC2::Instance')); + expect(instances).toHaveLength(1); + expect(instances[0]?.Properties?.KeyName).toBeUndefined(); + }); +}); + +describe('instance role', () => { + it('grants Bedrock invoke only on Anthropic models', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith(['bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream']), + Resource: Match.arrayWith(['arn:aws:bedrock:*::foundation-model/anthropic.*']), + }), + ]), + }), + }); + }); + + it('reaches no service beyond Bedrock, Parameter Store and its own log group', () => { + const { template } = synth(); + const inline = Object.entries(template.findResources('AWS::IAM::Policy')).filter(([id]) => + id.startsWith('RuntimeRoleDefaultPolicy'), + ); + expect(inline).toHaveLength(1); + + const services = new Set(); + for (const statement of inline[0]![1].Properties?.PolicyDocument?.Statement ?? []) { + for (const action of [statement.Action].flat()) { + if (typeof action === 'string') { + services.add(action.split(':')[0]!); + } + } + } + expect([...services].sort()).toEqual(['bedrock', 'kms', 'logs', 'ssm']); + }); + + it('scopes the SSM read to the runtime parameter prefix', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith(['ssm:GetParameter']), + Resource: 'arn:aws:ssm:ap-south-1:123456789012:parameter/multica/agent-runtime/*', + }), + ]), + }), + }); + }); + + it('confines the KMS decrypt grant to Parameter Store', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'kms:Decrypt', + Condition: { StringEquals: { 'kms:ViaService': 'ssm.ap-south-1.amazonaws.com' } }, + }), + ]), + }), + }); + }); + + it('attaches Session Manager access', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::IAM::Role', { + ManagedPolicyArns: Match.arrayWith([ + Match.objectLike({ + 'Fn::Join': Match.arrayWith([ + Match.arrayWith([':iam::aws:policy/AmazonSSMManagedInstanceCore']), + ]), + }), + ]), + }); + }); +}); + +describe('instance', () => { + it('encrypts the root volume', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::EC2::Instance', { + BlockDeviceMappings: Match.arrayWith([ + Match.objectLike({ Ebs: Match.objectLike({ Encrypted: true, VolumeType: 'gp3' }) }), + ]), + }); + }); + + it('requires IMDSv2', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::EC2::LaunchTemplate', { + LaunchTemplateData: Match.objectLike({ + MetadataOptions: Match.objectLike({ HttpTokens: 'required' }), + }), + }); + }); +}); + +describe('bootstrap', () => { + it('installs both runtime wrappers and starts the daemon service', () => { + const script = userData(synth().template); + expect(script).toContain('/usr/local/bin/claude-max'); + expect(script).toContain('/usr/local/bin/claude-bedrock'); + expect(script).toContain('multica daemon start --foreground'); + expect(script).toContain('systemctl enable --now multica-daemon.service'); + }); + + it('runs the daemon as a non-root user', () => { + const script = userData(synth().template); + expect(script).toContain('User=multica'); + expect(script).not.toContain('User=root'); + }); + + it('creates the daemon log directory before the unit that appends to it', () => { + // systemd opens StandardOutput=append: before running the root ("+") + // ExecStartPre, and LogsDirectory= is not set up for that step. Without + // the directory the unit fails with 209/STDOUT on every start. + const script = userData(synth().template); + const mkdir = script.indexOf('install -d -m 0750 -o "${RUNTIME_USER}" -g "${RUNTIME_USER}" /var/log/multica'); + expect(mkdir).toBeGreaterThan(-1); + expect(mkdir).toBeLessThan(script.indexOf('systemctl enable --now multica-daemon.service')); + }); + + it('ships the log the daemon itself writes, not only the unit output', () => { + // Without a terminal the CLI routes its logger to ~/.multica/daemon.log; + // the unit's stdout only ever sees the login step. + const script = userData(synth().template); + expect(script).toContain('"file_path": "${RUNTIME_HOME}/.multica/daemon.log"'); + expect(script).toContain('"file_path": "/var/log/multica/daemon.log"'); + }); + + it('pins the daemon id, so a replacement instance keeps the same runtimes', () => { + // A deployment passes its own id with -c daemonId=; the value is not + // committed, because it names one particular installation's runtimes. + const daemonId = '3f1b4f4e-8b2a-4a1e-9f3c-2d5e6a7b8c9d'; + expect(userData(synth({ daemonId }).template)).toContain( + `Environment=MULTICA_DAEMON_ID=${daemonId}`, + ); + }); + + it('leaves the daemon id empty when none is given, so the daemon mints one', () => { + expect(userData(synth().template)).toMatch(/^Environment=MULTICA_DAEMON_ID=$/m); + }); + + it('substitutes every placeholder', () => { + expect(userData(synth().template)).not.toContain('@@'); + }); + + it('keeps credentials out of the root volume and out of argv', () => { + const script = userData(synth().template); + // Tokens are read from tmpfs at use time... + expect(script).toContain('/run/multica/claude-oauth-token'); + expect(script).toContain('install -d -m 0750 -o "${RUNTIME_USER}"'); + // ...the PAT reaches `multica login` on stdin, not as an argument... + expect(script).toContain('multica login --token \s*\S*\.git-credentials/); + }); + + it('carries the configured server URL and concurrency into the unit file', () => { + const script = userData(synth({ serverUrl: 'https://multica.example.com', maxConcurrentTasks: '2' }).template); + expect(script).toContain('SERVER_URL="https://multica.example.com"'); + expect(script).toContain('MULTICA_DAEMON_MAX_CONCURRENT_TASKS=2'); + }); +}); diff --git a/infrastructure/test/self-host-stack.test.ts b/infrastructure/test/self-host-stack.test.ts new file mode 100644 index 000000000000..0216f05f7a96 --- /dev/null +++ b/infrastructure/test/self-host-stack.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment node +import { App } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { describe, expect, it } from 'vitest'; + +import cdkJson from '../cdk.json'; +import { resolveSelfHostConfig } from '../lib/config'; +import { SelfHostStack } from '../lib/self-host-stack'; + +const baseContext = { allowedEmails: 'owner@example.com' }; + +// One synth per configuration, shared across cases. See the note in +// agent-runtime-stack.test.ts: the first one is slow enough to matter. +const templates = new Map(); + +function synth(context: Record = {}): { template: Template } { + const merged = { ...baseContext, ...context }; + const key = JSON.stringify(merged); + const cached = templates.get(key); + if (cached) { + return { template: cached }; + } + + const app = new App({ context: { ...cdkJson.context, ...merged } }); + const stack = new SelfHostStack(app, 'Multica-SelfHost', { + env: { account: '123456789012', region: 'ap-south-1' }, + config: resolveSelfHostConfig(app), + }); + const template = Template.fromStack(stack); + templates.set(key, template); + return { template }; +} + +function userData(template: Template): string { + const resource = Object.values(template.findResources('AWS::EC2::Instance'))[0]; + const joined = resource?.Properties?.UserData?.['Fn::Base64']?.['Fn::Join']?.[1] ?? []; + return (joined as unknown[]).filter((part): part is string => typeof part === 'string').join(''); +} + +describe('config', () => { + it('refuses to build without an email allowlist', () => { + const app = new App({ context: cdkJson.context }); + expect(() => resolveSelfHostConfig(app)).toThrow(/allowedEmails is required/); + }); + + it('rejects an x86 instance type', () => { + expect(() => synth({ selfHostInstanceType: 'm5.large' })).toThrow(/not a Graviton/); + }); + + it('rejects a domain that is not a hostname', () => { + expect(() => synth({ domainName: 'https://multica.example.com/' })).toThrow(/is not a hostname/); + }); +}); + +describe('network', () => { + it('opens only 80 and 443', () => { + const { template } = synth(); + const groups = Object.values(template.findResources('AWS::EC2::SecurityGroup')); + expect(groups).toHaveLength(1); + const ports = (groups[0]?.Properties?.SecurityGroupIngress ?? []).map( + (rule: { FromPort: number; ToPort: number; CidrIp: string }) => [rule.FromPort, rule.ToPort, rule.CidrIp], + ); + expect(ports).toEqual([ + [80, 80, '0.0.0.0/0'], + [443, 443, '0.0.0.0/0'], + ]); + }); + + it('never exposes PostgreSQL or the raw app ports', () => { + const { template } = synth(); + const groups = Object.values(template.findResources('AWS::EC2::SecurityGroup')); + const ports = (groups[0]?.Properties?.SecurityGroupIngress ?? []).map( + (rule: { FromPort: number }) => rule.FromPort, + ); + expect(ports).not.toContain(5432); + expect(ports).not.toContain(3000); + expect(ports).not.toContain(8080); + expect(ports).not.toContain(22); + }); + + it('gives the server a fixed public address', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::EC2::EIP', { Domain: 'vpc' }); + expect(Object.keys(template.findResources('AWS::EC2::EIPAssociation'))).toHaveLength(1); + }); +}); + +describe('the database volume', () => { + it('is encrypted, separate from the instance, and survives stack deletion', () => { + const { template } = synth(); + const volumes = Object.values(template.findResources('AWS::EC2::Volume')); + expect(volumes).toHaveLength(1); + expect(volumes[0]?.Properties?.Encrypted).toBe(true); + // Losing this is losing every issue, comment and run in the deployment. + expect(volumes[0]?.DeletionPolicy).toBe('Retain'); + expect(volumes[0]?.UpdateReplacePolicy).toBe('Retain'); + }); + + it('is attached rather than part of the instance', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::EC2::VolumeAttachment', { Device: '/dev/sdf' }); + const instance = Object.values(template.findResources('AWS::EC2::Instance'))[0]; + const devices = (instance?.Properties?.BlockDeviceMappings ?? []).map( + (mapping: { DeviceName: string }) => mapping.DeviceName, + ); + expect(devices).toEqual(['/dev/xvda']); + }); + + it('is snapshotted daily', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::DLM::LifecyclePolicy', { + State: 'ENABLED', + PolicyDetails: Match.objectLike({ + ResourceTypes: ['VOLUME'], + TargetTags: [{ Key: 'Backup', Value: 'multica-self-host' }], + Schedules: [ + Match.objectLike({ + CreateRule: Match.objectLike({ Interval: 24, IntervalUnit: 'HOURS' }), + RetainRule: { Count: 7 }, + }), + ], + }), + }); + }); +}); + +describe('bootstrap', () => { + it('keeps the data on this deployment: telemetry off by default', () => { + expect(userData(synth().template)).toContain('DO_NOT_TRACK_VALUE="1"'); + }); + + it('sends telemetry only when explicitly asked to', () => { + expect(userData(synth({ telemetry: 'true' }).template)).toContain('DO_NOT_TRACK_VALUE=""'); + }); + + it('carries the signup allowlist into the backend configuration', () => { + const script = userData(synth({ allowedEmails: 'a@example.com,b@example.com' }).template); + expect(script).toContain('ALLOWED_EMAILS="a@example.com,b@example.com"'); + expect(script).toContain('ALLOWED_EMAILS=${ALLOWED_EMAILS}'); + }); + + it('never sets the fixed development verification code', () => { + // The self-hosting guide warns that this lets anyone who knows an email + // address log in, which on an internet-facing host is the whole account. + expect(userData(synth().template)).not.toContain('MULTICA_DEV_VERIFICATION_CODE'); + }); + + it('routes WebSockets to the backend ahead of the catch-all', () => { + const script = userData(synth().template); + const wsMatcher = script.indexOf('@multica_ws path /ws /ws/*'); + const catchAll = script.indexOf('reverse_proxy localhost:3000'); + expect(wsMatcher).toBeGreaterThan(-1); + expect(catchAll).toBeGreaterThan(wsMatcher); + expect(script).toContain('flush_interval -1'); + }); + + it('tells the backend which origin to accept, or the WebSocket upgrade is refused', () => { + const script = userData(synth({ domainName: 'multica.example.com' }).template); + expect(script).toContain('DOMAIN_NAME="multica.example.com"'); + expect(script).toContain('FRONTEND_ORIGIN=${PUBLIC_URL}'); + expect(script).toContain('CORS_ALLOWED_ORIGINS=${PUBLIC_URL}'); + }); + + it('puts PostgreSQL on the attached volume, not in a Docker volume on the root disk', () => { + const script = userData(synth().template); + expect(script).toContain('DATA_DIR="/var/lib/multica"'); + expect(script).toContain('${DATA_DIR}/postgres:/var/lib/postgresql/data'); + expect(script).toContain('${DATA_DIR}/uploads:/app/data/uploads'); + }); + + it('writes the env file unreadable to other users', () => { + const script = userData(synth().template); + expect(script).toContain('chmod 0600 "${APP_DIR}/.env"'); + }); + + it('substitutes every placeholder', () => { + expect(userData(synth().template)).not.toMatch(/@@\w+@@/); + }); +}); + +describe('generated secrets', () => { + it('generates both secrets rather than carrying a value in the template', () => { + const { template } = synth(); + const secrets = Object.values(template.findResources('AWS::SecretsManager::Secret')); + expect(secrets).toHaveLength(2); + for (const secret of secrets) { + // A literal SecretString here would put the value in the template, the + // change set and every CloudFormation console that can read the stack. + expect(secret.Properties?.SecretString).toBeUndefined(); + expect(secret.Properties?.GenerateSecretString).toBeDefined(); + } + }); + + it('excludes punctuation, since the password is interpolated into a postgres:// URL', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: 'Multica self-host: PostgreSQL password', + GenerateSecretString: Match.objectLike({ ExcludePunctuation: true, PasswordLength: 32 }), + }); + }); + + it('keeps the database password when the stack is deleted, with the volume', () => { + const { template } = synth(); + const [id] = Object.entries(template.findResources('AWS::SecretsManager::Secret')).find( + ([, r]) => r.Properties?.Description === 'Multica self-host: PostgreSQL password', + )!; + const secret = template.findResources('AWS::SecretsManager::Secret')[id]!; + expect(secret.DeletionPolicy).toBe('Retain'); + }); +}); + +describe('instance role', () => { + it('reaches no service beyond its secrets, Parameter Store, its log group and the asset bucket', () => { + const { template } = synth(); + const inline = Object.entries(template.findResources('AWS::IAM::Policy')).filter(([id]) => + id.startsWith('ServerRoleDefaultPolicy'), + ); + expect(inline).toHaveLength(1); + + const services = new Set(); + for (const statement of inline[0]![1].Properties?.PolicyDocument?.Statement ?? []) { + for (const action of [statement.Action].flat()) { + if (typeof action === 'string') { + services.add(action.split(':')[0]!); + } + } + } + expect([...services].sort()).toEqual(['kms', 'logs', 's3', 'secretsmanager', 'ssm']); + }); +}); diff --git a/infrastructure/tsconfig.json b/infrastructure/tsconfig.json new file mode 100644 index 000000000000..cd1f9d0c984f --- /dev/null +++ b/infrastructure/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "node16", + "moduleResolution": "node16", + "types": ["node"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["bin", "lib", "test", "vitest.config.mts"], + "exclude": ["node_modules", "cdk.out"] +} diff --git a/infrastructure/vitest.config.mts b/infrastructure/vitest.config.mts new file mode 100644 index 000000000000..b37144ac5cee --- /dev/null +++ b/infrastructure/vitest.config.mts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + // The first CDK synth in a worker takes several seconds on a cold CI + // runner — more than vitest's 5s default. That failed whichever test + // happened to synthesize first, regardless of what it asserted. + testTimeout: 30_000, + }, +}); diff --git a/knip.jsonc b/knip.jsonc index a19014ef17c4..e0ca5e243b87 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -74,6 +74,14 @@ }, "packages/eslint-config": {}, - "packages/tsconfig": {} + "packages/tsconfig": {}, + + // The CDK app's entry point is named in cdk.json, not in package.json, so + // knip cannot find it on its own. `aws-cdk` (the CLI) and `ts-node` are + // invoked by that same cdk.json command rather than imported. + "infrastructure": { + "entry": ["bin/app.ts", "test/*.test.ts", "vitest.config.mts"], + "ignoreDependencies": ["aws-cdk", "ts-node"] + } } } diff --git a/package.json b/package.json index 9abe86b92d69..62f811a307c2 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "ios:mobile:device:staging:release": "pnpm -C apps/mobile ios:device:staging:release", "ios:mobile:device:prod": "pnpm -C apps/mobile ios:device:prod", "ios:mobile:device:prod:release": "pnpm -C apps/mobile ios:device:prod:release", - "build": "turbo build --filter=!@multica/mobile", - "typecheck": "turbo typecheck --filter=!@multica/mobile", - "test": "turbo test --filter=!@multica/mobile", - "lint": "turbo lint --filter=!@multica/mobile", + "build": "turbo build --filter=!@multica/mobile --filter=!@multica/infrastructure", + "typecheck": "turbo typecheck --filter=!@multica/mobile --filter=!@multica/infrastructure", + "test": "turbo test --filter=!@multica/mobile --filter=!@multica/infrastructure", + "lint": "turbo lint --filter=!@multica/mobile --filter=!@multica/infrastructure", "knip": "knip", "check:ui-exports": "node scripts/check-ui-wildcard-exports.mjs", "check:ui-radii": "node scripts/check-ui-radius-tokens.mjs", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f4bc23dd84d..b76a4e96cc92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -637,6 +637,31 @@ importers: specifier: 'catalog:' version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(@typescript/typescript6@6.0.2))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.1)) + infrastructure: + dependencies: + aws-cdk-lib: + specifier: ^2.170.0 + version: 2.275.0(constructs@10.8.1) + constructs: + specifier: ^10.4.2 + version: 10.8.1 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.5.0 + aws-cdk: + specifier: ^2.1000.0 + version: 2.1147.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@25.5.0)(@typescript/typescript6@6.0.2) + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: 'catalog:' + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(@typescript/typescript6@6.0.2))(vite@8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.1)) + packages/core: dependencies: '@formatjs/intl-localematcher': @@ -1154,6 +1179,19 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-cdk/asset-awscli-v1@2.2.292': + resolution: {integrity: sha512-d4aMFsAFj19FtxVyw8IzlUKv5Zu4sIvgnEjI2IU6IWBJgVbJ4aFnadANqYa+6MwB1CQbGOg0jh8WE77M+Nb/9A==} + + '@aws-cdk/asset-node-proxy-agent-v6@2.1.3': + resolution: {integrity: sha512-98We1QWfin9vtuuQtNFhIIAEWb8CreyYTAP0Vn8HqSBoNiFJM9nZgifJeQawT9Wq0NX1HCNFlxCyTccIrK1PSA==} + + '@aws-cdk/cloud-assembly-schema@55.3.0': + resolution: {integrity: sha512-V40A9DFnKSB8kS615QaaMxLEyWRhHhPWz6fTlssT5fr61BAPgY+dduRtgN3wEo1IyV/zURuhVEfDIKyE8JzSNw==} + engines: {node: '>= 18.0.0'} + bundledDependencies: + - jsonschema + - semver + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1582,6 +1620,10 @@ packages: '@chevrotain/utils@12.0.0': resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + '@csstools/color-helpers@6.0.2': resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} engines: {node: '>=20.19.0'} @@ -2625,6 +2667,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} engines: {node: '>= 12.13.0'} @@ -4724,6 +4769,18 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tsconfig/node10@1.0.13': + resolution: {integrity: sha512-gcLdvR9HO1ZJBypsOGqaP6TFEzb6vIta0KSTLt9NAQ6pXQO3cRgSVyCN6pzYqI9DlJgY71XKO0dpDhCf08b3pg==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@turbo/darwin-64@2.9.5': resolution: {integrity: sha512-qPxhKsLMQP+9+dsmPgAGidi5uNifD4AoAOnEnljab3Qgn0QZRR31Hp+/CgW3Ia5AanWj6JuLLTBYvuQj4mqTWg==} cpu: [x64] @@ -5398,6 +5455,7 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@yuku-core/binding-android-arm64@0.19.0': resolution: {integrity: sha512-wdL2XmTiZixAOaHk9Z0JJZ8u9Qm8d333Zjpc6gaxXFX+UR5BlsfxZ7a2+2Rm71pamppsPYGUGFq2oVLbh6DISw==} @@ -5489,6 +5547,10 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -5574,6 +5636,9 @@ packages: dmg-builder: 26.8.1 electron-builder-squirrel-windows: 26.8.1 + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} @@ -5671,6 +5736,30 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws-cdk-lib@2.275.0: + resolution: {integrity: sha512-Gjj1tM3dQedhsI7MdwMvULF1PzgEO/zX0KFHU9xwZskkSL9057IZB4fJWm9nL+aaNU3TU+lNt8uB6d8QcZtUpQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + constructs: ^10.5.0 + bundledDependencies: + - '@aws/cloudformation-validate' + - '@balena/dockerignore' + - '@aws-cdk/cloud-assembly-api' + - case + - fs-extra + - ignore + - jsonschema + - minimatch + - punycode + - semver + - yaml + - mime-types + + aws-cdk@2.1147.0: + resolution: {integrity: sha512-2ON/W4jShXWS926bKO6Osgw3MDlxhco5cz3n7SS5GLzaEaYs6ebpAHRDZp6sirkGnMqWoUwxCmIOkvAHS9Rn2A==} + engines: {node: '>= 18.0.0'} + hasBin: true + babel-plugin-polyfill-corejs2@0.4.17: resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} peerDependencies: @@ -6095,6 +6184,9 @@ packages: resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} engines: {node: '>= 0.10.0'} + constructs@10.8.1: + resolution: {integrity: sha512-98yGXYyhePqPYh3cYu8nzBERmAhC0DONe3UD03okK0nehZ7hYP4wgZuf02a04+uOWxnTJ5Rpp5m0GRNpwyLGGA==} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -6149,6 +6241,9 @@ packages: crc@3.8.0: resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-dirname@0.1.0: resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} @@ -6506,6 +6601,10 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -6882,6 +6981,7 @@ packages: eslint@9.39.4: resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -7710,7 +7810,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -8646,6 +8746,9 @@ packages: magic-string@1.4.3: resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + make-fetch-happen@14.0.3: resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} engines: {node: ^18.17.0 || >=20.5.0} @@ -9629,10 +9732,6 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -11048,6 +11147,20 @@ packages: ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -11280,6 +11393,9 @@ packages: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + validate-npm-package-name@5.0.1: resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -11615,6 +11731,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -11698,7 +11818,7 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 - tinyexec: 1.0.4 + tinyexec: 1.3.1 '@asamuzakjp/css-color@5.0.1': dependencies: @@ -11718,6 +11838,12 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-cdk/asset-awscli-v1@2.2.292': {} + + '@aws-cdk/asset-node-proxy-agent-v6@2.1.3': {} + + '@aws-cdk/cloud-assembly-schema@55.3.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -12247,6 +12373,10 @@ snapshots: '@chevrotain/utils@12.0.0': {} + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + '@csstools/color-helpers@6.0.2': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -12316,10 +12446,10 @@ snapshots: dotenv: 17.4.1 eciesjs: 0.4.18 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.3) + fdir: 6.5.0(picomatch@4.0.7) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.3 + picomatch: 4.0.7 which: 4.0.0 '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': @@ -12741,7 +12871,7 @@ snapshots: node-forge: 1.4.0 npm-package-arg: 11.0.3 ora: 3.4.0 - picomatch: 4.0.5 + picomatch: 4.0.7 pretty-format: 29.7.0 progress: 2.0.3 prompts: 2.4.2 @@ -12924,7 +13054,7 @@ snapshots: hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.32.0 - picomatch: 4.0.5 + picomatch: 4.0.7 postcss: 8.5.26 resolve-from: 5.0.0 optionalDependencies: @@ -13338,6 +13468,11 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@malept/cross-spawn-promise@2.0.0': dependencies: cross-spawn: 7.0.6 @@ -14616,7 +14751,9 @@ snapshots: metro-runtime: 0.84.6 transitivePeerDependencies: - '@babel/core' + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.74.89': {} @@ -15352,6 +15489,14 @@ snapshots: minimatch: 10.2.4 path-browserify: 1.0.1 + '@tsconfig/node10@1.0.13': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + '@turbo/darwin-64@2.9.5': optional: true @@ -15804,7 +15949,7 @@ snapshots: debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(@typescript/typescript6@6.0.2) typescript: '@typescript/typescript6@6.0.2' transitivePeerDependencies: @@ -15819,7 +15964,7 @@ snapshots: debug: 4.4.3 minimatch: 10.2.4 semver: 7.7.4 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@6.0.3) typescript: 6.0.3 transitivePeerDependencies: @@ -16174,6 +16319,10 @@ snapshots: dependencies: acorn: 8.16.0 + acorn-walk@8.3.5: + dependencies: + acorn: 8.16.0 + acorn@8.16.0: {} agent-base@7.1.4: {} @@ -16278,6 +16427,8 @@ snapshots: transitivePeerDependencies: - supports-color + arg@4.1.3: {} + arg@5.0.2: {} argparse@2.0.1: {} @@ -16391,6 +16542,15 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws-cdk-lib@2.275.0(constructs@10.8.1): + dependencies: + '@aws-cdk/asset-awscli-v1': 2.2.292 + '@aws-cdk/asset-node-proxy-agent-v6': 2.1.3 + '@aws-cdk/cloud-assembly-schema': 55.3.0 + constructs: 10.8.1 + + aws-cdk@2.1147.0: {} + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): dependencies: '@babel/compat-data': 7.29.0 @@ -16902,6 +17062,8 @@ snapshots: transitivePeerDependencies: - supports-color + constructs@10.8.1: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -16950,6 +17112,8 @@ snapshots: buffer: 5.7.1 optional: true + create-require@1.1.1: {} + cross-dirname@0.1.0: optional: true @@ -17304,6 +17468,8 @@ snapshots: didyoumean@1.2.2: {} + diff@4.0.4: {} + diff@8.0.3: {} dir-compare@4.2.0: @@ -18503,14 +18669,14 @@ snapshots: dependencies: walk-up-path: 4.0.0 - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -19888,6 +20054,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + make-error@1.3.6: {} + make-fetch-happen@14.0.3: dependencies: '@npmcli/agent': 3.0.0 @@ -21508,8 +21676,6 @@ snapshots: picomatch@2.3.1: {} - picomatch@4.0.3: {} - picomatch@4.0.5: {} picomatch@4.0.7: {} @@ -23067,7 +23233,7 @@ snapshots: lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.7 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 sumchecker@3.0.1: @@ -23197,13 +23363,13 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.5) - picomatch: 4.0.5 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 tinyrainbow@3.1.0: {} @@ -23264,6 +23430,24 @@ snapshots: '@ts-morph/common': 0.27.0 code-block-writer: 13.0.3 + ts-node@10.9.2(@types/node@25.5.0)(@typescript/typescript6@6.0.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.13 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 25.5.0 + acorn: 8.16.0 + acorn-walk: 8.3.5 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: '@typescript/typescript6@6.0.2' + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -23545,6 +23729,8 @@ snapshots: uuid@11.1.1: {} + v8-compile-cache-lib@3.0.1: {} + validate-npm-package-name@5.0.1: {} validate-npm-package-name@7.0.2: {} @@ -23665,11 +23851,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.1) why-is-node-running: 2.3.0 @@ -23694,11 +23880,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.0) why-is-node-running: 2.3.0 @@ -23723,11 +23909,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(esbuild@0.25.12)(jiti@2.7.0)(terser@5.47.1)(yaml@2.9.1) why-is-node-running: 2.3.0 @@ -23752,11 +23938,11 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.7 std-env: 4.0.0 tinybench: 2.9.0 - tinyexec: 1.0.4 - tinyglobby: 0.2.15 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.0.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.3)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.47.1)(yaml@2.9.1) why-is-node-running: 2.3.0 @@ -23968,6 +24154,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yn@3.1.1: {} + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5272a770f4f7..476d898be583 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - apps/* - packages/* + - infrastructure catalog: '@formatjs/intl-localematcher': ^0.8.4