diff --git a/.claude/skills/env-reference/SKILL.md b/.claude/skills/env-reference/SKILL.md index 2fd169d442..5677c707fd 100644 --- a/.claude/skills/env-reference/SKILL.md +++ b/.claude/skills/env-reference/SKILL.md @@ -112,6 +112,10 @@ See `apps/api/.env.example` for the full list. Key variables: - `DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE` — Maximum terminal release rows deleted per run (default: `250`) - `DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS` — Minimum interval between release retention runs (default: `24`) - `DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY` — KV interval marker (default: `cleanup:deployment-releases:last-run`) +- `DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED` — Kill switch for stale nonterminal compose release reconciliation (default: enabled) +- `DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE` — Maximum stale nonterminal releases terminalized before retention pruning in one run (default: `50`) +- `DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS` — Minimum release status age before reconciliation can mark a nonterminal release failed (default: `168`) +- `DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS` — Recent release-event protection window for active fetch/apply work (default: `6`) - `COMPOSE_IMAGE_ARTIFACT_CLEANUP_BATCH_SIZE` — Maximum abandoned compose archives deleted per daily run (default: `250`) ### Guided Agent Credential Setup diff --git a/.codex/config.toml b/.codex/config.toml index 08185cb382..cf65957589 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -14,7 +14,7 @@ project_doc_fallback_filenames = ["CLAUDE.md"] # Added by SAM vm-agent for Codex ACP sessions. sandbox_mode = "danger-full-access" approval_policy = "never" -model_reasoning_effort = "high" +model_reasoning_effort = "xhigh" [mcp_servers.sam-mcp] url = "https://api.simple-agent-manager.org/mcp" bearer_token_env_var = "SAM_MCP_TOKEN" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93547e43d5..685fd92d8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: go-modules: ${{ steps.filter.outputs.go-modules }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: filters: | @@ -85,7 +85,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -95,7 +95,20 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Preflight evidence documents an agent's pre-implementation research. Dependabot + # opens PRs from a fixed template and cannot produce that evidence, so the check + # is unsatisfiable for it and leaves the whole dependency lane permanently red. + # The stale-binary guard below still runs for every author. + # + # Both identities are required. `user.login` is frozen when the PR is opened, and + # dependabot branches live in this repo rather than a fork, so anyone with push + # access can add a commit to an open dependabot/** branch without changing it. + # `github.actor` is the actor of THIS event, so a human push flips it back and the + # evidence check runs again. Skip only when both say dependabot. - name: Validate agent preflight evidence + if: >- + github.actor != 'dependabot[bot]' || + github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm quality:preflight - name: Validate no stale binary artifacts are tracked @@ -109,7 +122,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -130,7 +143,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -153,7 +166,7 @@ jobs: with: fetch-depth: 0 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -192,7 +205,7 @@ jobs: with: fetch-depth: 0 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -234,7 +247,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -258,7 +271,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -282,7 +295,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -303,7 +316,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -355,7 +368,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -413,7 +426,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -454,7 +467,7 @@ jobs: with: fetch-depth: 0 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -522,7 +535,7 @@ jobs: - name: Assert Go version run: go version | grep -E '^go version go1\.26\.6 ' - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -551,7 +564,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -578,7 +591,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index e46df009df..f94788f213 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -33,7 +33,7 @@ jobs: - name: Run Claude Code id: claude - uses: anthropics/claude-code-action@be7b93b1907a4abad570368f3c74b6fe3807510b # v1 + uses: anthropics/claude-code-action@6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975 # v1 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} allowed_bots: 'simple-agent-manager' diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 94fdaf0dc9..7fe8e8c5be 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -34,7 +34,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run benchmarks - uses: CodSpeedHQ/action@0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1 # v5.0.2 + uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3 with: mode: simulation run: pnpm --filter @simple-agent-manager/shared exec vitest bench --run diff --git a/.github/workflows/d1-restore.yml b/.github/workflows/d1-restore.yml index f3370a3881..c86856e9e0 100644 --- a/.github/workflows/d1-restore.yml +++ b/.github/workflows/d1-restore.yml @@ -66,7 +66,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/deploy-reusable.yml b/.github/workflows/deploy-reusable.yml index 2730ba9429..b657b9a8b3 100644 --- a/.github/workflows/deploy-reusable.yml +++ b/.github/workflows/deploy-reusable.yml @@ -148,7 +148,7 @@ jobs: fi - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -407,6 +407,10 @@ jobs: DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE: ${{ vars.DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE }} DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS: ${{ vars.DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS }} DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY: ${{ vars.DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY }} + DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED }} + DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE }} + DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS }} + DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS }} SESSION_SNAPSHOT_PURGE_ENABLED: ${{ vars.SESSION_SNAPSHOT_PURGE_ENABLED }} SESSION_SNAPSHOT_PURGE_BATCH_SIZE: ${{ vars.SESSION_SNAPSHOT_PURGE_BATCH_SIZE }} SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTS: ${{ vars.SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTS }} @@ -764,6 +768,10 @@ jobs: DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE: ${{ vars.DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE }} DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS: ${{ vars.DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS }} DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY: ${{ vars.DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY }} + DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED }} + DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE }} + DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS }} + DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS: ${{ vars.DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS }} SESSION_SNAPSHOT_PURGE_ENABLED: ${{ vars.SESSION_SNAPSHOT_PURGE_ENABLED }} SESSION_SNAPSHOT_PURGE_BATCH_SIZE: ${{ vars.SESSION_SNAPSHOT_PURGE_BATCH_SIZE }} SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTS: ${{ vars.SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTS }} diff --git a/.github/workflows/deploy-www.yml b/.github/workflows/deploy-www.yml index aa94617432..7851c26721 100644 --- a/.github/workflows/deploy-www.yml +++ b/.github/workflows/deploy-www.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e299fb46b1..0ecf91a1e3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,7 +62,7 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -96,7 +96,7 @@ jobs: with: ref: refs/heads/main - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/devcontainer-cache-experiments.yml b/.github/workflows/devcontainer-cache-experiments.yml index 8d053f1c24..9485994fa3 100644 --- a/.github/workflows/devcontainer-cache-experiments.yml +++ b/.github/workflows/devcontainer-cache-experiments.yml @@ -47,7 +47,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.9 @@ -114,7 +114,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.9 @@ -209,7 +209,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 with: version: 9.15.9 diff --git a/.github/workflows/do-wall-time.yml b/.github/workflows/do-wall-time.yml index 6898af2557..9a69c32350 100644 --- a/.github/workflows/do-wall-time.yml +++ b/.github/workflows/do-wall-time.yml @@ -28,7 +28,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/e2e-smoke.yml b/.github/workflows/e2e-smoke.yml index 40adab9200..94b14ecf7d 100644 --- a/.github/workflows/e2e-smoke.yml +++ b/.github/workflows/e2e-smoke.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/provision-www.yml b/.github/workflows/provision-www.yml index fc5cd27204..156623853e 100644 --- a/.github/workflows/provision-www.yml +++ b/.github/workflows/provision-www.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/pulumi-state-repair.yml b/.github/workflows/pulumi-state-repair.yml index d84e53c3b9..3d360b735d 100644 --- a/.github/workflows/pulumi-state-repair.yml +++ b/.github/workflows/pulumi-state-repair.yml @@ -43,7 +43,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/scheduler-lifecycle.yml b/.github/workflows/scheduler-lifecycle.yml index bbd01ca8ce..e258390359 100644 --- a/.github/workflows/scheduler-lifecycle.yml +++ b/.github/workflows/scheduler-lifecycle.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/teardown.yml b/.github/workflows/teardown.yml index 40c3c1980a..d3ef34084e 100644 --- a/.github/workflows/teardown.yml +++ b/.github/workflows/teardown.yml @@ -102,7 +102,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/apps/api/.env.example b/apps/api/.env.example index b3dbdcd371..ac304d6042 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -789,6 +789,10 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000 # DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE=250 # Max terminal release rows deleted per run # DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS=24 # Minimum hours between release retention runs # DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY=cleanup:deployment-releases:last-run +# DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED=true # Set to false to disable stale nonterminal release reconciliation +# DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE=50 # Max stale nonterminal releases terminalized per retention run +# DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS=168 # Minimum status age before reconciliation (7 days) +# DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS=6 # Protect releases with recent fetch/apply events # Account Map visualization # ACCOUNT_MAP_MAX_ENTITIES=200 # Max entities per type from D1 (default: 200) diff --git a/apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql b/apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql new file mode 100644 index 0000000000..2a7b684837 --- /dev/null +++ b/apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql @@ -0,0 +1,6 @@ +-- Track deployment release status activity so scheduled reconciliation can +-- distinguish stale nonterminal releases from active apply/fetch work. +ALTER TABLE deployment_releases ADD COLUMN status_updated_at TEXT; + +CREATE INDEX IF NOT EXISTS idx_deployment_releases_status_updated_at + ON deployment_releases(status, status_updated_at); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 45dd53c4cd..43151ace1e 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -2773,6 +2773,8 @@ export const deploymentReleases = sqliteTable( manifest: text('manifest').notNull(), version: integer('version').notNull(), status: text('status').notNull().default('created'), + /** Updated whenever the control plane changes release.status. Added in migration 0112. */ + statusUpdatedAt: text('status_updated_at'), // Discriminator for how the release was produced (migration 0073). // NULL / 'build-on-node' = manifest is a DeploymentManifest. // 'compose-publish' = manifest is a captured `docker compose publish` @@ -2792,6 +2794,10 @@ export const deploymentReleases = sqliteTable( table.version ), sourceIdx: index('idx_deployment_releases_source').on(table.source), + statusUpdatedAtIdx: index('idx_deployment_releases_status_updated_at').on( + table.status, + table.statusUpdatedAt + ), }) ); diff --git a/apps/api/src/durable-objects/task-runner/workspace-steps.ts b/apps/api/src/durable-objects/task-runner/workspace-steps.ts index 55d9d39a5d..609875e9f0 100644 --- a/apps/api/src/durable-objects/task-runner/workspace-steps.ts +++ b/apps/api/src/durable-objects/task-runner/workspace-steps.ts @@ -585,8 +585,12 @@ export async function handleAttachmentTransfer( const protocol = rc.env.VM_AGENT_PROTOCOL || 'https'; const port = rc.env.VM_AGENT_PORT || '8443'; const workspaceId = state.stepResults.workspaceId; + const nodeId = state.stepResults.nodeId; const baseDomain = rc.env.BASE_DOMAIN || ''; - const vmUrl = `${protocol}://ws-${workspaceId}.${baseDomain}:${port}`; + // Use the two-level node backend hostname for internal Worker → VM-agent calls. + // The single-level ws-* hostname is the browser workspace proxy and now enforces + // browser-session-bound terminal tokens. + const vmUrl = `${protocol}://${nodeId.toLowerCase()}.vm.${baseDomain}:${port}`; // Token passed as query param — VM agent's requireWorkspaceRequestAuth() checks // r.URL.Query().Get("token"), not Authorization header. const uploadBaseUrl = `${vmUrl}/workspaces/${workspaceId}/files/upload`; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 652014ee8d..73deb513a6 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -208,6 +208,10 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE?: string; // Max terminal releases deleted per run (default: 250) DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS?: string; // Minimum hours between release retention runs (default: 24) DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY?: string; // KV key for release retention interval gating + DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED?: string; // Kill switch: "false" disables stale nonterminal release reconciliation (default: enabled) + DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE?: string; // Max stale nonterminal releases terminalized per retention run (default: 50) + DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS?: string; // Minimum status age before stale release reconciliation (default: 168) + DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS?: string; // Recent release-event protection window (default: 6) SESSION_SNAPSHOT_TTL_DAYS?: string; // Runtime hibernate snapshot retention (default: 7) SESSION_SNAPSHOT_R2_PREFIX?: string; // R2 key prefix for runtime hibernate snapshots SESSION_SNAPSHOT_TOTAL_BUDGET_BYTES?: string; // Max combined home/WIP snapshot size (default: 268435456) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 211a9475fe..aea8ec7723 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -142,6 +142,7 @@ import { scheduled } from './scheduled/handler'; import { signTerminalToken, verifyPortAccessToken, verifyTerminalToken } from './services/jwt'; import { assertUserNotSuspended } from './services/signup-approval'; import { recordNodeRoutingMetric } from './services/telemetry'; +import { assertTerminalTokenSessionLive } from './services/terminal-token-liveness'; import { fetchVmAgentContainer, getVmAgentContainerConfig } from './services/vm-agent-container'; const app = new Hono<{ Bindings: Env }>(); @@ -345,6 +346,7 @@ h1{font-size:1.4rem}code{background:#f0f0f0;padding:2px 6px;border-radius:3px;fo if (payload.workspace !== workspaceId || payload.subject === 'port-proxy') { return c.json({ error: 'UNAUTHORIZED', message: 'Invalid workspace token' }, 401); } + await assertTerminalTokenSessionLive(c.env, payload); userId = payload.subject; } catch (err) { log.warn('ws_proxy_terminal_token_rejected', { diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 5e483d87f6..0ac779a049 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -25,7 +25,8 @@ export interface AuthContext { status: UserStatus; }; session: { - id: string; + id: string | null; + token: string | null; expiresAt: Date; }; } @@ -68,6 +69,7 @@ type AuthSession = NonNullable< */ function buildAuthContext(session: AuthSession): AuthContext { const sessionUser = expectJsonRecord(session.user, 'auth.session.user'); + const sessionRecord = expectJsonRecord(session.session, 'auth.session.session'); return { user: { id: session.user.id, @@ -78,7 +80,14 @@ function buildAuthContext(session: AuthSession): AuthContext { status: resolveSessionStatus(sessionUser.status, session.user.id), }, session: { - id: session.session.id, + id: + typeof sessionRecord.id === 'string' && sessionRecord.id.length > 0 + ? sessionRecord.id + : null, + token: + typeof sessionRecord.token === 'string' && sessionRecord.token.length > 0 + ? sessionRecord.token + : null, expiresAt: session.session.expiresAt, }, }; diff --git a/apps/api/src/routes/deploy-release-callback.ts b/apps/api/src/routes/deploy-release-callback.ts index 47b7fe25b4..c93628b36a 100644 --- a/apps/api/src/routes/deploy-release-callback.ts +++ b/apps/api/src/routes/deploy-release-callback.ts @@ -208,10 +208,18 @@ deployReleaseCallbackRoute.get('/:id/deploy-release', async (c) => { throw errors.notFound('Deployment release'); } - await db - .update(schema.deploymentReleases) - .set({ status: 'applying' }) - .where(eq(schema.deploymentReleases.id, release.id)); + const applyingAt = new Date().toISOString(); + const applyingClaim = await c.env.DATABASE.prepare( + `UPDATE deployment_releases + SET status = 'applying', status_updated_at = ? + WHERE id = ? + AND status IN ('created', 'applying')` + ) + .bind(applyingAt, release.id) + .run(); + if ((applyingClaim.meta?.changes ?? 0) === 0) { + throw errors.conflict('Deployment release is no longer pending apply'); + } // Two release shapes share this apply path, discriminated by `release.source`: // diff --git a/apps/api/src/routes/deployment-environment-lifecycle.ts b/apps/api/src/routes/deployment-environment-lifecycle.ts index bd4c17b3fe..b93f522613 100644 --- a/apps/api/src/routes/deployment-environment-lifecycle.ts +++ b/apps/api/src/routes/deployment-environment-lifecycle.ts @@ -151,9 +151,10 @@ async function markEnvironmentStartFailed( .set(updates) .where(eq(schema.deploymentEnvironments.id, envId)); if (opts.latestReleaseId) { + const statusUpdatedAt = new Date().toISOString(); await db .update(schema.deploymentReleases) - .set({ status: 'failed' }) + .set({ status: 'failed', statusUpdatedAt }) .where(eq(schema.deploymentReleases.id, opts.latestReleaseId)); } } @@ -535,7 +536,7 @@ async function markEnvironmentStarting( .where(eq(schema.deploymentEnvironments.id, envId)); await db .update(schema.deploymentReleases) - .set({ status: 'created' }) + .set({ status: 'created', statusUpdatedAt: new Date().toISOString() }) .where(eq(schema.deploymentReleases.id, latestReleaseId)); } diff --git a/apps/api/src/routes/deployment-release-submission.ts b/apps/api/src/routes/deployment-release-submission.ts index 72f571430e..ef14169f26 100644 --- a/apps/api/src/routes/deployment-release-submission.ts +++ b/apps/api/src/routes/deployment-release-submission.ts @@ -147,6 +147,7 @@ async function insertDeploymentRelease(params: { manifest: JSON.stringify(params.manifest), version: params.version, status: 'created', + statusUpdatedAt: params.now, createdBy: params.userId, createdAt: params.now, }); @@ -196,7 +197,7 @@ async function markDeploymentReleasePlacementFailed( const now = new Date().toISOString(); await db .update(schema.deploymentReleases) - .set({ status: 'failed' }) + .set({ status: 'failed', statusUpdatedAt: now }) .where(eq(schema.deploymentReleases.id, releaseId)); await db .update(schema.deploymentEnvironments) diff --git a/apps/api/src/routes/projects/compose-publish-release-callback.ts b/apps/api/src/routes/projects/compose-publish-release-callback.ts index b8a0839fc1..41ff9c30ef 100644 --- a/apps/api/src/routes/projects/compose-publish-release-callback.ts +++ b/apps/api/src/routes/projects/compose-publish-release-callback.ts @@ -324,6 +324,7 @@ composePublishReleaseCallbackRoute.post('/:id/compose-publish-release', async (c const nextVersion = (latestRows[0]?.version ?? 0) + 1; const releaseId = ulid(); + const releaseCreatedAt = new Date().toISOString(); try { await db.insert(schema.deploymentReleases).values({ @@ -334,8 +335,10 @@ composePublishReleaseCallbackRoute.post('/:id/compose-publish-release', async (c manifest: JSON.stringify(manifestSubmission), version: nextVersion, status: 'created', + statusUpdatedAt: releaseCreatedAt, source: 'compose-publish', createdBy: userId, + createdAt: releaseCreatedAt, }); await db .update(schema.deploymentEnvironments) diff --git a/apps/api/src/routes/terminal.ts b/apps/api/src/routes/terminal.ts index 27c3d0aa2c..34bda66a94 100644 --- a/apps/api/src/routes/terminal.ts +++ b/apps/api/src/routes/terminal.ts @@ -5,7 +5,7 @@ import { Hono } from 'hono'; import * as schema from '../db/schema'; import type { Env } from '../env'; -import { getUserId, requireApproved, requireAuth } from '../middleware/auth'; +import { getAuth, requireApproved, requireAuth } from '../middleware/auth'; import { errors } from '../middleware/error'; import { rateLimitTerminalToken } from '../middleware/rate-limit'; import { jsonValidator, TerminalRequestSchema } from '../schemas'; @@ -26,7 +26,8 @@ terminalRoutes.post( (c, next) => rateLimitTerminalToken(c.env)(c, next), jsonValidator(TerminalRequestSchema), async (c) => { - const userId = getUserId(c); + const auth = getAuth(c); + const userId = auth.user.id; const db = drizzle(c.env.DATABASE, { schema }); const body = c.req.valid('json'); @@ -35,12 +36,7 @@ terminalRoutes.post( const workspace = await db .select() .from(schema.workspaces) - .where( - and( - eq(schema.workspaces.id, body.workspaceId), - eq(schema.workspaces.userId, userId) - ) - ) + .where(and(eq(schema.workspaces.id, body.workspaceId), eq(schema.workspaces.userId, userId))) .limit(1); const ws = workspace[0]; @@ -55,7 +51,12 @@ terminalRoutes.post( } // Generate the terminal token - const { token, expiresAt } = await signTerminalToken(userId, body.workspaceId, c.env); + if (!auth.session.token) { + throw errors.unauthorized('Authentication required'); + } + const { token, expiresAt } = await signTerminalToken(userId, body.workspaceId, c.env, { + sessionToken: auth.session.token, + }); // Canonical workspace URL is derived from workspace ID and base domain. // In multi-workspace-per-node mode, routing no longer depends on vmIp in this record. @@ -64,11 +65,11 @@ terminalRoutes.post( // Record terminal activity for workspace idle detection if (ws.projectId) { c.executionCtx.waitUntil( - projectDataService.updateTerminalActivity( - c.env, ws.projectId, ws.id, ws.chatSessionId - ).catch(() => { - // Best-effort: don't block token generation - }) + projectDataService + .updateTerminalActivity(c.env, ws.projectId, ws.id, ws.chatSessionId) + .catch(() => { + // Best-effort: don't block token generation + }) ); } @@ -87,7 +88,7 @@ terminalRoutes.post( * Called periodically by the frontend while a terminal session is active. */ terminalRoutes.post('/activity', jsonValidator(TerminalRequestSchema), async (c) => { - const userId = getUserId(c); + const userId = getAuth(c).user.id; const db = drizzle(c.env.DATABASE, { schema }); const body = c.req.valid('json'); @@ -99,12 +100,7 @@ terminalRoutes.post('/activity', jsonValidator(TerminalRequestSchema), async (c) chatSessionId: schema.workspaces.chatSessionId, }) .from(schema.workspaces) - .where( - and( - eq(schema.workspaces.id, body.workspaceId), - eq(schema.workspaces.userId, userId) - ) - ) + .where(and(eq(schema.workspaces.id, body.workspaceId), eq(schema.workspaces.userId, userId))) .limit(1); const ws = workspace[0]; @@ -113,9 +109,7 @@ terminalRoutes.post('/activity', jsonValidator(TerminalRequestSchema), async (c) } if (ws.projectId) { - await projectDataService.updateTerminalActivity( - c.env, ws.projectId, ws.id, ws.chatSessionId - ); + await projectDataService.updateTerminalActivity(c.env, ws.projectId, ws.id, ws.chatSessionId); } return c.json({ ok: true }); diff --git a/apps/api/src/scheduled/d1-retention.ts b/apps/api/src/scheduled/d1-retention.ts index c2db39084b..c9f32207c8 100644 --- a/apps/api/src/scheduled/d1-retention.ts +++ b/apps/api/src/scheduled/d1-retention.ts @@ -4,12 +4,16 @@ import { parsePositiveInt } from '../lib/route-helpers'; import * as projectDataService from '../services/project-data'; import { DEFAULT_SESSION_SLEEP_CLAIM_LEASE_MS } from '../services/session-snapshots'; import { destroyVmAgentContainer } from '../services/vm-agent-container'; +import { COMPOSE_IMAGE_ARTIFACT_PREFIX } from './compose-image-artifact-cleanup'; export const DEFAULT_DEPLOYMENT_RELEASE_RETENTION_COUNT = 3; export const DEFAULT_DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE = 250; export const DEFAULT_DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS = 24; export const DEFAULT_DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY = 'cleanup:deployment-releases:last-run'; +export const DEFAULT_DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE = 50; +export const DEFAULT_DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS = 168; +export const DEFAULT_DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS = 6; export const DEFAULT_SESSION_SNAPSHOT_PURGE_BATCH_SIZE = 250; @@ -35,6 +39,11 @@ interface IntervalGateOptions { export interface DeploymentReleaseRetentionStats extends ScheduledSweepResult { retentionCount: number; batchSize: number; + reconciliationEnabled: boolean; + reconciliationBatchSize: number; + reconciliationStaleHours: number; + reconciliationActivityGraceHours: number; + reconciledStaleReleases: number; deletedReleases: number; } @@ -96,6 +105,31 @@ function deploymentReleaseRetentionIntervalHours(env: Env): number { ); } +function deploymentReleaseReconciliationEnabled(env: Env): boolean { + return isEnabled(env.DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED); +} + +function deploymentReleaseReconciliationBatchSize(env: Env): number { + return parsePositiveInt( + env.DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE, + DEFAULT_DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE + ); +} + +function deploymentReleaseReconciliationStaleHours(env: Env): number { + return parsePositiveInt( + env.DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS, + DEFAULT_DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS + ); +} + +function deploymentReleaseReconciliationActivityGraceHours(env: Env): number { + return parsePositiveInt( + env.DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS, + DEFAULT_DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS + ); +} + function emptyDeploymentReleaseRetentionStats( env: Env, overrides: Partial = {} @@ -106,11 +140,129 @@ function emptyDeploymentReleaseRetentionStats( skipReason: null, retentionCount: deploymentReleaseRetentionCount(env), batchSize: deploymentReleaseRetentionBatchSize(env), + reconciliationEnabled: deploymentReleaseReconciliationEnabled(env), + reconciliationBatchSize: deploymentReleaseReconciliationBatchSize(env), + reconciliationStaleHours: deploymentReleaseReconciliationStaleHours(env), + reconciliationActivityGraceHours: deploymentReleaseReconciliationActivityGraceHours(env), + reconciledStaleReleases: 0, deletedReleases: 0, ...overrides, }; } +export interface StaleDeploymentReleaseReconciliationStats { + enabled: boolean; + batchSize: number; + staleHours: number; + activityGraceHours: number; + reconciledReleases: number; +} + +function emptyStaleDeploymentReleaseReconciliationStats( + env: Env, + overrides: Partial = {} +): StaleDeploymentReleaseReconciliationStats { + return { + enabled: deploymentReleaseReconciliationEnabled(env), + batchSize: deploymentReleaseReconciliationBatchSize(env), + staleHours: deploymentReleaseReconciliationStaleHours(env), + activityGraceHours: deploymentReleaseReconciliationActivityGraceHours(env), + reconciledReleases: 0, + ...overrides, + }; +} + +function hoursBefore(now: Date, hours: number): string { + return new Date(now.getTime() - hours * 60 * 60 * 1000).toISOString(); +} + +/** + * Terminalize stale nonterminal compose releases whose R2 image artifacts are + * otherwise retained forever by manifest references. + * + * Race-safety depends on D1-only evidence: + * - the release came from the compose-publish path; + * - status activity is older than the stale threshold; + * - the authenticated deployment node has observed a stable non-applying state + * after this release was created; + * - the release is not the environment's observed applied seq; + * - no recent release fetch/apply event exists inside the activity lease; + * - the manifest is valid JSON and actually references compose image artifacts. + * + * Unknown statuses, malformed/future timestamps, malformed manifests, and + * missing/ambiguous observed state fail closed by not matching the update. + */ +export async function runStaleDeploymentReleaseReconciliation( + env: Env, + now: Date = new Date() +): Promise { + const stats = emptyStaleDeploymentReleaseReconciliationStats(env); + if (!stats.enabled) { + return stats; + } + + const staleBefore = hoursBefore(now, stats.staleHours); + const activeAfter = hoursBefore(now, stats.activityGraceHours); + const nowIso = now.toISOString(); + + const result = (await env.DATABASE.prepare( + `UPDATE deployment_releases + SET status = 'failed', + status_updated_at = ? + WHERE id IN ( + SELECT release.id + FROM deployment_releases AS release + INNER JOIN deployment_environments AS environment + ON environment.id = release.environment_id + WHERE release.status IN ('created', 'applying') + AND release.source = 'compose-publish' + AND release.manifest LIKE ? + AND json_valid(release.manifest) = 1 + AND datetime(coalesce(release.status_updated_at, release.created_at)) IS NOT NULL + AND datetime(coalesce(release.status_updated_at, release.created_at)) <= datetime(?) + AND datetime(release.created_at) IS NOT NULL + AND datetime(environment.observed_at) IS NOT NULL + AND datetime(environment.observed_at) >= datetime(release.created_at) + AND datetime(environment.observed_at) <= datetime(?) + AND environment.observed_status IN ('applied', 'failed', 'failed-initial', 'reverted') + AND ( + environment.observed_applied_seq IS NULL + OR release.version <> environment.observed_applied_seq + ) + AND NOT EXISTS ( + SELECT 1 + FROM deployment_release_events AS event + WHERE ( + event.release_id = release.id + OR ( + event.release_id IS NULL + AND event.environment_id = release.environment_id + AND event.release_version = release.version + ) + ) + AND datetime(event.created_at) IS NOT NULL + AND datetime(event.created_at) > datetime(?) + ) + ORDER BY release.environment_id ASC, release.version ASC, release.id ASC + LIMIT ? + )` + ) + .bind( + nowIso, + `%${COMPOSE_IMAGE_ARTIFACT_PREFIX}%`, + staleBefore, + nowIso, + activeAfter, + stats.batchSize + ) + .run()) as D1MutationResult; + + return { + ...stats, + reconciledReleases: mutationChanges(result), + }; +} + /** * Delete a bounded page of superseded terminal releases across every environment. * @@ -120,7 +272,8 @@ function emptyDeploymentReleaseRetentionStats( * closed. Successful candidates leave the set permanently (rule 47). */ export async function runDeploymentReleaseRetention( - env: Env + env: Env, + now: Date = new Date() ): Promise { if (!isEnabled(env.DEPLOYMENT_RELEASE_RETENTION_ENABLED)) { return emptyDeploymentReleaseRetentionStats(env, { @@ -132,6 +285,7 @@ export async function runDeploymentReleaseRetention( const retentionCount = deploymentReleaseRetentionCount(env); const batchSize = deploymentReleaseRetentionBatchSize(env); + const reconciliation = await runStaleDeploymentReleaseReconciliation(env, now); const result = (await env.DATABASE.prepare( `DELETE FROM deployment_releases WHERE id IN ( @@ -160,6 +314,11 @@ export async function runDeploymentReleaseRetention( return emptyDeploymentReleaseRetentionStats(env, { retentionCount, batchSize, + reconciliationEnabled: reconciliation.enabled, + reconciliationBatchSize: reconciliation.batchSize, + reconciliationStaleHours: reconciliation.staleHours, + reconciliationActivityGraceHours: reconciliation.activityGraceHours, + reconciledStaleReleases: reconciliation.reconciledReleases, deletedReleases: mutationChanges(result), }); } @@ -185,7 +344,7 @@ export async function runScheduledDeploymentReleaseRetention( DEFAULT_DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY ), emptyResult: (overrides) => emptyDeploymentReleaseRetentionStats(env, overrides), - run: () => runDeploymentReleaseRetention(env), + run: () => runDeploymentReleaseRetention(env, now), }); } diff --git a/apps/api/src/services/deployment-control.ts b/apps/api/src/services/deployment-control.ts index 9fc922e67a..e89695cc38 100644 --- a/apps/api/src/services/deployment-control.ts +++ b/apps/api/src/services/deployment-control.ts @@ -208,6 +208,7 @@ export async function reconcileDeploymentReleaseStatuses( environmentId: string, deployment: DeploymentHeartbeatState ): Promise { + const statusUpdatedAt = new Date().toISOString(); const appliedSeq = normalizeAppliedSeq(deployment.appliedSeq) ?? 0; const status = normalizeStatus(deployment.status); if (!status) return; @@ -228,7 +229,7 @@ export async function reconcileDeploymentReleaseStatuses( if (appliedSeq > 0 && APPLY_SUCCESS_STATUSES.has(status)) { await db .update(schema.deploymentReleases) - .set({ status: 'applied' }) + .set({ status: 'applied', statusUpdatedAt }) .where( and( eq(schema.deploymentReleases.environmentId, environmentId), @@ -242,7 +243,7 @@ export async function reconcileDeploymentReleaseStatuses( if (status === 'applying' && latest.version > appliedSeq) { await db .update(schema.deploymentReleases) - .set({ status: 'applying' }) + .set({ status: 'applying', statusUpdatedAt }) .where(eq(schema.deploymentReleases.id, latest.id)); return; } @@ -250,7 +251,7 @@ export async function reconcileDeploymentReleaseStatuses( if (status === 'applied' && latest.version === appliedSeq && latest.status !== 'applied') { await db .update(schema.deploymentReleases) - .set({ status: 'applied' }) + .set({ status: 'applied', statusUpdatedAt }) .where(eq(schema.deploymentReleases.id, latest.id)); return; } @@ -275,7 +276,7 @@ export async function reconcileDeploymentReleaseStatuses( await db .update(schema.deploymentReleases) - .set({ status: 'failed' }) + .set({ status: 'failed', statusUpdatedAt }) .where(eq(schema.deploymentReleases.id, failedRelease.id)); } } diff --git a/apps/api/src/services/deployment-volumes.ts b/apps/api/src/services/deployment-volumes.ts index 1e072e8df7..5da986e5f1 100644 --- a/apps/api/src/services/deployment-volumes.ts +++ b/apps/api/src/services/deployment-volumes.ts @@ -287,7 +287,7 @@ export async function markDeploymentReleaseVolumeAttachFailed( await db .update(schema.deploymentReleases) - .set({ status: 'failed' }) + .set({ status: 'failed', statusUpdatedAt: now }) .where(eq(schema.deploymentReleases.id, releaseId)); await db .update(schema.deploymentEnvironments) diff --git a/apps/api/src/services/jwt.ts b/apps/api/src/services/jwt.ts index 6ea79c3190..a1474e67df 100644 --- a/apps/api/src/services/jwt.ts +++ b/apps/api/src/services/jwt.ts @@ -1,4 +1,7 @@ -import { DEFAULT_GCP_IDENTITY_TOKEN_EXPIRY_SECONDS } from '@simple-agent-manager/shared'; +import { + DEFAULT_GCP_IDENTITY_TOKEN_EXPIRY_SECONDS, + DEFAULT_TERMINAL_TOKEN_EXPIRY_MS, +} from '@simple-agent-manager/shared'; import { decodeJwt, exportJWK, importPKCS8, importSPKI, jwtVerify, SignJWT } from 'jose'; import type { Env } from '../env'; @@ -24,11 +27,11 @@ function getIssuer(env: Env): string { /** * Get terminal token expiry in milliseconds. - * Default: 1 hour (3600000ms) + * Default: 1 hour. */ function getTerminalTokenExpiry(env: Env): number { const envValue = env.TERMINAL_TOKEN_EXPIRY_MS; - return envValue ? parseInt(envValue, 10) : 60 * 60 * 1000; + return envValue ? parseInt(envValue, 10) : DEFAULT_TERMINAL_TOKEN_EXPIRY_MS; } /** @@ -47,7 +50,8 @@ function getCallbackTokenExpiry(env: Env): number { export async function signTerminalToken( userId: string, workspaceId: string, - env: Env + env: Env, + options: { sessionToken?: string | null } = {} ): Promise<{ token: string; expiresAt: string }> { const privateKey = await importPKCS8(env.JWT_PRIVATE_KEY, 'RS256'); const expiry = getTerminalTokenExpiry(env); @@ -56,6 +60,7 @@ export async function signTerminalToken( const token = await new SignJWT({ workspace: workspaceId, + ...(options.sessionToken ? { sessionToken: options.sessionToken } : {}), }) .setProtectedHeader({ alg: 'RS256', kid: KEY_ID }) .setIssuer(issuer) @@ -180,6 +185,7 @@ export interface CallbackTokenPayload { export interface TerminalTokenPayload { workspace: string; subject: string; + sessionToken?: string; } export interface PortAccessTokenPayload { @@ -276,6 +282,10 @@ export async function verifyTerminalToken(token: string, env: Env): Promise 0 + ? payload.sessionToken + : undefined, }; } diff --git a/apps/api/src/services/terminal-token-liveness.ts b/apps/api/src/services/terminal-token-liveness.ts new file mode 100644 index 0000000000..2b8364b041 --- /dev/null +++ b/apps/api/src/services/terminal-token-liveness.ts @@ -0,0 +1,84 @@ +import { and, eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/d1'; + +import * as schema from '../db/schema'; +import type { Env } from '../env'; +import { log } from '../lib/logger'; +import type { TerminalTokenPayload } from './jwt'; +import { assertUserAllowedBySignupApproval, isSignupApprovalRequired } from './signup-approval'; + +function sessionExpiryMs(value: Date | number | string): number { + if (value instanceof Date) { + return value.getTime(); + } + if (typeof value === 'number') { + return value; + } + return new Date(value).getTime(); +} + +/** + * Bind browser terminal-token liveness to the BetterAuth session that minted it. + * + * Terminal JWTs still carry their normal expiry for VM-agent compatibility, but + * browser-origin workspace proxy access is privileged enough that the Worker must + * also prove the minting session is still live. Logout deletes/invalidates the + * BetterAuth session row, so a captured token fails closed on the next upgrade. + */ +export async function assertTerminalTokenSessionLive( + env: Env, + payload: TerminalTokenPayload +): Promise { + if (!payload.sessionToken) { + log.warn('terminal_token.session_missing', { + workspaceId: payload.workspace, + userId: payload.subject, + action: 'rejected', + }); + throw new Error('Terminal token is not bound to an auth session'); + } + + const db = drizzle(env.DATABASE, { schema }); + const row = await db + .select({ + sessionId: schema.sessions.id, + userId: schema.sessions.userId, + expiresAt: schema.sessions.expiresAt, + userRole: schema.users.role, + userStatus: schema.users.status, + }) + .from(schema.sessions) + .innerJoin(schema.users, eq(schema.sessions.userId, schema.users.id)) + .where( + and( + eq(schema.sessions.token, payload.sessionToken), + eq(schema.sessions.userId, payload.subject) + ) + ) + .get(); + + if (!row) { + log.warn('terminal_token.session_not_found', { + workspaceId: payload.workspace, + userId: payload.subject, + action: 'rejected', + }); + throw new Error('Terminal token auth session is not live'); + } + + const expiresAt = sessionExpiryMs(row.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + log.warn('terminal_token.session_expired', { + workspaceId: payload.workspace, + userId: payload.subject, + expiresAt, + action: 'rejected', + }); + throw new Error('Terminal token auth session expired'); + } + + assertUserAllowedBySignupApproval(await isSignupApprovalRequired(env), { + role: row.userRole, + status: row.userStatus, + }); +} diff --git a/apps/api/tests/unit/routes/deploy-release-callback.test.ts b/apps/api/tests/unit/routes/deploy-release-callback.test.ts index 6f9aba4f47..097d381d40 100644 --- a/apps/api/tests/unit/routes/deploy-release-callback.test.ts +++ b/apps/api/tests/unit/routes/deploy-release-callback.test.ts @@ -19,6 +19,9 @@ const mockBuildVolumeMountDescriptors = vi.fn().mockResolvedValue([]); const mockOrderBy = vi.fn().mockResolvedValue([]); const mockUpdateSet = vi.fn(); const mockUpdateWhere = vi.fn(); +const mockD1Run = vi.fn(); +const mockD1Bind = vi.fn(() => ({ run: mockD1Run })); +const mockD1Prepare = vi.fn(() => ({ bind: mockD1Bind })); let customDomainRows: Array<{ hostname: string; service: string; @@ -179,7 +182,7 @@ function manifestWithSecret() { function env(): Env { return { - DATABASE: {} as D1Database, + DATABASE: { prepare: mockD1Prepare } as unknown as D1Database, BASE_DOMAIN: 'sammy.party', CF_API_TOKEN: 'cf-token', CF_ZONE_ID: 'zone-1', @@ -284,6 +287,10 @@ describe('deploy release callback route', () => { mockUpdateSet.mockReset(); mockUpdateWhere.mockReset(); mockUpdateWhere.mockResolvedValue(undefined); + mockD1Prepare.mockClear(); + mockD1Bind.mockClear(); + mockD1Run.mockReset(); + mockD1Run.mockResolvedValue({ meta: { changes: 1 } }); mockVerifyCallbackToken.mockClear(); mockSignDeployPayload.mockClear(); mockSignRouteConfigPayload.mockClear(); @@ -327,8 +334,11 @@ describe('deploy release callback route', () => { expect(mockVerifyCallbackToken).toHaveBeenCalledWith('callback-token', expect.anything(), { expectedScope: 'node', }); - expect(mockUpdateSet).toHaveBeenCalledWith({ status: 'applying' }); - expect(mockUpdateWhere).toHaveBeenCalledTimes(1); + expect(mockD1Prepare).toHaveBeenCalledWith( + expect.stringContaining('UPDATE deployment_releases') + ); + expect(mockD1Bind).toHaveBeenCalledWith(expect.any(String), 'rel-1'); + expect(mockD1Run).toHaveBeenCalledTimes(1); // Port base includes per-environment offset to prevent cross-env collisions const envOffset = environmentPortOffset('env-1', 10, 36_000); @@ -388,6 +398,23 @@ describe('deploy release callback route', () => { }); }); + it('returns conflict when the release was terminalized before the node claimed apply', async () => { + stubHappyPathDb(); + mockD1Run.mockResolvedValueOnce({ meta: { changes: 0 } }); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await requestDeployRelease(); + + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ + message: 'Deployment release is no longer pending apply', + }); + expect(mockD1Bind).toHaveBeenCalledWith(expect.any(String), 'rel-1'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockSignDeployPayload).not.toHaveBeenCalled(); + }); + it('includes attached volume descriptors in the signed apply payload', async () => { vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000); stubHappyPathDb(); @@ -640,7 +667,7 @@ describe('deploy release callback route', () => { }); // The release lookup, status flip to 'applying', DNS, volume descriptors, // and signing must NOT run for a rejected request. - expect(mockUpdateSet).not.toHaveBeenCalled(); + expect(mockD1Prepare).not.toHaveBeenCalled(); expect(mockBuildVolumeMountDescriptors).not.toHaveBeenCalled(); expect(fetchMock).not.toHaveBeenCalled(); expect(mockSignDeployPayload).not.toHaveBeenCalled(); diff --git a/apps/api/tests/unit/routes/deployment-custom-domains-vertical.test.ts b/apps/api/tests/unit/routes/deployment-custom-domains-vertical.test.ts index 5fb81d9d89..7e463be096 100644 --- a/apps/api/tests/unit/routes/deployment-custom-domains-vertical.test.ts +++ b/apps/api/tests/unit/routes/deployment-custom-domains-vertical.test.ts @@ -141,6 +141,9 @@ const mockVerifyCallbackToken = vi.fn(); const mockMintProjectRegistryCredential = vi.fn(); const mockLoadResolvedSecrets = vi.fn(); const mockLoadDeploymentInterpolationEnv = vi.fn(); +const mockD1Run = vi.fn(); +const mockD1Bind = vi.fn(() => ({ run: mockD1Run })); +const mockD1Prepare = vi.fn(() => ({ bind: mockD1Bind })); let envRows: EnvironmentRow[] = []; let projectRows: ProjectRow[] = []; @@ -430,7 +433,7 @@ function createApp() { function env(): Env { return { - DATABASE: {} as D1Database, + DATABASE: { prepare: mockD1Prepare } as unknown as D1Database, BASE_DOMAIN: 'sammy.party', CF_API_TOKEN: 'cf-token', CF_ZONE_ID: 'zone-1', @@ -488,6 +491,10 @@ describe('deployment custom domain attach verify apply flow', () => { }); mockLoadResolvedSecrets.mockResolvedValue({}); mockLoadDeploymentInterpolationEnv.mockResolvedValue({ values: {} }); + mockD1Prepare.mockClear(); + mockD1Bind.mockClear(); + mockD1Run.mockReset(); + mockD1Run.mockResolvedValue({ meta: { changes: 1 } }); projectRows = [{ id: 'proj-1', userId: 'user-1' }]; nodeRows = [{ id: 'node-deploy-1', userId: 'user-1', ipAddress: '203.0.113.10' }]; envRows = [ diff --git a/apps/api/tests/unit/routes/deployment-environment-observability.test.ts b/apps/api/tests/unit/routes/deployment-environment-observability.test.ts index 029d2c4cca..ff82776b4a 100644 --- a/apps/api/tests/unit/routes/deployment-environment-observability.test.ts +++ b/apps/api/tests/unit/routes/deployment-environment-observability.test.ts @@ -467,7 +467,10 @@ describe('deployment environment observability routes', () => { ); expect(updateCalls).toContainEqual({ table: expect.objectContaining({ id: 'deploymentReleases.id' }), - values: { status: 'created' }, + values: expect.objectContaining({ + status: 'created', + statusUpdatedAt: expect.any(String), + }), }); expect(body.lifecycle).toMatchObject({ started: true, @@ -785,7 +788,10 @@ describe('deployment environment observability routes', () => { expect.arrayContaining([ { table: expect.objectContaining({ id: 'deploymentReleases.id' }), - values: { status: 'created' }, + values: expect.objectContaining({ + status: 'created', + statusUpdatedAt: expect.any(String), + }), }, { table: expect.objectContaining({ id: 'deploymentEnvironments.id' }), @@ -896,7 +902,10 @@ describe('deployment environment observability routes', () => { }, { table: expect.objectContaining({ id: 'deploymentReleases.id' }), - values: { status: 'failed' }, + values: expect.objectContaining({ + status: 'failed', + statusUpdatedAt: expect.any(String), + }), }, ]) ); diff --git a/apps/api/tests/unit/routes/terminal.test.ts b/apps/api/tests/unit/routes/terminal.test.ts index 392247b0c0..8a4ec66fd8 100644 --- a/apps/api/tests/unit/routes/terminal.test.ts +++ b/apps/api/tests/unit/routes/terminal.test.ts @@ -10,6 +10,21 @@ import { updateTerminalActivity } from '../../../src/services/project-data'; vi.mock('drizzle-orm/d1'); vi.mock('../../../src/middleware/auth', () => ({ + getAuth: () => ({ + user: { + id: 'user-1', + email: 'user@example.com', + name: 'Test User', + avatarUrl: null, + role: 'user', + status: 'active', + }, + session: { + id: 'session-1', + token: 'token-session-1', + expiresAt: new Date(Date.now() + 60_000), + }, + }), requireAuth: () => vi.fn((c: { set: (key: string, value: unknown) => void }, next: () => Promise) => { c.set('auth', { @@ -23,13 +38,13 @@ vi.mock('../../../src/middleware/auth', () => ({ }, session: { id: 'session-1', + token: 'token-session-1', expiresAt: new Date(Date.now() + 60_000), }, }); return next(); }), requireApproved: () => vi.fn((_c: unknown, next: () => Promise) => next()), - getUserId: () => 'user-1', })); vi.mock('../../../src/services/jwt', () => ({ signTerminalToken: vi.fn(), @@ -160,7 +175,9 @@ describe('terminal routes', () => { expiresAt: '2026-05-10T03:00:00.000Z', workspaceUrl: 'https://ws-ws-123.sammy.party', }); - expect(signTerminalToken).toHaveBeenCalledWith('user-1', 'ws-123', env); + expect(signTerminalToken).toHaveBeenCalledWith('user-1', 'ws-123', env, { + sessionToken: 'token-session-1', + }); expect(updateTerminalActivity).not.toHaveBeenCalled(); }); diff --git a/apps/api/tests/unit/scheduled/d1-retention.test.ts b/apps/api/tests/unit/scheduled/d1-retention.test.ts index 33705b6405..103c0da6f6 100644 --- a/apps/api/tests/unit/scheduled/d1-retention.test.ts +++ b/apps/api/tests/unit/scheduled/d1-retention.test.ts @@ -3,12 +3,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as schema from '../../../src/db/schema'; import type { Env } from '../../../src/env'; +import { + COMPOSE_IMAGE_ARTIFACT_PREFIX, + runComposeImageArtifactCleanup, +} from '../../../src/scheduled/compose-image-artifact-cleanup'; import { DEFAULT_DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY, runDeploymentReleaseRetention, runScheduledDeploymentReleaseRetention, runScheduledSessionSnapshotPurge, runSessionSnapshotPurge, + runStaleDeploymentReleaseReconciliation, } from '../../../src/scheduled/d1-retention'; import { createMemoryKv, createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; @@ -21,6 +26,7 @@ describe('D1 retention sweeps', () => { createSchemaTables(sqlite, [ schema.deploymentEnvironments, schema.deploymentReleases, + schema.deploymentReleaseEvents, schema.sessionSnapshots, ]); env = { @@ -33,19 +39,55 @@ describe('D1 retention sweeps', () => { sqlite.close(); }); - function addEnvironment(id: string, observedAppliedSeq: number | null = null): void { + function addEnvironment( + id: string, + observedAppliedSeq: number | null = null, + options: { observedStatus?: string | null; observedAt?: string | null } = {} + ): void { sqlite - .prepare('INSERT INTO deployment_environments (id, observed_applied_seq) VALUES (?, ?)') - .run(id, observedAppliedSeq); + .prepare( + `INSERT INTO deployment_environments + (id, observed_applied_seq, observed_status, observed_at) + VALUES (?, ?, ?, ?)` + ) + .run(id, observedAppliedSeq, options.observedStatus ?? null, options.observedAt ?? null); } - function addRelease(environmentId: string, version: number, status: string): void { + function artifactKey(name: string): string { + return `${COMPOSE_IMAGE_ARTIFACT_PREFIX}project/env/workspace/upload/${name}.docker-save.tar`; + } + + function composeArtifactManifest(name: string): string { + return JSON.stringify({ services: [{ serviceName: name, r2Key: artifactKey(name) }] }); + } + + function addRelease( + environmentId: string, + version: number, + status: string, + options: { + manifest?: string | null; + createdAt?: string; + statusUpdatedAt?: string | null; + source?: string | null; + } = {} + ): void { sqlite .prepare( - `INSERT INTO deployment_releases (id, environment_id, version, status) - VALUES (?, ?, ?, ?)` + `INSERT INTO deployment_releases + (id, environment_id, version, status, manifest, created_at, status_updated_at, source) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ) - .run(`${environmentId}-v${version}`, environmentId, version, status); + .run( + `${environmentId}-v${version}`, + environmentId, + version, + status, + options.manifest ?? null, + options.createdAt ?? null, + options.statusUpdatedAt ?? null, + options.source ?? null + ); } function releaseIds(environmentId: string): string[] { @@ -59,6 +101,54 @@ describe('D1 retention sweeps', () => { .map((row) => (row as { id: string }).id); } + function releaseStatuses(environmentId: string): Record { + return Object.fromEntries( + sqlite + .prepare( + `SELECT id, status FROM deployment_releases + WHERE environment_id = ? + ORDER BY version ASC` + ) + .all(environmentId) + .map((row) => { + const typed = row as { id: string; status: string }; + return [typed.id, typed.status]; + }) + ); + } + + function addReleaseEvent( + environmentId: string, + version: number, + createdAt: string, + releaseId: string | null = `${environmentId}-v${version}` + ): void { + sqlite + .prepare( + `INSERT INTO deployment_release_events + (id, project_id, environment_id, release_id, release_version, node_id, seq, event_type, message, created_at) + VALUES (?, 'project-1', ?, ?, ?, 'node-1', 1, 'deployment.apply.fetch_started', 'fetching', ?)` + ) + .run( + `${environmentId}-v${version}-event-${createdAt}`, + environmentId, + releaseId, + version, + createdAt + ); + } + + function makeR2(objects: Array<{ key: string; size: number; uploaded: Date }>) { + const deleted: string[] = []; + return { + list: async () => ({ objects, truncated: false }), + delete: async (key: string) => { + deleted.push(key); + }, + deleted, + }; + } + function addSnapshot(id: string, expiresAt: string, sleeping = true): void { sqlite .prepare( @@ -167,6 +257,284 @@ describe('D1 retention sweeps', () => { expect(releaseIds('env-zombie')).toEqual(survivors); }); + it('protects fresh and actively observed applying compose releases', async () => { + addEnvironment('env-fresh', 1, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-fresh', 1, 'applied', { + createdAt: '2026-08-01T00:00:00.000Z', + manifest: composeArtifactManifest('fresh-current'), + }); + addRelease('env-fresh', 2, 'applying', { + createdAt: '2026-08-16T11:30:00.000Z', + statusUpdatedAt: '2026-08-16T11:30:00.000Z', + manifest: composeArtifactManifest('fresh-applying'), + source: 'compose-publish', + }); + addRelease('env-fresh', 3, 'created', { + createdAt: '2026-08-16T11:45:00.000Z', + statusUpdatedAt: '2026-08-16T11:45:00.000Z', + manifest: composeArtifactManifest('fresh-created'), + source: 'compose-publish', + }); + + addEnvironment('env-active', 4, { + observedStatus: 'applying', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-active', 5, 'applying', { + createdAt: '2026-06-01T00:00:00.000Z', + statusUpdatedAt: '2026-06-01T00:00:00.000Z', + manifest: composeArtifactManifest('actively-applying'), + source: 'compose-publish', + }); + + const result = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + + expect(result.reconciledReleases).toBe(0); + expect(releaseStatuses('env-fresh')['env-fresh-v2']).toBe('applying'); + expect(releaseStatuses('env-fresh')['env-fresh-v3']).toBe('created'); + expect(releaseStatuses('env-active')['env-active-v5']).toBe('applying'); + }); + + it('marks provably stale nonterminal compose releases failed', async () => { + addEnvironment('env-stale', 3, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-stale', 4, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('stale-archive'), + source: 'compose-publish', + }); + addRelease('env-stale', 5, 'created', { + createdAt: '2026-06-27T00:00:00.000Z', + statusUpdatedAt: '2026-06-27T00:00:00.000Z', + manifest: composeArtifactManifest('stale-created-archive'), + source: 'compose-publish', + }); + + const result = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + + expect(result).toMatchObject({ + enabled: true, + batchSize: 50, + staleHours: 168, + activityGraceHours: 6, + reconciledReleases: 2, + }); + expect(releaseStatuses('env-stale')).toEqual({ + 'env-stale-v4': 'failed', + 'env-stale-v5': 'failed', + }); + }); + + it('protects the observed-applied release even when its row is nonterminal and old', async () => { + addEnvironment('env-observed', 4, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-observed', 4, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('observed-applied'), + source: 'compose-publish', + }); + + const result = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + + expect(result.reconciledReleases).toBe(0); + expect(releaseStatuses('env-observed')).toEqual({ 'env-observed-v4': 'applying' }); + }); + + it('runs stale reconciliation before terminal retention so R2 cleanup can reclaim unreferenced archives', async () => { + const staleKey = artifactKey('stale-old'); + addEnvironment('env-order', 4, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-order', 1, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('stale-old'), + source: 'compose-publish', + }); + for (let version = 2; version <= 4; version += 1) { + addRelease('env-order', version, 'applied', { + createdAt: `2026-08-0${version}T00:00:00.000Z`, + manifest: composeArtifactManifest(`applied-${version}`), + }); + } + env.DEPLOYMENT_RELEASE_RETENTION_COUNT = '3'; + env.DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE = '10'; + env.R2 = makeR2([ + { key: staleKey, size: 123, uploaded: new Date('2026-06-27T00:00:00.000Z') }, + ]) as unknown as R2Bucket; + + const retention = await runDeploymentReleaseRetention( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + const cleanup = await runComposeImageArtifactCleanup(env, new Date('2026-08-16T12:00:00.000Z')); + + expect(retention).toMatchObject({ + reconciledStaleReleases: 1, + deletedReleases: 1, + }); + expect(releaseIds('env-order')).toEqual(['env-order-v2', 'env-order-v3', 'env-order-v4']); + expect((env.R2 as unknown as { deleted: string[] }).deleted).toEqual([staleKey]); + expect(cleanup.deletedObjects).toBe(1); + }); + + it('bounds reconciliation batches and is idempotent across repeated sweeps', async () => { + addEnvironment('env-batch', 0, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + for (let version = 1; version <= 3; version += 1) { + addRelease('env-batch', version, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest(`batch-${version}`), + source: 'compose-publish', + }); + } + env.DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE = '2'; + + const now = new Date('2026-08-16T12:00:00.000Z'); + const first = await runStaleDeploymentReleaseReconciliation(env, now); + const second = await runStaleDeploymentReleaseReconciliation(env, now); + const third = await runStaleDeploymentReleaseReconciliation(env, now); + + expect(first.reconciledReleases).toBe(2); + expect(second.reconciledReleases).toBe(1); + expect(third.reconciledReleases).toBe(0); + expect(releaseStatuses('env-batch')).toEqual({ + 'env-batch-v1': 'failed', + 'env-batch-v2': 'failed', + 'env-batch-v3': 'failed', + }); + }); + + it('protects stale-looking releases with recent apply activity events', async () => { + addEnvironment('env-lease', 0, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-lease', 1, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('recent-event'), + source: 'compose-publish', + }); + addReleaseEvent('env-lease', 1, '2026-08-16T09:00:00.000Z'); + + const result = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + + expect(result.reconciledReleases).toBe(0); + expect(releaseStatuses('env-lease')).toEqual({ 'env-lease-v1': 'applying' }); + }); + + it('honors reconciliation kill switch and stale-age configuration', async () => { + addEnvironment('env-config', 0, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-config', 1, 'applying', { + createdAt: '2026-08-10T00:00:00.000Z', + statusUpdatedAt: '2026-08-10T00:00:00.000Z', + manifest: composeArtifactManifest('configured-age'), + source: 'compose-publish', + }); + env.DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS = '240'; + + const configuredProtected = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + env.DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS = '24'; + env.DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED = 'false'; + const disabled = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + + expect(configuredProtected).toMatchObject({ staleHours: 240, reconciledReleases: 0 }); + expect(disabled).toMatchObject({ enabled: false, reconciledReleases: 0 }); + expect(releaseStatuses('env-config')).toEqual({ 'env-config-v1': 'applying' }); + }); + + it('fails closed for malformed manifests, future status activity, future observations, and unknown statuses', async () => { + addEnvironment('env-ambiguous', 0, { + observedStatus: 'applied', + observedAt: '2026-08-16T12:00:00.000Z', + }); + addRelease('env-ambiguous', 1, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: `{"services":[{"r2Key":"${artifactKey('malformed')}"}`, + source: 'compose-publish', + }); + addRelease('env-ambiguous', 2, 'queued-future-status', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('future-status'), + source: 'compose-publish', + }); + addRelease('env-ambiguous', 3, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-08-17T00:00:00.000Z', + manifest: composeArtifactManifest('future-status-update'), + source: 'compose-publish', + }); + addRelease('env-ambiguous', 4, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('build-on-node-same-prefix'), + source: 'build-on-node', + }); + addEnvironment('env-future-observed', 0, { + observedStatus: 'applied', + observedAt: '2026-08-17T00:00:00.000Z', + }); + addRelease('env-future-observed', 1, 'applying', { + createdAt: '2026-06-26T00:00:00.000Z', + statusUpdatedAt: '2026-06-26T00:00:00.000Z', + manifest: composeArtifactManifest('future-observed'), + source: 'compose-publish', + }); + + const result = await runStaleDeploymentReleaseReconciliation( + env, + new Date('2026-08-16T12:00:00.000Z') + ); + + expect(result.reconciledReleases).toBe(0); + expect(releaseStatuses('env-ambiguous')).toEqual({ + 'env-ambiguous-v1': 'applying', + 'env-ambiguous-v2': 'queued-future-status', + 'env-ambiguous-v3': 'applying', + 'env-ambiguous-v4': 'applying', + }); + expect(releaseStatuses('env-future-observed')).toEqual({ + 'env-future-observed-v1': 'applying', + }); + }); + it('interval-gates scheduled release retention with its own KV marker', async () => { addEnvironment('env-scheduled'); for (let version = 1; version <= 4; version += 1) { diff --git a/apps/api/tests/unit/services/deployment-control.test.ts b/apps/api/tests/unit/services/deployment-control.test.ts index f3ea9dc48b..e9ad7c4ef2 100644 --- a/apps/api/tests/unit/services/deployment-control.test.ts +++ b/apps/api/tests/unit/services/deployment-control.test.ts @@ -248,7 +248,9 @@ describe('reconcileDeploymentReleaseStatuses', () => { status: 'applying', }); - expect(db.updates.map((update) => update.values)).toEqual([{ status: 'applying' }]); + expect(db.updates.map((update) => update.values)).toEqual([ + expect.objectContaining({ status: 'applying', statusUpdatedAt: expect.any(String) }), + ]); }); it('marks applied release applied and newer failed release failed after revert', async () => { @@ -260,8 +262,8 @@ describe('reconcileDeploymentReleaseStatuses', () => { }); expect(db.updates.map((update) => update.values)).toEqual([ - { status: 'applied' }, - { status: 'failed' }, + expect.objectContaining({ status: 'applied', statusUpdatedAt: expect.any(String) }), + expect.objectContaining({ status: 'failed', statusUpdatedAt: expect.any(String) }), ]); }); @@ -276,6 +278,8 @@ describe('reconcileDeploymentReleaseStatuses', () => { status: 'failed-initial', }); - expect(db.updates.map((update) => update.values)).toEqual([{ status: 'failed' }]); + expect(db.updates.map((update) => update.values)).toEqual([ + expect.objectContaining({ status: 'failed', statusUpdatedAt: expect.any(String) }), + ]); }); }); diff --git a/apps/api/tests/unit/services/terminal-token-liveness.test.ts b/apps/api/tests/unit/services/terminal-token-liveness.test.ts new file mode 100644 index 0000000000..ef2daff617 --- /dev/null +++ b/apps/api/tests/unit/services/terminal-token-liveness.test.ts @@ -0,0 +1,135 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import * as schema from '../../../src/db/schema'; +import type { Env } from '../../../src/env'; +import { assertTerminalTokenSessionLive } from '../../../src/services/terminal-token-liveness'; +import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; + +describe('terminal token session liveness', () => { + let sqlite: Database.Database; + let env: Env; + + beforeEach(() => { + sqlite = new Database(':memory:'); + createSchemaTables(sqlite, [schema.users, schema.sessions, schema.platformSettings]); + env = { + DATABASE: createSqliteD1(sqlite), + REQUIRE_APPROVAL: 'false', + } as Env; + }); + + afterEach(() => { + sqlite.close(); + }); + + function seedUser(overrides: Partial<{ id: string; role: string; status: string }> = {}): void { + const user = { + id: overrides.id ?? 'user-1', + role: overrides.role ?? 'user', + status: overrides.status ?? 'active', + }; + sqlite + .prepare( + `INSERT INTO users (id, email, email_verified, role, status, created_at, updated_at) + VALUES (?, ?, 1, ?, ?, ?, ?)` + ) + .run(user.id, `${user.id}@example.com`, user.role, user.status, Date.now(), Date.now()); + } + + function seedSession( + overrides: Partial<{ id: string; token: string; userId: string; expiresAt: number }> = {} + ): void { + const id = overrides.id ?? 'session-1'; + sqlite + .prepare( + `INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + id, + overrides.token ?? `token-${id}`, + overrides.userId ?? 'user-1', + overrides.expiresAt ?? Date.now() + 60_000, + Date.now(), + Date.now() + ); + } + + it('allows a token whose minting auth session still exists for an active user', async () => { + seedUser(); + seedSession(); + + await expect( + assertTerminalTokenSessionLive(env, { + workspace: 'workspace-1', + subject: 'user-1', + sessionToken: 'token-session-1', + }) + ).resolves.toBeUndefined(); + }); + + it('rejects a token after logout removes the minting session row', async () => { + seedUser(); + + await expect( + assertTerminalTokenSessionLive(env, { + workspace: 'workspace-1', + subject: 'user-1', + sessionToken: 'token-session-1', + }) + ).rejects.toThrow('Terminal token auth session is not live'); + }); + + it('rejects legacy or ambiguous tokens without a session claim', async () => { + seedUser(); + seedSession(); + + await expect( + assertTerminalTokenSessionLive(env, { + workspace: 'workspace-1', + subject: 'user-1', + }) + ).rejects.toThrow('Terminal token is not bound to an auth session'); + }); + + it('rejects a token whose session belongs to another user', async () => { + seedUser({ id: 'user-1' }); + seedUser({ id: 'user-2' }); + seedSession({ userId: 'user-2' }); + + await expect( + assertTerminalTokenSessionLive(env, { + workspace: 'workspace-1', + subject: 'user-1', + sessionToken: 'token-session-1', + }) + ).rejects.toThrow('Terminal token auth session is not live'); + }); + + it('rejects an expired minting auth session', async () => { + seedUser(); + seedSession({ expiresAt: Date.now() - 1_000 }); + + await expect( + assertTerminalTokenSessionLive(env, { + workspace: 'workspace-1', + subject: 'user-1', + sessionToken: 'token-session-1', + }) + ).rejects.toThrow('Terminal token auth session expired'); + }); + + it('rejects a token for a suspended user even when the session row remains', async () => { + seedUser({ status: 'suspended' }); + seedSession(); + + await expect( + assertTerminalTokenSessionLive(env, { + workspace: 'workspace-1', + subject: 'user-1', + sessionToken: 'token-session-1', + }) + ).rejects.toThrow('Your account has been suspended'); + }); +}); diff --git a/apps/api/tests/unit/vm-agent-cross-boundary-contract.test.ts b/apps/api/tests/unit/vm-agent-cross-boundary-contract.test.ts index 2479e7d317..316c8d742d 100644 --- a/apps/api/tests/unit/vm-agent-cross-boundary-contract.test.ts +++ b/apps/api/tests/unit/vm-agent-cross-boundary-contract.test.ts @@ -67,16 +67,18 @@ function getAttachmentTransferTimeout(value: string | undefined): number { */ function setupNodeAgentMocks( responseInit?: { status: number; body?: string | null }, - signTokenOverride?: ReturnType, + signTokenOverride?: ReturnType ): MockFetchCapture { const capture: MockFetchCapture = { url: null, body: null, headers: null, method: null }; const response = responseInit ?? DEFAULT_NODE_AGENT_RESPONSE; vi.doMock('../../src/services/jwt', () => ({ - signNodeManagementToken: signTokenOverride ?? vi.fn().mockResolvedValue({ - token: 'mock-jwt', - expiresAt: new Date().toISOString(), - }), + signNodeManagementToken: + signTokenOverride ?? + vi.fn().mockResolvedValue({ + token: 'mock-jwt', + expiresAt: new Date().toISOString(), + }), })); vi.doMock('../../src/services/telemetry', () => ({ @@ -94,7 +96,7 @@ function setupNodeAgentMocks( new Response(resBody, { status: response.status, headers: resBody ? { 'Content-Type': 'application/json' } : undefined, - }), + }) ); }), getTimeoutMs: vi.fn().mockReturnValue(30000), @@ -130,39 +132,44 @@ function setupNodeAgentMocksWithError(errorMessage: string): void { describe('Contract 1: Attachment Transfer (TaskRunner → VM Agent)', () => { describe('URL construction', () => { - it('uses ws-* subdomain routing, NOT {nodeId}.vm.* routing', () => { - // The attachment transfer uses workspace-scoped routing: - // ws-${workspaceId}.${baseDomain}:${port} - // NOT the node-management routing used by other calls: + it('uses {nodeId}.vm.* backend routing, NOT browser ws-* proxy routing', () => { + // The attachment transfer is an internal Worker → VM-agent call. It must + // use the two-level backend hostname so browser workspace-proxy session + // liveness gates do not apply to control-plane attachment uploads: // ${nodeId}.vm.${baseDomain}:${port} + // NOT browser proxy routing: + // ws-${workspaceId}.${baseDomain}:${port} const protocol = 'https'; const port = '8443'; // Workspace IDs are ULIDs (e.g., '01HXYZ...'), NOT ws-prefixed strings. // The ws- prefix is added by the URL construction, not stored in the ID. const workspaceId = '01HXYZ789DEF'; + const nodeId = 'node-01HXYZ789DEF'; const baseDomain = 'example.com'; - // Reproduce URL construction from workspace-steps.ts:374 - const vmUrl = `${protocol}://ws-${workspaceId}.${baseDomain}:${port}`; + // Reproduce URL construction from workspace-steps.ts + const vmUrl = `${protocol}://${nodeId.toLowerCase()}.vm.${baseDomain}:${port}`; const uploadUrl = `${vmUrl}/workspaces/${workspaceId}/files/upload`; - expect(uploadUrl).toBe('https://ws-01HXYZ789DEF.example.com:8443/workspaces/01HXYZ789DEF/files/upload'); - // ws- prefix appears exactly once in subdomain (not doubled) - expect(uploadUrl).toMatch(/^https:\/\/ws-[^.]+\.example\.com/); - expect(uploadUrl).not.toContain('.vm.'); + expect(uploadUrl).toBe( + 'https://node-01hxyz789def.vm.example.com:8443/workspaces/01HXYZ789DEF/files/upload' + ); + expect(uploadUrl).toMatch(/^https:\/\/[^.]+\.vm\.example\.com/); + expect(uploadUrl).not.toContain('://ws-'); }); it('constructs correct URL with default protocol and port', () => { const protocol = 'https'; const port = '8443'; const workspaceId = 'test-workspace'; + const nodeId = 'node-test-workspace'; const baseDomain = 'sammy.party'; - const vmUrl = `${protocol}://ws-${workspaceId}.${baseDomain}:${port}`; + const vmUrl = `${protocol}://${nodeId}.vm.${baseDomain}:${port}`; const uploadUrl = `${vmUrl}/workspaces/${workspaceId}/files/upload`; expect(uploadUrl).toBe( - 'https://ws-test-workspace.sammy.party:8443/workspaces/test-workspace/files/upload', + 'https://node-test-workspace.vm.sammy.party:8443/workspaces/test-workspace/files/upload' ); }); }); @@ -310,8 +317,13 @@ describe('Contract 2: Agent Activity Callback (VM Agent → API Worker)', () => const projectID = 'proj-abc'; const sessionID = 'sess-xyz'; - const goUrl = trimTrailingSlashes(controlPlaneURL) + - '/api/projects/' + projectID + '/acp-sessions/' + sessionID + '/activity'; + const goUrl = + trimTrailingSlashes(controlPlaneURL) + + '/api/projects/' + + projectID + + '/acp-sessions/' + + sessionID + + '/activity'; // The API route is mounted at: // app.route('/api/projects', agentActivityCallbackRoute); @@ -319,7 +331,9 @@ describe('Contract 2: Agent Activity Callback (VM Agent → API Worker)', () => // '/:id/acp-sessions/:sessionId/activity' // So the full path is: // /api/projects/:id/acp-sessions/:sessionId/activity - expect(goUrl).toBe('https://api.example.com/api/projects/proj-abc/acp-sessions/sess-xyz/activity'); + expect(goUrl).toBe( + 'https://api.example.com/api/projects/proj-abc/acp-sessions/sess-xyz/activity' + ); }); it('handles trailing slash in controlPlaneURL', () => { @@ -327,10 +341,17 @@ describe('Contract 2: Agent Activity Callback (VM Agent → API Worker)', () => const projectID = 'proj-abc'; const sessionID = 'sess-xyz'; - const goUrl = trimTrailingSlashes(controlPlaneURL) + - '/api/projects/' + projectID + '/acp-sessions/' + sessionID + '/activity'; + const goUrl = + trimTrailingSlashes(controlPlaneURL) + + '/api/projects/' + + projectID + + '/acp-sessions/' + + sessionID + + '/activity'; - expect(goUrl).toBe('https://api.example.com/api/projects/proj-abc/acp-sessions/sess-xyz/activity'); + expect(goUrl).toBe( + 'https://api.example.com/api/projects/proj-abc/acp-sessions/sess-xyz/activity' + ); }); }); @@ -433,8 +454,11 @@ describe('Contract 3: Credential Sync Callback (VM Agent → API Worker)', () => const controlPlaneURL = 'https://api.example.com'; const workspaceID = 'ws-abc-123'; - const goUrl = trimTrailingSlashes(controlPlaneURL) + - '/api/workspaces/' + encodeURIComponent(workspaceID) + '/agent-credential-sync'; + const goUrl = + trimTrailingSlashes(controlPlaneURL) + + '/api/workspaces/' + + encodeURIComponent(workspaceID) + + '/agent-credential-sync'; // The API route is mounted at: // app.route('/api/workspaces', runtimeRoutes); (inside workspacesRoutes) @@ -447,8 +471,11 @@ describe('Contract 3: Credential Sync Callback (VM Agent → API Worker)', () => const controlPlaneURL = 'https://api.example.com'; const workspaceID = 'ws-abc/123'; - const goUrl = trimTrailingSlashes(controlPlaneURL) + - '/api/workspaces/' + encodeURIComponent(workspaceID) + '/agent-credential-sync'; + const goUrl = + trimTrailingSlashes(controlPlaneURL) + + '/api/workspaces/' + + encodeURIComponent(workspaceID) + + '/agent-credential-sync'; expect(goUrl).toContain('ws-abc%2F123'); }); @@ -490,7 +517,7 @@ describe('Contract 4: Send Prompt to Agent (API Worker → VM Agent)', () => { 'sess-xyz', 'Fix the bug in auth.ts', env, - 'user-123', + 'user-123' ); // Verify URL path @@ -527,7 +554,7 @@ describe('Contract 4: Send Prompt to Agent (API Worker → VM Agent)', () => { 'Follow up', env, 'user-123', - 'msg-prepersisted-001', + 'msg-prepersisted-001' ); const parsedBody = JSON.parse(capture.body!); @@ -555,7 +582,7 @@ describe('Contract 4: Send Prompt to Agent (API Worker → VM Agent)', () => { env, 'user-123', 'msg-prepersisted-002', - { requestTimeoutMs: 1_234, protocolVersion: 1, deliveryId: 'delivery-002' }, + { requestTimeoutMs: 1_234, protocolVersion: 1, deliveryId: 'delivery-002' } ); expect(JSON.parse(capture.body!)).toEqual({ @@ -593,7 +620,7 @@ describe('Contract 4: Send Prompt to Agent (API Worker → VM Agent)', () => { opencodeProvider: 'scaleway', opencodeBaseUrl: 'https://api.scaleway.ai/v1', }, - { projectId: 'proj-abc', taskId: 'task-xyz', taskMode: 'task' }, + { projectId: 'proj-abc', taskId: 'task-xyz', taskMode: 'task' } ); // Verify URL path @@ -633,7 +660,7 @@ describe('Contract 4: Send Prompt to Agent (API Worker → VM Agent)', () => { 'claude-code', 'Hello', {} as any, - 'user-123', + 'user-123' // No MCP server, no overrides, no task context ); @@ -666,7 +693,7 @@ describe('Contract 4: Send Prompt to Agent (API Worker → VM Agent)', () => { { model: 'opencode-go/glm-5.2', opencodeProvider: 'opencode-go', - }, + } ); const parsedBody = JSON.parse(capture.body!); @@ -695,7 +722,7 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = 'ws-test', 'sess-xyz', {} as any, - 'user-123', + 'user-123' ); // cancelAgentSessionOnNode normalizes any 2xx to { success: true, status: 200 } @@ -716,7 +743,7 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = 'ws-test', 'sess-xyz', {} as any, - 'user-123', + 'user-123' ); expect(result.success).toBe(false); @@ -733,7 +760,7 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = 'ws-test', 'sess-xyz', {} as any, - 'user-123', + 'user-123' ); expect(result.success).toBe(false); @@ -750,7 +777,7 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = 'ws-test', 'sess-xyz', { BASE_DOMAIN: 'example.com' } as any, - 'user-123', + 'user-123' ); expect(capture.method).toBe('POST'); @@ -769,7 +796,7 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = 'ws-test', 'sess-xyz', { BASE_DOMAIN: 'example.com' } as any, - 'user-123', + 'user-123' ); expect(capture.method).toBe('POST'); @@ -783,13 +810,7 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = // stop THROWS on error (unlike cancel which returns { success: false }) await expect( - stopAgentSessionOnNode( - 'node-abc', - 'ws-test', - 'sess-xyz', - {} as any, - 'user-123', - ), + stopAgentSessionOnNode('node-abc', 'ws-test', 'sess-xyz', {} as any, 'user-123') ).rejects.toThrow('Node Agent request failed: 404'); }); }); @@ -811,11 +832,16 @@ describe('Contract 5: Cancel/Stop Agent Session (API Worker → VM Agent)', () = 'ws-test', 'sess-xyz', { BASE_DOMAIN: 'example.com' } as any, - 'user-123', + 'user-123' ); // Verify it used signNodeManagementToken (not signCallbackToken or signTerminalToken) - expect(mockSignToken).toHaveBeenCalledWith('user-123', 'node-abc', 'ws-test', expect.anything()); + expect(mockSignToken).toHaveBeenCalledWith( + 'user-123', + 'node-abc', + 'ws-test', + expect.anything() + ); expect(capture.headers!.get('Authorization')).toBe('Bearer mgmt-jwt'); }); }); diff --git a/apps/api/tests/unit/workspace-proxy-ownership.test.ts b/apps/api/tests/unit/workspace-proxy-ownership.test.ts index 6b3471d052..603c1a4f24 100644 --- a/apps/api/tests/unit/workspace-proxy-ownership.test.ts +++ b/apps/api/tests/unit/workspace-proxy-ownership.test.ts @@ -4,6 +4,18 @@ const mockGetSession = vi.fn(); const mockVerifyTerminalToken = vi.fn(); const mockSignTerminalToken = vi.fn(); let workspaceResult: { nodeId: string; status: string } | null = null; +let terminalSessionResult: { + sessionId: string; + userId: string; + expiresAt: Date; + userRole: string; + userStatus: string; +} | null = null; +let platformSettingResult: { + value: string; + updatedAt: string | null; + updatedBy: string | null; +} | null = null; vi.mock('../../src/auth', () => ({ createAuth: vi.fn(() => ({ @@ -37,13 +49,20 @@ vi.mock('@cloudflare/containers', () => ({ vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn(() => ({ - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - get: vi.fn(async () => workspaceResult), - })), - })), - })), + select: vi.fn((selection?: Record) => { + const getResult = async () => { + if (selection && 'sessionId' in selection) return terminalSessionResult; + if (selection && 'value' in selection) return platformSettingResult; + return workspaceResult; + }; + const chain = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => chain), + get: vi.fn(getResult), + }; + return chain; + }), })), })); @@ -63,13 +82,22 @@ describe('workspace subdomain proxy ownership', () => { beforeEach(() => { vi.clearAllMocks(); workspaceResult = { nodeId: 'node-owner', status: 'running' }; + terminalSessionResult = { + sessionId: 'session-owner', + userId: 'user-owner', + expiresAt: new Date(Date.now() + 60_000), + userRole: 'user', + userStatus: 'active', + }; + platformSettingResult = null; mockGetSession.mockResolvedValue({ user: { id: 'user-owner' }, - session: { id: 'session-owner', expiresAt: new Date() }, + session: { id: 'session-owner', token: 'token-owner', expiresAt: new Date() }, }); mockVerifyTerminalToken.mockResolvedValue({ workspace: OWNER_WORKSPACE_ID, subject: 'user-owner', + sessionToken: 'token-owner', }); mockSignTerminalToken.mockResolvedValue({ token: 'backend-port-token', @@ -96,7 +124,7 @@ describe('workspace subdomain proxy ownership', () => { it('rejects suspended browser-session workspace subdomain requests before proxying', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-owner', status: 'suspended', role: 'admin' }, - session: { id: 'session-owner', expiresAt: new Date() }, + session: { id: 'session-owner', token: 'token-owner', expiresAt: new Date() }, }); const response = await worker.default.fetch( @@ -131,6 +159,60 @@ describe('workspace subdomain proxy ownership', () => { expect(proxiedUrl.searchParams.get('token')).toBe('valid-terminal-token'); }); + it('rejects a captured terminal token after the minting session row is gone', async () => { + mockGetSession.mockResolvedValue(null); + terminalSessionResult = null; + + const response = await worker.default.fetch( + new Request( + `https://ws-${OWNER_WORKSPACE_ID.toLowerCase()}.workspaces.example.com/terminal/ws/multi?token=logout-revoked-token` + ), + env + ); + + expect(response.status).toBe(401); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('rejects a terminal token without a minting session claim', async () => { + mockGetSession.mockResolvedValue(null); + mockVerifyTerminalToken.mockResolvedValue({ + workspace: OWNER_WORKSPACE_ID, + subject: 'user-owner', + }); + + const response = await worker.default.fetch( + new Request( + `https://ws-${OWNER_WORKSPACE_ID.toLowerCase()}.workspaces.example.com/terminal/ws/multi?token=legacy-token` + ), + env + ); + + expect(response.status).toBe(401); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('rejects a terminal token for a suspended user even when its session row exists', async () => { + mockGetSession.mockResolvedValue(null); + terminalSessionResult = { + sessionId: 'session-owner', + userId: 'user-owner', + expiresAt: new Date(Date.now() + 60_000), + userRole: 'user', + userStatus: 'suspended', + }; + + const response = await worker.default.fetch( + new Request( + `https://ws-${OWNER_WORKSPACE_ID.toLowerCase()}.workspaces.example.com/terminal/ws/multi?token=suspended-token` + ), + env + ); + + expect(response.status).toBe(401); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + it('rejects terminal tokens for a different workspace', async () => { mockGetSession.mockResolvedValue(null); mockVerifyTerminalToken.mockResolvedValue({ @@ -154,7 +236,15 @@ describe('workspace subdomain proxy ownership', () => { mockVerifyTerminalToken.mockResolvedValue({ workspace: OTHER_WORKSPACE_ID, subject: 'user-other', + sessionToken: 'token-other', }); + terminalSessionResult = { + sessionId: 'session-other', + userId: 'user-other', + expiresAt: new Date(Date.now() + 60_000), + userRole: 'user', + userStatus: 'active', + }; workspaceResult = null; const response = await worker.default.fetch( diff --git a/apps/api/tests/unit/workspace-proxy-port-access.test.ts b/apps/api/tests/unit/workspace-proxy-port-access.test.ts index 17ee5bd447..92e5e898f4 100644 --- a/apps/api/tests/unit/workspace-proxy-port-access.test.ts +++ b/apps/api/tests/unit/workspace-proxy-port-access.test.ts @@ -16,7 +16,24 @@ const mockGetSession = vi.fn(); const mockVerifyTerminalToken = vi.fn(); const mockSignTerminalToken = vi.fn(); const mockVerifyPortAccessToken = vi.fn(); -let workspaceResult: { nodeId: string; status: string; userId?: string; portsPublicEnabled?: boolean } | null = null; +let workspaceResult: { + nodeId: string; + status: string; + userId?: string; + portsPublicEnabled?: boolean; +} | null = null; +let terminalSessionResult: { + sessionId: string; + userId: string; + expiresAt: Date; + userRole: string; + userStatus: string; +} | null = null; +let platformSettingResult: { + value: string; + updatedAt: string | null; + updatedBy: string | null; +} | null = null; vi.mock('../../src/auth', () => ({ createAuth: vi.fn(() => ({ @@ -32,9 +49,13 @@ vi.mock('../../src/services/jwt', () => ({ verifyPortAccessToken: mockVerifyPortAccessToken, })); -vi.mock('cloudflare:workers', () => ({ - DurableObject: class {}, -}), { virtual: true }); +vi.mock( + 'cloudflare:workers', + () => ({ + DurableObject: class {}, + }), + { virtual: true } +); vi.mock('@cloudflare/sandbox', () => ({ Sandbox: class {}, @@ -47,13 +68,20 @@ vi.mock('@cloudflare/containers', () => ({ vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn(() => ({ - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - get: vi.fn(async () => workspaceResult), - })), - })), - })), + select: vi.fn((selection?: Record) => { + const getResult = async () => { + if (selection && 'sessionId' in selection) return terminalSessionResult; + if (selection && 'value' in selection) return platformSettingResult; + return workspaceResult; + }; + const chain = { + from: vi.fn(() => chain), + innerJoin: vi.fn(() => chain), + where: vi.fn(() => chain), + get: vi.fn(getResult), + }; + return chain; + }), })), })); @@ -73,7 +101,20 @@ const env = { describe('workspace proxy port-access auth', () => { beforeEach(() => { vi.clearAllMocks(); - workspaceResult = { nodeId: 'node-1', status: 'running', userId: 'user-1', portsPublicEnabled: false }; + workspaceResult = { + nodeId: 'node-1', + status: 'running', + userId: 'user-1', + portsPublicEnabled: false, + }; + terminalSessionResult = { + sessionId: 'session-1', + userId: 'user-1', + expiresAt: new Date(Date.now() + 60_000), + userRole: 'user', + userStatus: 'active', + }; + platformSettingResult = null; mockGetSession.mockResolvedValue(null); // No session cookie on port subdomains mockVerifyTerminalToken.mockRejectedValue(new Error('Invalid token')); mockSignTerminalToken.mockResolvedValue({ @@ -82,7 +123,7 @@ describe('workspace proxy port-access auth', () => { }); vi.stubGlobal( 'fetch', - vi.fn(async () => new Response('proxied', { status: 200 })), + vi.fn(async () => new Response('proxied', { status: 200 })) ); }); @@ -94,10 +135,8 @@ describe('workspace proxy port-access auth', () => { }); const response = await worker.default.fetch( - new Request( - `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/?port_token=valid-jwt`, - ), - env, + new Request(`https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/?port_token=valid-jwt`), + env ); expect(response.status).toBe(302); @@ -122,7 +161,7 @@ describe('workspace proxy port-access auth', () => { new Request(`https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/`, { headers: { cookie: 'sam_port_access=valid-jwt' }, }), - env, + env ); // Should proxy through (not 302, not 401) @@ -141,7 +180,7 @@ describe('workspace proxy port-access auth', () => { new Request(`https://ws-${WORKSPACE_ID}--8080.workspaces.example.com/`, { headers: { cookie: 'sam_port_access=wrong-port-cookie-jwt' }, }), - env, + env ); // Cookie port (3000) !== subdomain port (8080) → HTML 401 @@ -160,9 +199,9 @@ describe('workspace proxy port-access auth', () => { const response = await worker.default.fetch( new Request( - `https://ws-${WORKSPACE_ID}--8080.workspaces.example.com/?port_token=wrong-port-jwt`, + `https://ws-${WORKSPACE_ID}--8080.workspaces.example.com/?port_token=wrong-port-jwt` ), - env, + env ); // Port mismatch: token.port (3000) !== targetPort (8080) → HTML 401 @@ -182,9 +221,9 @@ describe('workspace proxy port-access auth', () => { const response = await worker.default.fetch( new Request( - `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/?port_token=wrong-ws-jwt`, + `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/?port_token=wrong-ws-jwt` ), - env, + env ); // Workspace mismatch → HTML 401 @@ -198,9 +237,9 @@ describe('workspace proxy port-access auth', () => { const response = await worker.default.fetch( new Request( - `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/?port_token=expired-jwt`, + `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/?port_token=expired-jwt` ), - env, + env ); expect(response.status).toBe(401); @@ -214,10 +253,8 @@ describe('workspace proxy port-access auth', () => { it('returns HTML error for port request with no auth at all', async () => { const response = await worker.default.fetch( - new Request( - `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/`, - ), - env, + new Request(`https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/`), + env ); expect(response.status).toBe(401); @@ -227,13 +264,16 @@ describe('workspace proxy port-access auth', () => { }); it('proxies a port request without browser auth when workspace ports are public', async () => { - workspaceResult = { nodeId: 'node-1', status: 'running', userId: 'user-1', portsPublicEnabled: true }; + workspaceResult = { + nodeId: 'node-1', + status: 'running', + userId: 'user-1', + portsPublicEnabled: true, + }; const response = await worker.default.fetch( - new Request( - `https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/`, - ), - env, + new Request(`https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/`), + env ); expect(response.status).toBe(200); @@ -252,22 +292,23 @@ describe('workspace proxy port-access auth', () => { // Simulate container response with a Set-Cookie header vi.stubGlobal( 'fetch', - vi.fn(async () => - new Response('container page', { - status: 200, - headers: { - 'set-cookie': 'malicious_cookie=evil; Path=/', - 'content-type': 'text/html', - }, - }), - ), + vi.fn( + async () => + new Response('container page', { + status: 200, + headers: { + 'set-cookie': 'malicious_cookie=evil; Path=/', + 'content-type': 'text/html', + }, + }) + ) ); const response = await worker.default.fetch( new Request(`https://ws-${WORKSPACE_ID}--3000.workspaces.example.com/`, { headers: { cookie: 'sam_port_access=valid-jwt' }, }), - env, + env ); expect(response.status).toBe(200); @@ -281,13 +322,12 @@ describe('workspace proxy port-access auth', () => { mockVerifyTerminalToken.mockResolvedValue({ workspace: WORKSPACE_ID, subject: 'user-1', + sessionToken: 'token-session-1', }); const response = await worker.default.fetch( - new Request( - `https://ws-${WORKSPACE_ID}.workspaces.example.com/terminal?token=terminal-jwt`, - ), - env, + new Request(`https://ws-${WORKSPACE_ID}.workspaces.example.com/terminal?token=terminal-jwt`), + env ); // Non-port workspace request should still work with terminal token diff --git a/apps/web/package.json b/apps/web/package.json index 289994d00f..6af3690c56 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,7 +31,7 @@ "better-auth": "catalog:", "d3-scale": "4.0.2", "dagre": "0.8.5", - "dompurify": "3.4.11", + "dompurify": "3.4.13", "lucide-react": "catalog:", "mermaid": "11.14.0", "prism-react-renderer": "catalog:", @@ -41,7 +41,7 @@ "react-router": "catalog:", "react-simple-maps": "3.0.0", "react-virtuoso": "catalog:", - "recharts": "3.10.0", + "recharts": "3.10.1", "remark-gfm": "catalog:", "tailwindcss": "4.3.2", "valibot": "catalog:" diff --git a/apps/www/src/content/docs/docs/architecture/overview.md b/apps/www/src/content/docs/docs/architecture/overview.md index 0999bb4331..41ae8fdc3b 100644 --- a/apps/www/src/content/docs/docs/architecture/overview.md +++ b/apps/www/src/content/docs/docs/architecture/overview.md @@ -161,11 +161,11 @@ Summary data flows back from DOs to D1 via debounced sync (e.g., `last_activity_ ### Other Bindings -| Service | Binding | Purpose | -| -------------- | ------- | ----------------------------------------------------------------------------------- | -| **KV** | `KV` | Auth sessions, bootstrap tokens, boot logs, MCP tokens | -| **R2** | `R2` | VM Agent binaries, private diagnostic artifacts, session snapshots, TTS audio cache | -| **Workers AI** | `AI` | Idea title generation, transcription, TTS, context summarization | +| Service | Binding | Purpose | +| -------------- | ------- | ------------------------------------------------------------------------------------------------------------ | +| **KV** | `KV` | Auth sessions, bootstrap tokens, boot logs, MCP tokens | +| **R2** | `R2` | VM Agent binaries, private diagnostic artifacts, session snapshots, compose image artifacts, TTS audio cache | +| **Workers AI** | `AI` | Idea title generation, transcription, TTS, context summarization | ### VM diagnostic incident flow @@ -173,6 +173,20 @@ VM Agent errors and their automatic evidence remain inside one SAM installation. Superadmin error queries batch-join incident summaries without exposing object keys. The UI downloads bytes only through an authenticated Worker proxy, while the diagnosis agent can read only the redacted D1 preview. There is no cross-installation intake or transport in this flow. +### Compose image artifact retention + +Compose-publish releases may store docker-save archives in R2 under +`compose-image-artifacts/`. Those objects are durable while any surviving +`deployment_releases.manifest` references them. The scheduled release-retention path +(`apps/api/src/scheduled/d1-retention.ts:runDeploymentReleaseRetention()`) reconciles +only provably stale `created`/`applying` compose releases to terminal `failed` using +D1-observed deployment-node state and recent release-event activity as the lease. It +does not call the deployment node or inspect R2. Terminal release retention then prunes +old releases outside the observed-applied/newest rollback window, and +`apps/api/src/scheduled/compose-image-artifact-cleanup.ts:runComposeImageArtifactCleanup()` +deletes only old compose artifacts that are no longer referenced by any remaining valid +release manifest. + ## Agent Configuration Layers Agent behavior is assembled from several override layers rather than a single global setting: diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index f3a77f0ff6..4f437ccdc5 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -176,21 +176,31 @@ Sleeping and reclaimed Instant and VM sessions are restored from a snapshot of t ### Deployment release and compose artifact retention -The scheduled Worker prunes only terminal deployment releases outside the protected -window (`apps/api/src/scheduled/d1-retention.ts:runDeploymentReleaseRetention()`). It -always retains the newest releases per environment, the version reported in -`deployment_environments.observed_applied_seq`, and every non-terminal release. The -compose artifact cleanup then re-derives references from the remaining manifests +The scheduled Worker first reconciles provably stale non-terminal compose releases, then +prunes terminal deployment releases outside the protected window +(`apps/api/src/scheduled/d1-retention.ts:runDeploymentReleaseRetention()`). Terminal +retention always retains the newest releases per environment and the version reported in +`deployment_environments.observed_applied_seq`. The stale reconciler only marks a +`created`/`applying` compose-artifact release `failed` when D1 shows old release status +activity, stable authenticated deployment-node observed state, no recent release +fetch/apply events, a valid manifest, and a release version that is not the observed +applied version. Unknown statuses, malformed manifests, missing observed state, active +`applying` observations, and recent release events fail closed. Compose artifact cleanup +then re-derives references from the remaining manifests (`apps/api/src/scheduled/compose-image-artifact-cleanup.ts:runComposeImageArtifactCleanup()`). -| Variable | Default | Description | -| ---------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `DEPLOYMENT_RELEASE_RETENTION_ENABLED` | `true` | Enables bounded terminal release pruning. | -| `DEPLOYMENT_RELEASE_RETENTION_COUNT` | `3` | Newest releases protected per environment, in addition to observed-applied and non-terminal releases. | -| `DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE` | `250` | Maximum release rows deleted per run. | -| `DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS` | `24` | Minimum interval between release retention runs. | -| `DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY` | `cleanup:deployment-releases:last-run` | KV interval marker. | -| `COMPOSE_IMAGE_ARTIFACT_CLEANUP_BATCH_SIZE` | `250` | Maximum abandoned compose archives deleted per daily run. | +| Variable | Default | Description | +| -------------------------------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `DEPLOYMENT_RELEASE_RETENTION_ENABLED` | `true` | Enables bounded terminal release pruning. | +| `DEPLOYMENT_RELEASE_RETENTION_COUNT` | `3` | Newest releases protected per environment, in addition to observed-applied and non-terminal releases. | +| `DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE` | `250` | Maximum release rows deleted per run. | +| `DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS` | `24` | Minimum interval between release retention runs. | +| `DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY` | `cleanup:deployment-releases:last-run` | KV interval marker. | +| `DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED` | `true` | Enables stale non-terminal compose release reconciliation before terminal retention. | +| `DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE` | `50` | Maximum stale non-terminal releases marked failed per retention run. | +| `DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS` | `168` | Minimum release status age before reconciliation can terminalize a stale non-terminal release. | +| `DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS` | `6` | Recent release-event window that protects active fetch/apply work from reconciliation. | +| `COMPOSE_IMAGE_ARTIFACT_CLEANUP_BATCH_SIZE` | `250` | Maximum abandoned compose archives deleted per daily run. | ### R2 object lifecycle retention diff --git a/packages/acp-client/package.json b/packages/acp-client/package.json index f52f3b2fae..cc874617d6 100644 --- a/packages/acp-client/package.json +++ b/packages/acp-client/package.json @@ -26,7 +26,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "0.25.0", - "dompurify": "3.4.11", + "dompurify": "3.4.13", "lucide-react": "catalog:", "mermaid": "11.14.0", "prism-react-renderer": "catalog:", diff --git a/packages/cli/internal/cli/workspace.go b/packages/cli/internal/cli/workspace.go index 4f6dd9ea61..cff4c428bb 100644 --- a/packages/cli/internal/cli/workspace.go +++ b/packages/cli/internal/cli/workspace.go @@ -289,17 +289,13 @@ func acceptConnections(ctx context.Context, runtime Runtime, client APIClient, c proxy := &httputil.ReverseProxy{ Director: func(req *http.Request) { + token, _ := req.Context().Value(localForwardTokenContextKey{}).(string) req.URL.Scheme = target.Scheme req.URL.Host = target.Host setEscapedURLPath(req.URL, singleJoiningSlash(target.EscapedPath(), req.URL.EscapedPath())) req.Host = target.Host stripProxyRequestHeaders(req.Header) - token, tokenErr := tc.getToken(req.Context()) - if tokenErr != nil { - fmt.Fprintf(runtime.Stderr, " [%s] token error: %v\n", time.Now().Format("15:04:05"), tokenErr) - return - } req.Header.Set("X-SAM-Forward-Token", token) req.Header.Set("X-SAM-Local-Authority", tc.localAuthority) }, @@ -322,7 +318,14 @@ func acceptConnections(ctx context.Context, runtime Runtime, client APIClient, c } fmt.Fprintf(runtime.Stderr, " [%s] %s %s -> localhost:%d\n", time.Now().Format("15:04:05"), r.Method, r.URL.Path, cfg.remotePort) - proxy.ServeHTTP(w, r) + token, tokenErr := tc.getToken(r.Context()) + if tokenErr != nil { + fmt.Fprintf(runtime.Stderr, " [%s] token unavailable for %s %s\n", + time.Now().Format("15:04:05"), r.Method, r.URL.Path) + http.Error(w, "local forward token unavailable", http.StatusBadGateway) + return + } + proxy.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), localForwardTokenContextKey{}, token))) }), ReadTimeout: 30 * time.Second, WriteTimeout: 120 * time.Second, @@ -339,6 +342,8 @@ func acceptConnections(ctx context.Context, runtime Runtime, client APIClient, c _ = server.Serve(cfg.listener) } +type localForwardTokenContextKey struct{} + // tokenCache manages port access token refresh. type tokenCache struct { client APIClient diff --git a/packages/cli/internal/cli/workspace_test.go b/packages/cli/internal/cli/workspace_test.go index 8ace469115..d0c7e478e8 100644 --- a/packages/cli/internal/cli/workspace_test.go +++ b/packages/cli/internal/cli/workspace_test.go @@ -3,12 +3,15 @@ package cli import ( "context" "encoding/json" + "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "strings" + "sync" + "sync/atomic" "testing" "time" ) @@ -756,6 +759,144 @@ func TestAcceptConnectionsProxiesWithToken(t *testing.T) { assertTokenForwardedRequest(t, receiveRemoteRequest(t, remoteRequests)) } +func TestAcceptConnectionsFailsClosedWhenTokenAcquisitionFails(t *testing.T) { + const secret = "secret-forward-token-value" + var tokenCalls atomic.Int32 + doer := roundTripFunc(func(req *http.Request) (*http.Response, error) { + tokenCalls.Add(1) + return nil, fmt.Errorf("token service unavailable: %s", secret) + }) + + var upstreamCalls atomic.Int32 + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.WriteHeader(http.StatusTeapot) + })) + defer remote.Close() + client := NewAPIClient(CLIConfig{APIURL: remote.URL, SessionCookie: "test"}, doer) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var stderr strings.Builder + go acceptConnections(ctx, Runtime{Stderr: &stderr}, client, acceptConnectionsConfig{ + workspaceID: "ws-test", + remotePort: 3000, + localHost: "127.0.0.1", + localPort: port, + listener: ln, + remoteURL: remote.URL + "/api/workspaces/ws-test/local-forward/3000", + }) + time.Sleep(50 * time.Millisecond) + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/test-path", port), nil) + if err != nil { + t.Fatalf("failed to build request: %v", err) + } + req.Header.Set("X-SAM-Forward-Token", "spoofed-browser-token") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to connect to proxy: %v", err) + } + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("status = %d, want %d; body=%q", resp.StatusCode, http.StatusBadGateway, string(body)) + } + if !strings.Contains(string(body), "local forward token unavailable") { + t.Fatalf("response body should explain local token failure generically, got %q", string(body)) + } + if strings.Contains(string(body), secret) || strings.Contains(string(body), "spoofed-browser-token") { + t.Fatalf("response body leaked secret material: %q", string(body)) + } + if strings.Contains(stderr.String(), secret) || strings.Contains(stderr.String(), "spoofed-browser-token") { + t.Fatalf("stderr leaked secret material: %q", stderr.String()) + } + if upstreamCalls.Load() != 0 { + t.Fatalf("upstream was contacted %d time(s) despite token acquisition failure", upstreamCalls.Load()) + } + if tokenCalls.Load() != 1 { + t.Fatalf("token endpoint calls = %d, want 1", tokenCalls.Load()) + } +} + +func TestAcceptConnectionsConcurrentTokenFailuresNeverContactUpstream(t *testing.T) { + var tokenCalls atomic.Int32 + doer := roundTripFunc(func(req *http.Request) (*http.Response, error) { + tokenCalls.Add(1) + return nil, errors.New("token service unavailable") + }) + + var upstreamCalls atomic.Int32 + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.WriteHeader(http.StatusOK) + })) + defer remote.Close() + client := NewAPIClient(CLIConfig{APIURL: remote.URL, SessionCookie: "test"}, doer) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go acceptConnections(ctx, Runtime{Stderr: io.Discard}, client, acceptConnectionsConfig{ + workspaceID: "ws-test", + remotePort: 3000, + localHost: "127.0.0.1", + localPort: port, + listener: ln, + remoteURL: remote.URL + "/api/workspaces/ws-test/local-forward/3000", + }) + time.Sleep(50 * time.Millisecond) + + const requests = 12 + var wg sync.WaitGroup + errs := make(chan error, requests) + for i := 0; i < requests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/concurrent", port)) + if err != nil { + errs <- err + return + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusBadGateway { + errs <- fmt.Errorf("status = %d, want %d", resp.StatusCode, http.StatusBadGateway) + } + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if upstreamCalls.Load() != 0 { + t.Fatalf("upstream was contacted %d time(s) despite token acquisition failures", upstreamCalls.Load()) + } + if tokenCalls.Load() != requests { + t.Fatalf("token endpoint calls = %d, want %d", tokenCalls.Load(), requests) + } +} + func TestAcceptConnectionsPreservesEscapedPathSegments(t *testing.T) { doer := roundTripFunc(func(req *http.Request) (*http.Response, error) { return jsonResponse(`{"token":"test-forward-token","expiresAt":"2026-06-20T00:00:00Z","remotePort":3000,"mode":"http","localAuthority":"127.0.0.1:3000"}`, http.StatusOK), nil diff --git a/packages/shared/src/vm-agent-contract.ts b/packages/shared/src/vm-agent-contract.ts index e1b8a91fe4..e7a488e5f2 100644 --- a/packages/shared/src/vm-agent-contract.ts +++ b/packages/shared/src/vm-agent-contract.ts @@ -272,6 +272,9 @@ export const DEFAULT_CALLBACK_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; /** Default node management token expiry in milliseconds (1 hour) */ export const DEFAULT_NODE_MANAGEMENT_TOKEN_EXPIRY_MS = 60 * 60 * 1000; +/** Default browser/workspace terminal token expiry in milliseconds (1 hour) */ +export const DEFAULT_TERMINAL_TOKEN_EXPIRY_MS = 60 * 60 * 1000; + /** JWT algorithm used for all tokens */ export const JWT_ALGORITHM = 'RS256' as const; diff --git a/packages/vm-agent/go.mod b/packages/vm-agent/go.mod index 640bdc40a3..46181ee8ba 100644 --- a/packages/vm-agent/go.mod +++ b/packages/vm-agent/go.mod @@ -11,7 +11,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/pelletier/go-toml/v2 v2.2.4 + github.com/pelletier/go-toml/v2 v2.4.3 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.55.0 ) diff --git a/packages/vm-agent/go.sum b/packages/vm-agent/go.sum index 7b05874728..9a221e9708 100644 --- a/packages/vm-agent/go.sum +++ b/packages/vm-agent/go.sum @@ -22,8 +22,8 @@ github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLG github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= +github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76c754f88a..f5036dd878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,11 +34,11 @@ catalogs: specifier: 19.2.3 version: 19.2.3 '@typescript-eslint/eslint-plugin': - specifier: 8.65.0 - version: 8.65.0 + specifier: 8.67.0 + version: 8.67.0 '@typescript-eslint/parser': - specifier: 8.65.0 - version: 8.65.0 + specifier: 8.67.0 + version: 8.67.0 '@vitejs/plugin-react': specifier: 5.2.0 version: 5.2.0 @@ -109,8 +109,8 @@ catalogs: specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: 8.65.0 - version: 8.65.0 + specifier: 8.67.0 + version: 8.67.0 valibot: specifier: 1.3.1 version: 1.3.1 @@ -155,10 +155,10 @@ importers: version: 22.19.7 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) eslint: specifier: 'catalog:' version: 9.39.5(jiti@2.6.1) @@ -200,7 +200,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) valibot: specifier: 'catalog:' version: 1.3.1(typescript@5.9.3) @@ -297,10 +297,10 @@ importers: version: 8.0.0 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -389,8 +389,8 @@ importers: specifier: 0.8.5 version: 0.8.5 dompurify: - specifier: 3.4.11 - version: 3.4.11 + specifier: 3.4.13 + version: 3.4.13 lucide-react: specifier: 'catalog:' version: 0.460.0(react@19.2.7) @@ -419,8 +419,8 @@ importers: specifier: 'catalog:' version: 4.18.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) recharts: - specifier: 3.10.0 - version: 3.10.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) + specifier: 3.10.1 + version: 3.10.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1) remark-gfm: specifier: 'catalog:' version: 4.0.1 @@ -463,10 +463,10 @@ importers: version: 3.0.6 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-react': specifier: 'catalog:' version: 5.2.0(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -572,8 +572,8 @@ importers: specifier: 0.25.0 version: 0.25.0(zod@4.4.3) dompurify: - specifier: 3.4.11 - version: 3.4.11 + specifier: 3.4.13 + version: 3.4.13 lucide-react: specifier: 'catalog:' version: 0.460.0(react@19.2.7) @@ -604,10 +604,10 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -652,7 +652,7 @@ importers: devDependencies: '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -677,10 +677,10 @@ importers: devDependencies: '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -711,10 +711,10 @@ importers: version: 5.6.0(tinybench@2.9.0)(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0))(vitest@4.1.5) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -751,10 +751,10 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': specifier: 'catalog:' - version: 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + version: 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.7(vitest@4.1.5) @@ -4385,23 +4385,23 @@ packages: '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} - '@typescript-eslint/eslint-plugin@8.65.0': - resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} + '@typescript-eslint/eslint-plugin@8.67.0': + resolution: {integrity: sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.65.0 + '@typescript-eslint/parser': ^8.67.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.65.0': - resolution: {integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==} + '@typescript-eslint/parser@8.67.0': + resolution: {integrity: sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.65.0': - resolution: {integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==} + '@typescript-eslint/project-service@8.67.0': + resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -4410,20 +4410,18 @@ packages: resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.66.0': - resolution: {integrity: sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==} + '@typescript-eslint/tsconfig-utils@8.67.0': + resolution: {integrity: sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.65.0': - resolution: {integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==} + '@typescript-eslint/type-utils@8.67.0': + resolution: {integrity: sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4437,14 +4435,18 @@ packages: resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} + '@typescript-eslint/types@8.67.0': + resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.67.0': + resolution: {integrity: sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.65.0': - resolution: {integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==} + '@typescript-eslint/utils@8.67.0': + resolution: {integrity: sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -4454,6 +4456,10 @@ packages: resolution: {integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.67.0': + resolution: {integrity: sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -5672,8 +5678,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.11: - resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -6072,6 +6078,10 @@ packages: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + eventsource@3.0.7: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} @@ -7534,8 +7544,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -8201,8 +8211,8 @@ packages: resolution: {integrity: sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==} engines: {node: '>= 4'} - recharts@3.10.0: - resolution: {integrity: sha512-wulMvfncpIlmu2uFtRU/mE5/+NiVtASXkw2KdwJTdHs3WsASX0WxZlX+rpKgyn5BDbIhkPtCpUKkB9XNK5KE0w==} + recharts@3.10.1: + resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==} engines: {node: '>=18'} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -9000,8 +9010,8 @@ packages: typescript-auto-import-cache@0.3.6: resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} - typescript-eslint@8.65.0: - resolution: {integrity: sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==} + typescript-eslint@8.67.0: + resolution: {integrity: sha512-S2udFs8tCKEKffuJ4TB1idGUZiXdCPGi3IPBGWXarbLQ5UPXORV8QEVzJ4gCRduURMb5EkpNCdjbk0eDIuI8Yg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -9759,7 +9769,7 @@ snapshots: '@ai-sdk/provider-utils@2.2.8(zod@4.3.6)': dependencies: '@ai-sdk/provider': 1.1.3 - nanoid: 3.3.17 + nanoid: 3.3.18 secure-json-parse: 2.7.0 zod: 4.3.6 @@ -9767,14 +9777,14 @@ snapshots: dependencies: '@ai-sdk/provider': 2.0.1 '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 zod: 4.3.6 '@ai-sdk/provider-utils@4.0.0(zod@4.3.6)': dependencies: '@ai-sdk/provider': 3.0.0 '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 zod: 4.3.6 '@ai-sdk/provider-utils@5.0.2(zod@4.3.6)': @@ -13034,14 +13044,14 @@ snapshots: '@types/use-sync-external-store@0.0.6': {} - '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/type-utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/type-utils': 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 eslint: 9.39.5(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -13050,22 +13060,22 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3 eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) - '@typescript-eslint/types': 8.66.0 + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: @@ -13076,19 +13086,20 @@ snapshots: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': + '@typescript-eslint/scope-manager@8.67.0': dependencies: - typescript: 5.9.3 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 - '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.67.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.5(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -13100,12 +13111,14 @@ snapshots: '@typescript-eslint/types@8.66.0': {} - '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': + '@typescript-eslint/types@8.67.0': {} + + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.65.0(typescript@5.9.3) - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 debug: 4.4.3 minimatch: 10.2.5 semver: 7.8.5 @@ -13115,12 +13128,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/utils@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.67.0 + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -13131,6 +13144,11 @@ snapshots: '@typescript-eslint/types': 8.65.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@upsetjs/venn.js@2.0.0': @@ -13179,7 +13197,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.1.3(@types/node@25.9.1)(esbuild@0.19.12)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.1.3(@types/node@25.9.1)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/expect@3.2.4': dependencies: @@ -13554,7 +13572,7 @@ snapshots: dependencies: '@astrojs/compiler': 3.0.1 '@typescript-eslint/scope-manager': 8.65.0 - '@typescript-eslint/types': 8.65.0 + '@typescript-eslint/types': 8.66.0 astrojs-compiler-sync: 1.1.1(@astrojs/compiler@3.0.1) debug: 4.4.3 entities: 7.0.1 @@ -14586,7 +14604,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.11: + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -15113,9 +15131,11 @@ snapshots: eventsource-parser@3.1.0: {} + eventsource-parser@3.1.1: {} + eventsource@3.0.7: dependencies: - eventsource-parser: 3.1.0 + eventsource-parser: 3.1.1 execa@5.1.1: dependencies: @@ -16597,7 +16617,7 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.20 - dompurify: 3.4.11 + dompurify: 3.4.13 katex: 0.16.45 khroma: 2.1.0 lodash-es: 4.18.1 @@ -17021,7 +17041,7 @@ snapshots: nanoid@3.3.15: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} nanostores@1.3.0: {} @@ -17537,7 +17557,7 @@ snapshots: postcss@8.5.6: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -17783,7 +17803,7 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 - recharts@3.10.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): + recharts@3.10.1(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@17.0.2)(react@19.2.7)(redux@5.0.1): dependencies: '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.17)(react@19.2.7)(redux@5.0.1))(react@19.2.7) clsx: 2.1.1 @@ -18903,12 +18923,12 @@ snapshots: dependencies: semver: 7.8.5 - typescript-eslint@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3): + typescript-eslint@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.65.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.67.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.5(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7cf89d6983..94ccc06dc3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -23,9 +23,11 @@ catalog: '@vitejs/plugin-react': 5.2.0 # === TypeScript ESLint === - '@typescript-eslint/eslint-plugin': 8.65.0 - '@typescript-eslint/parser': 8.65.0 - typescript-eslint: 8.65.0 + # Released in lockstep from one monorepo — keep aligned so the parser and plugin + # always agree on the AST/rule surface. Dependabot only proposes the parser. + '@typescript-eslint/eslint-plugin': 8.67.0 + '@typescript-eslint/parser': 8.67.0 + typescript-eslint: 8.67.0 # === Cloudflare === '@cloudflare/workers-types': 5.20260707.1 diff --git a/scripts/deploy/sync-wrangler-config.ts b/scripts/deploy/sync-wrangler-config.ts index ae3be0b1ee..a388198d86 100644 --- a/scripts/deploy/sync-wrangler-config.ts +++ b/scripts/deploy/sync-wrangler-config.ts @@ -504,6 +504,10 @@ function getApiWorkerVars( 'DEPLOYMENT_RELEASE_RETENTION_BATCH_SIZE', 'DEPLOYMENT_RELEASE_RETENTION_INTERVAL_HOURS', 'DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY', + 'DEPLOYMENT_RELEASE_RECONCILIATION_ENABLED', + 'DEPLOYMENT_RELEASE_RECONCILIATION_BATCH_SIZE', + 'DEPLOYMENT_RELEASE_RECONCILIATION_STALE_HOURS', + 'DEPLOYMENT_RELEASE_RECONCILIATION_ACTIVITY_GRACE_HOURS', 'SESSION_SNAPSHOT_PURGE_ENABLED', 'SESSION_SNAPSHOT_PURGE_BATCH_SIZE', 'SESSION_SNAPSHOT_RECOVERY_MAX_ATTEMPTS', diff --git a/scripts/quality/check-migration-ordering.ts b/scripts/quality/check-migration-ordering.ts index f8e36c8eec..84d8169da0 100644 --- a/scripts/quality/check-migration-ordering.ts +++ b/scripts/quality/check-migration-ordering.ts @@ -51,6 +51,19 @@ const LEGACY_ALLOWED_DUPLICATE_FILES = new Map>> new Set(['0105_bootstrap_token_consumes.sql', '0105_debug_diagnosis_canonical_status.sql']), ], ['0106', new Set(['0106_diagnostic_incidents.sql', '0106_node_agent_version.sql'])], + // Same situation: 0112_deployment_release_status_updated_at.sql was applied to + // staging on 2026-08-16 (d1_migrations id 134) from PR #1837's branch, before + // 0112_session_snapshot_direct_upload_authorization.sql landed on main under the + // same prefix. Staging's ledger records the exact applied filename, so renumbering + // it would replay ALTER TABLE deployment_releases ADD COLUMN status_updated_at + // against a table that already has the column and abort the migration step. + [ + '0112', + new Set([ + '0112_session_snapshot_direct_upload_authorization.sql', + '0112_deployment_release_status_updated_at.sql', + ]), + ], ]), ], ]); diff --git a/tasks/archive/2026-08-05-cli-local-forwarding-fail-closed.md b/tasks/archive/2026-08-05-cli-local-forwarding-fail-closed.md new file mode 100644 index 0000000000..3d621acf74 --- /dev/null +++ b/tasks/archive/2026-08-05-cli-local-forwarding-fail-closed.md @@ -0,0 +1,92 @@ +# Make CLI local forwarding fail closed + +## Problem + +`sam workspace forward` uses `httputil.ReverseProxy.Director` to acquire and attach the SAM local-forward token. `Director` cannot abort proxying. If token acquisition fails after the request URL has been rewritten, the proxy can still contact the upstream local-forward endpoint without `X-SAM-Forward-Token`. + +This must fail closed: when the CLI cannot acquire a forward token, it should return a local redacted `502`/`503` and must not contact upstream. + +## Scope + +- Only change `packages/cli`. +- Preserve successful existing behavior, flags, URLs, headers, defaults, public API shape, and data formats. +- Keep the PR tightly targeted. +- Open a PR and do not merge. + +## Research Findings + +- CLI local forwarding is implemented in `packages/cli/internal/cli/workspace.go`. +- `acceptConnections` creates a `tokenCache` and a `httputil.ReverseProxy`. +- The current `Director` rewrites `req.URL`, strips spoofable proxy/SAM headers, then calls `tc.getToken(req.Context())`. +- On token acquisition failure, `Director` writes a redacted log line and returns, but `ReverseProxy` continues with the already-mutated request. +- Existing tests in `packages/cli/internal/cli/workspace_test.go` cover successful local forwarding, app `Authorization`/`Cookie` preservation, multiple `Set-Cookie` preservation, escaped path segments, host validation, header stripping, token cache refresh, and shutdown. +- Prior local forwarding task: `tasks/archive/2026-06-17-localhost-preserving-cli-forwarding.md`. +- CLI quality requirements are in `.claude/rules/36-cli-quality.md`: command-boundary and scenario tests, injectable boundaries, redaction, race/coverage evidence. + +## Implementation Checklist + +- [x] Move token acquisition out of `ReverseProxy.Director` and into the local handler before proxying. +- [x] Ensure the handler returns a local redacted `502`/`503` on token acquisition failure. +- [x] Ensure `Director` only rewrites and attaches a token already acquired by the handler. +- [x] Add a regression test proving upstream is never contacted when token acquisition fails. +- [x] Add/constrain a race/scenario test proving concurrent token acquisition failures do not leak requests upstream. +- [x] Preserve and rerun existing success-path tests for URLs, escaped paths, headers, app auth/cookies, and `Set-Cookie`. +- [x] Run `go test -race -coverprofile=coverage.out -covermode=atomic ./...` in `packages/cli`. +- [x] Review `go tool cover -func=coverage.out` for touched production file coverage. +- [x] Record local `test-engineer`, `go-specialist`, `security-auditor`, and task-completion review outcomes. + +## Acceptance Criteria + +- A forward-token acquisition error returns a local redacted `502`/`503`. +- No upstream request is made when token acquisition fails. +- Token strings, session cookies, and internal auth details are not written to local HTTP responses. +- Existing successful local forwarding behavior remains unchanged. +- Tests are scenario-driven and include race-capable coverage for the safety path. + +## Bug-Fix Post-Mortem + +### What broke + +CLI local forwarding could contact the upstream local-forward endpoint without `X-SAM-Forward-Token` when token acquisition failed inside `ReverseProxy.Director`. + +### Root cause + +`httputil.ReverseProxy.Director` is not an abort hook. The CLI used it both to rewrite the outbound request and to acquire credentials. Returning from `Director` after an error did not stop `ReverseProxy` from sending the partially prepared request. + +### Process fix + +Security-sensitive proxy credentials must be acquired and validated before entering a proxy component whose request lifecycle cannot be aborted by the credential hook. Regression tests must prove the protected upstream is not contacted on credential acquisition failure. + +## References + +- `packages/cli/internal/cli/workspace.go` +- `packages/cli/internal/cli/workspace_test.go` +- `.claude/rules/36-cli-quality.md` +- `tasks/archive/2026-06-17-localhost-preserving-cli-forwarding.md` + + +## Validation Evidence + +- `go test ./internal/cli -run 'TestAcceptConnections(ProxiesWithToken|FailsClosedWhenTokenAcquisitionFails|ConcurrentTokenFailuresNeverContactUpstream|PreservesEscapedPathSegments)'` passed. +- `go test -race -coverprofile=coverage.out -covermode=atomic ./...` passed in `packages/cli`. +- `go tool cover -func=coverage.out` reviewed: `packages/cli/internal/cli/workspace.go` `acceptConnections` 77.8%, `getToken` 100%, package total 81.4%. +- `pnpm lint` passed from repository root with existing warnings. +- `pnpm typecheck` passed from repository root. +- `pnpm build` passed from repository root. +- `pnpm test` from repository root failed in unrelated API timeout tests under full-suite load; both failed tests passed on focused rerun: + - `pnpm vitest run tests/unit/vm-agent-cross-boundary-contract.test.ts -t 'sendPromptToAgentOnNode sends'` in `apps/api`. + - `pnpm vitest run tests/unit/routes/mcp-orchestration-tools.test.ts -t 'should reject missing taskId'` in `apps/api`. + +## Local Review Evidence + +| Reviewer | Status | Outcome | +| --- | --- | --- | +| test-engineer | PASS (local checklist) | New tests are scenario-level: token failure response/redaction/no-upstream-contact and concurrent failure no-upstream-contact under race suite. | +| go-specialist | PASS (local checklist) | Token acquisition now occurs before `ReverseProxy.ServeHTTP`; `Director` no longer contains fallible token acquisition. Existing success path URL/header behavior remains covered. | +| security-auditor | ADDRESSED | Initial local review found raw token acquisition errors reached stderr. Fixed by logging generic failure only and asserting stderr/body do not leak canary secret or spoofed token. | +| task-completion-validator | PASS (local checklist) | Research findings map to checklist and diff; acceptance criteria have tests or validation evidence; no UI/backend or multi-resource scope. | +| delegated subagents | FAILED TOOLING | Four local subagents returned without inspecting files due sandbox `bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted`; not counted as review passes. | + +## Staging Evidence + +Not deployed to staging. This change is limited to `packages/cli`, which is not a staging Worker/web runtime surface; behavior is covered by local CLI HTTP proxy tests and the Go race/coverage suite. diff --git a/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md new file mode 100644 index 0000000000..adbf4732da --- /dev/null +++ b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md @@ -0,0 +1,174 @@ +# Reconcile stale compose deployment releases + +## Problem + +R2 compose image archives under `compose-image-artifacts/` remain protected as long as any +persisted `deployment_releases.manifest` references them. The existing scheduled pipeline +correctly deletes only old unreferenced archives: release retention runs first, then +`runComposeImageArtifactCleanup()` recomputes references and deletes old unreferenced objects. + +Production investigation on 2026-08-16 found stale `deployment_releases.status = 'applying'` +rows from 2026-06-26/27 that still reference 9 compose archives / 5.903 GB. Current release +retention intentionally fails closed for every non-terminal status, so those rows are never +pruned and their R2 archives never become unreferenced. + +This task must add a bounded, configurable reconciliation step that transitions only provably +stale non-terminal compose releases to a terminal state. Active deploys, observed-applied +releases, newest rollback releases, and ambiguous/future statuses must remain protected. + +## Research findings + +- `apps/api/src/scheduled/d1-retention.ts:runDeploymentReleaseRetention()` deletes only + terminal `applied`/`failed` release rows outside the newest-N window and not matching + `deployment_environments.observed_applied_seq`. +- `apps/api/src/scheduled/compose-image-artifact-cleanup.ts:runComposeImageArtifactCleanup()` + scans surviving release manifests for `compose-image-artifacts/` references and fails closed + on malformed relevant manifests. +- `apps/api/src/routes/deploy-release-callback.ts` marks a release `applying` when a deployment + node fetches a signed apply payload. The row has no status timestamp today, so status age is + not independently tracked. +- `apps/api/src/routes/node-lifecycle.ts` receives authenticated deployment-node heartbeats, + persists `observed_applied_seq`, `observed_status`, `observed_at`, and only asks a node to + apply the latest release when the latest row is `created`, or when it is `applying` but the + node is not currently reporting `applying`. +- `apps/api/src/services/deployment-control.ts:reconcileDeploymentReleaseStatuses()` maps + observed runtime status back to release rows: observed `applied` marks the observed seq + `applied`; observed terminal failure marks the failed seq `failed`; observed `applying` + is an active-deploy signal. +- `packages/vm-agent/internal/deploy/engine.go` reports `applying`, then either `applied`, + `failed`, `failed-initial`, or `reverted`. `packages/vm-agent/internal/server/health.go` + also has an apply watchdog and emits release events during fetch/apply progress. +- `deployment_release_events` provide a cheap D1-only activity/lease signal for apply progress. + A stale reconciler can protect any release with recent events without calling the node. +- The retained post-mortem in `tasks/archive/2026-08-07-fix-provisioning-node-cleanup-race.md` + shows cleanup jobs must model the real ownership/state-machine interleaving, not just + downstream idle state. This task needs deterministic D1 tests for fresh, active, stale, + concurrent, and ambiguous release states. +- `.claude/rules/47-control-loop-io-budget.md` requires bounded candidate sets and an escape + path for every selected candidate. The reconciliation must be D1-only, batch-limited, and + idempotent. +- `apps/www/src/content/blog/sams-journal-the-cleanup-job-asked-d1-first.md` documents the + core safety rule: R2 artifact cleanup must treat D1 release references as the source of truth, + never object age alone. +- The separate degraded sleeping session snapshot purge gap is intentionally out of scope for + this PR unless a tiny shared lifecycle abstraction becomes clearly safer. + +## Implementation checklist + +- [x] Add additive D1 schema/migration support for release status timestamps needed to make + stale-state reconciliation race-safe. +- [x] Update release creation/apply/status-transition paths to maintain status timestamp data + and avoid late apply-fetch overwriting a reconciled terminal status. +- [x] Add configurable stale non-terminal release reconciliation to the scheduled release + retention path, with safe defaults, kill switch, batch bound, observed-state gate, recent + event lease, compose-artifact scope, and fail-closed handling for unknown statuses. +- [x] Preserve observed-applied release and newest rollback protection by keeping terminalized + stale rows subject to the existing terminal release-retention query. +- [x] Ensure the scheduled ordering is reconciliation → terminal release retention → compose + artifact cleanup so a single scheduled run can make stale old releases unreferenced before + R2 cleanup. +- [x] Add deterministic tests for fresh applying protection, stale reconciliation, observed + applied protection, cleanup ordering, batching/concurrency/idempotency, disabled/configured + behavior, and malformed/future statuses. +- [x] Update `Env`, `.env.example`, generated deployment variable allowlists, env reference, and + public configuration/architecture docs for the new knobs and stale definition. +- [x] Capture the degraded sleeping snapshot purge gap as a SAM Idea unless addressed in this PR + by a clearly shared lifecycle abstraction. +- [x] Run focused tests while implementing, then full local validation required by `/do`. +- [x] Run required specialist reviews: Cloudflare, constitution, documentation sync, env + validation, task completion, and test engineering. +- [x] Push the branch, create a PR against `main`, include required preflight/specialist + evidence, monitor CI, fix failures until required checks are green, and leave the PR open + and unmerged. + +## Implementation notes + +- Out-of-scope degraded sleeping session snapshot purge follow-up captured as SAM Idea + `01M05HTJHCWXCG5YZJ6TB3Y2AG`. + +## Local validation evidence + +- `pnpm typecheck` — passed +- `pnpm lint` — passed with pre-existing warnings only +- `pnpm quality:migration-safety` — passed +- `pnpm quality:migration-ordering` — passed +- `pnpm quality:wrangler-bindings` — passed +- `pnpm format:check` — passed +- `pnpm lint:oxlint` — passed, report-only diagnostics +- `pnpm quality:type-boundaries` — passed, blocking counts zero +- `pnpm --filter @simple-agent-manager/api typecheck` — passed after final test coverage + adjustment +- `pnpm --filter @simple-agent-manager/api lint` — passed after final test coverage + adjustment +- `pnpm --filter @simple-agent-manager/api test -- tests/unit/services/deployment-control.test.ts tests/unit/routes/deploy-release-callback.test.ts tests/unit/routes/compose-publish-release-callback.test.ts tests/unit/routes/deployment-release-compose-submission.test.ts tests/unit/routes/deployment-environment-observability.test.ts tests/unit/routes/deployment-environment-lifecycle-vertical.test.ts tests/unit/services/deployment-volumes.test.ts tests/unit/scheduled/d1-retention.test.ts` + — passed, 8 files / 143 tests +- `pnpm --filter @simple-agent-manager/api test` — passed, 547 files / 7,367 tests +- `pnpm test` — passed on rerun, 21 / 21 turbo tasks green; API 547 files / 7,367 tests. + The first root attempt hit two unrelated MCP route `beforeEach` hook timeouts under root-run + concurrency; both timed-out files passed when rerun directly (`24 / 24`) before the full root + rerun passed. +- After PR CI exposed missing deploy workflow env mappings, updated + `.github/workflows/deploy-reusable.yml` and reran: + - `npx tsc --project scripts/deploy/tsconfig.json --noEmit` + - `npx tsx --check scripts/deploy/setup-github.ts` + - `npx tsx --check scripts/deploy/sync-wrangler-config.ts` + - `npx tsx --check scripts/deploy/generate-keys.ts` + - `pnpm quality:scripts:test` + - `pnpm quality:wrangler-bindings` + - `pnpm quality:agent-install-manifest` + - `pnpm exec vitest run --config scripts/quality/vitest.config.ts ci-quality-program.test.ts ci-worker-suite.test.ts deployment-workflow-hardening.test.ts deploy-reusable-workflow.test.ts` + +## Staging verification evidence + +- Staging deploy workflow run `31955562805` passed for branch + `sam/build-pr-safely-reconciles-xx84f3`. +- Deploy job passed, including database migrations and Worker health check. +- Smoke tests passed: 12 Playwright tests. +- Read-only staging D1 verification confirmed `deployment_releases.status_updated_at` exists, + `idx_deployment_releases_status_updated_at` exists, and migration + `0112_deployment_release_status_updated_at.sql` is recorded. +- After `main` advanced with `0112_session_snapshot_direct_upload_authorization.sql` and + `0113_session_snapshot_capture_error.sql`, this PR's additive migration was renumbered to + `0112_deployment_release_status_updated_at.sql` with no SQL content change. +- No production data was mutated; production evidence was used only to justify the stale-state + lifecycle gap. + +## PR / CI evidence + +- PR: https://github.com/raphaeltm/simple-agent-manager/pull/1837 +- PR remains open and unmerged. +- PR check rollup is the source of truth for final head status; CI was green after follow-up + implementation fixes before archiving: + - Main PR workflow run `31957889584` passed required checks, including Build, Code Quality + Checks, Durable Object Workers, Lint, Preflight Evidence, Pulumi Infrastructure Tests, + Secret Scan, Specialist Review Evidence, Test, Type Check, UI Compliance, Validate Deploy + Scripts, and Workspace Quality Surfaces. + - VM smoke workflow run `31957889570` passed worker and mock smoke jobs. + - Benchmark workflow run `31957889592` passed. + +## Specialist review evidence + +| Reviewer | Verdict | Evidence | +| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Cloudflare specialist | PASS | Additive D1 migration only (`apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql`); bounded parameterized D1 update with `LIMIT ?` and no production mutation (`apps/api/src/scheduled/d1-retention.ts`); R2 cleanup ordering verified by test; `pnpm quality:migration-safety`, `pnpm quality:migration-ordering`, and `pnpm quality:wrangler-bindings` passed. | +| Constitution validator | PASS | New business limits/time windows are configurable via `DEPLOYMENT_RELEASE_RECONCILIATION_*` env vars with default constants, not bare literals; no new internal URLs or deployment-specific identifiers; status strings are domain state-machine constants. | +| Documentation sync validator | PASS | Updated `apps/api/src/env.ts`, `apps/api/.env.example`, `scripts/deploy/sync-wrangler-config.ts`, `.claude/skills/env-reference/SKILL.md`, `apps/www/src/content/docs/docs/reference/configuration.md`, and `apps/www/src/content/docs/docs/architecture/overview.md`. Optional Worker variables do not require GH/GITHUB secret mapping. | +| Env validator | PASS | New variables are Worker runtime vars with `DEPLOYMENT_RELEASE_RECONCILIATION_*` prefix; no GitHub Actions secret prefix mapping needed; code/docs/defaults agree across Env, `.env.example`, env-reference, public config docs, and generated wrangler allowlist. | +| Test engineer | PASS | Added deterministic D1/R2 tests for fresh `created`/`applying`, active observed `applying`, stale `created`/`applying`, observed-applied protection, ordering into R2 cleanup, batching/idempotency, recent activity lease, disabled/configured behavior, malformed/future/unknown states, and deploy callback CAS conflict. Full API suite passed. | +| Task completion validator | PASS | Research findings map to checked checklist items; all checked items have committed diff coverage; acceptance criteria have unit/vertical slice coverage or CI/PR-gate evidence; no UI/backend propagation or multi-resource discriminator gap in this backend scheduled-cleanup PR. | + +## Acceptance criteria + +- Fresh `created`/`applying` compose releases remain protected. +- Releases actively reported as `applying`, or with recent apply/fetch events, remain protected. +- A stale non-terminal compose release with old status activity, stable authoritative observed + node state, no recent release activity, and not matching `observed_applied_seq` transitions to + terminal `failed`. +- Unknown/future/malformed release statuses and ambiguous observed environment state are not + modified. +- Existing terminal release retention still protects observed-applied and newest-N releases, and + only deletes terminal releases outside that window. +- R2 compose artifact cleanup continues to fail closed on malformed relevant manifests and only + deletes old unreferenced objects. +- The reconciler is D1-only, batch-bounded, configurable, disabled by kill switch, and idempotent. +- CI required checks are green on the PR; the PR remains open/unmerged. diff --git a/tasks/archive/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/archive/2026-08-16-terminal-jwt-logout-revocation.md new file mode 100644 index 0000000000..5a767973f4 --- /dev/null +++ b/tasks/archive/2026-08-16-terminal-jwt-logout-revocation.md @@ -0,0 +1,113 @@ +# Terminal JWT Logout Revocation + +## Problem + +Workspace terminal JWTs minted by `POST /api/terminal/token` remain usable for new workspace WebSocket connections after the minting browser session logs out. PR #1813 removed client-side persistence and teardown gaps, but the server-side workspace proxy still accepts any unexpired `workspace-terminal` JWT whose subject owns the workspace. + +Live staging reproduction on 2026-08-16: + +- Workspace `01M05DPW6YDCBTJ9EHVXDFXGTZ` reached `running`. +- Before logout, `POST /api/terminal/token` returned 200 and `wss://ws-01m05dpw6ydcbtj9ehvxdfxgtz.sammy.party/terminal/ws/multi?token=` returned `session_created`. +- Logout with browser-origin headers returned 200. +- After logout, `/api/auth/me` returned 401 and a fresh `POST /api/terminal/token` returned 401. +- The captured pre-logout token still opened a new terminal WebSocket and returned `session_created`. + +## Research Findings + +- `apps/api/src/routes/terminal.ts` mints terminal tokens after `requireAuth()` and `requireApproved()`, checks workspace ownership/status, then calls `signTerminalToken(userId, workspaceId, env)`. +- `apps/api/src/services/jwt.ts` signs `workspace-terminal` JWTs with `sub=userId`, `workspace=workspaceId`, and env-configurable `TERMINAL_TOKEN_EXPIRY_MS`. The fallback expiry is currently inline and should be moved behind a `DEFAULT_*` constant while this code is touched. +- `apps/api/src/index.ts` handles `ws-*` workspace subdomain proxying. It accepts a valid terminal token when no app session cookie is present, checks only workspace claim, subject, and D1 workspace ownership, then forwards the request to the VM agent. +- The BetterAuth `sessions` table exists in `apps/api/src/db/schema.ts` with `id`, `token`, `expiresAt`, and `userId`. Logout removes or invalidates the current browser session row; checking this row on terminal-token use binds token liveness to logout without adding KV revocation state. Live staging showed token-login sessions expose `session.token` reliably, so browser terminal JWTs bind to the BetterAuth session token and the liveness gate queries `sessions.token`. +- `users.status` is already an unconditional access-denial boundary for normal authenticated routes through `assertUserNotSuspended()`. Terminal-token-only workspace proxy traffic bypasses that browser-session middleware and must enforce the same suspension check when validating captured tokens. +- Existing internal `port-proxy` tokens are minted with `sub='port-proxy'` by the Worker for VM-agent port proxy calls and are already rejected as browser workspace-proxy credentials. They must remain compatible with old VM agents and should not require a browser session claim for Worker-to-VM internal use. +- Relevant retained lessons: + - `tasks/archive/2026-05-08-conversation-agent-offline.md`: token-only workspace proxy auth is required because workspace subdomain traffic does not carry `api.*` cookies. + - `tasks/archive/2026-05-08-port-access-tokens.md`: exposed port access relies on a distinct port-token/cookie flow and must not regress. + - `tasks/archive/2026-08-16-account-suspension-unconditional-denial.md`: suspension must be enforced before role/config bypasses and must not rely on cached browser-session state. + +## Implementation Checklist + +- [x] Add a session-binding claim to browser-minted terminal JWTs using the current BetterAuth session token from `getAuth(c)`. +- [x] Keep `signTerminalToken()` backward-compatible for internal Worker-to-VM uses by making session binding optional at signing time, while requiring it only for browser workspace-proxy token authentication. +- [x] Add a workspace-proxy liveness helper that, after JWT verification, fails closed unless: + - [x] the token includes a non-empty session token; + - [x] a BetterAuth session row exists for that session token and token subject; + - [x] the session is not expired; + - [x] the user row exists and is not suspended. +- [x] Apply the liveness helper in `apps/api/src/index.ts` before D1 workspace routing/proxying for token-only workspace subdomain requests. +- [x] Preserve app-session-cookie workspace proxy behavior for active sessions. +- [x] Preserve port-access token/cookie behavior and internal `port-proxy` token generation. +- [x] Move terminal token default expiry fallback to a `DEFAULT_*` constant. +- [x] Add behavioral tests for: + - [x] mint token → logout/session row removed → new workspace-proxy WebSocket upgrade rejected; + - [x] active minting session still allows a new workspace-proxy connection; + - [x] suspended token subject rejected even with an otherwise live session; + - [x] missing session claim and missing/ambiguous DB state fail closed; + - [x] terminal route passes the current auth session token into browser-minted tokens. +- [x] Preserve internal control-plane attachment uploads by routing Worker-to-VM calls through `{nodeId}.vm.*` instead of the browser `ws-*` proxy gate. +- [x] Run focused API tests and broader local validation. +- [x] Complete specialist review, staging deploy, and live staging verification. +- [x] Clean up staging workspace/node `01M05DPW6YDCBTJ9EHVXDFXGTZ` or any replacement verification workspace. + +## Local Validation + +- `pnpm --filter @simple-agent-manager/api test -- tests/unit/vm-agent-cross-boundary-contract.test.ts tests/unit/services/terminal-token-liveness.test.ts tests/unit/workspace-proxy-ownership.test.ts tests/unit/workspace-proxy-port-access.test.ts tests/unit/routes/terminal.test.ts tests/unit/node-agent-contract.test.ts` — passed, 6 files / 129 tests. +- `pnpm --filter @simple-agent-manager/api typecheck` — passed. +- `pnpm --filter @simple-agent-manager/api lint` — passed. +- `git diff --check` — passed. +- Earlier full local run: `pnpm lint && pnpm typecheck && pnpm test && pnpm build` passed lint/typecheck and changed API tests; the final aggregate test command hit unrelated web `project-triggers` timeouts under repository-wide concurrency. Isolated reruns of the timed-out web test and adjacent API route tests passed. +- `pnpm build` — passed. +- `pnpm check:fast` — passed. +- After live staging exposed that BetterAuth token-login provides `session.token` rather than `session.id`, reran: + - `pnpm --filter @simple-agent-manager/api test -- tests/unit/vm-agent-cross-boundary-contract.test.ts tests/unit/services/terminal-token-liveness.test.ts tests/unit/workspace-proxy-ownership.test.ts tests/unit/workspace-proxy-port-access.test.ts tests/unit/routes/terminal.test.ts tests/unit/node-agent-contract.test.ts` — passed, 6 files / 129 tests. + - `pnpm --filter @simple-agent-manager/api typecheck` — passed. + - `pnpm --filter @simple-agent-manager/api lint` — passed. + - `git diff --check` — passed. + - `pnpm check:fast` — passed. + - `pnpm --filter @simple-agent-manager/api build` — passed. + +## Specialist Review + +- `security-auditor` — passed. Browser terminal token minting now embeds the current auth session token, token-only workspace-proxy upgrades verify that session/user row before proxying, missing session state fails closed, and suspended users are denied by the same signup/suspension gate. Session token values are not logged. +- `cloudflare-specialist` — passed. The gate is enforced in the Worker workspace proxy before forwarding to the VM agent; no KV read-modify-write revocation state was added; D1 session/user lookup is read-only and fail-closed. Internal Worker-to-VM attachment uploads use `{nodeId}.vm.*` routing so they do not depend on browser proxy semantics. +- `constitution-validator` — passed. Terminal token TTL fallback uses `DEFAULT_TERMINAL_TOKEN_EXPIRY_MS` and remains overridable by `TERMINAL_TOKEN_EXPIRY_MS`; no new hardcoded TTL/rate-limit/revocation constants were introduced. +- `test-engineer` — passed. Behavioral tests cover active session allowed, logout/session-row removal denied, suspended user denied, missing session claim denied, mismatched session/user denied, proxy not forwarding on denied token, mint route passing session token, and internal VM-agent routing contract. + +## Staging Verification + +- Staging deploy `31954011519` for SHA `8cfe208ba74ce52db00f48f083a0fda27c4f9372` succeeded, but targeted live verification failed: a freshly minted terminal token still lacked a session-binding claim and the captured token still opened a new `wss://ws-.../terminal/ws/multi` connection after logout. This discriminatory failure exposed that the implementation used `auth.session.id`, while the live token-login session path exposes `auth.session.token`. +- Corrected staging deploy `31955598604` for SHA `24854e6fc7227e5e726060fb432813f1a7321371` succeeded, including smoke tests. +- Final live verification on workspace `01M05MWGT3QTFYQ9JWK4P9ZES4` / node `01M05MWGAG3QCETJEFETFX6BTD`: + - Browser token-login session minted terminal token: `POST /api/terminal/token` returned 200; decoded JWT had `aud=workspace-terminal`, matching workspace claim, and `sessionTokenPresent=true`. + - Captured token before logout opened `wss://ws-01m05mwgt3qtfyq9jwk4p9zes4.sammy.party/terminal/ws/multi` and returned `session_created`. + - Logout returned 200; `/api/auth/me` returned 401; fresh `POST /api/terminal/token` returned 401. + - Reusing the captured pre-logout token for a new WebSocket returned browser `error` before open and did not return `session_created`. + - A second browser token-login session minted a fresh token with `sessionTokenPresent=true`; new WebSocket returned `session_created`. + - Cleanup `DELETE /api/workspaces/01M05MWGT3QTFYQ9JWK4P9ZES4` returned 200 and `/api/workspaces` default list returned `[]`. + +## Task Completion Validation + +- `task-completion-validator` — passed. The implementation checklist has no open items; the diff contains the terminal-token session-token claim, Worker proxy liveness gate, fail-closed D1 session/user lookup, suspension denial, internal Worker-to-VM attachment routing compatibility, env-backed default TTL, behavioral tests, staging deploy evidence, final live verification evidence, and cleanup evidence. + +## Acceptance Criteria + +- Previously minted browser terminal tokens are rejected for new workspace WebSocket/proxy connections after the minting auth session logs out. +- A terminal token from a still-live, non-suspended session continues to authorize new workspace WebSocket/proxy connections. +- Suspended users cannot use previously minted terminal tokens. +- Missing session claims, missing session rows, expired sessions, missing user rows, or mismatched session/user state reject without proxying. +- Existing live WebSocket connections are not explicitly terminated server-side by this change; they rely on the existing client logout cleanup from PR #1813 and VM/workspace lifecycle. The new server gate applies to new Worker-mediated upgrades. +- No VM-agent protocol change is required; old VM agents remain compatible because the Worker enforces the new liveness gate before forwarding and internal Worker-to-VM tokens remain valid. +- PR body documents the session-binding design tradeoff and includes local tests, specialist review evidence, staging deployment, live staging verification, CI link, head SHA, and cleanup evidence. +- PR remains open and unmerged. + +## References + +- SAM idea `01M04ZCW9C1NAAN88F0VVYCSVN` +- PR #1813: https://github.com/raphaeltm/simple-agent-manager/pull/1813 +- PR #1834: https://github.com/raphaeltm/simple-agent-manager/pull/1834 +- `.claude/rules/02-quality-gates.md` +- `.claude/rules/06-technical-patterns.md` +- `.claude/rules/11-fail-fast-patterns.md` +- `.claude/rules/28-credential-resolution-fallback-tests.md` +- `.claude/rules/51-server-side-node-class-gates.md` +- `.claude/rules/54-vm-agent-rollout-compatibility.md`