From b2ebcf00a235c0e7cdf614e7c231a60fcdfd271e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Wed, 5 Aug 2026 08:57:11 +0000 Subject: [PATCH 01/30] fix(cli): fail closed on forward token errors --- packages/cli/internal/cli/workspace.go | 17 ++- packages/cli/internal/cli/workspace_test.go | 141 ++++++++++++++++++ ...-08-05-cli-local-forwarding-fail-closed.md | 92 ++++++++++++ 3 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 tasks/archive/2026-08-05-cli-local-forwarding-fail-closed.md 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/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. From 8d5959ea8431bff3662cbcd6e4ca73b0ec37f3d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:58:54 +0000 Subject: [PATCH 02/30] chore(deps): bump CodSpeedHQ/action from 5.0.2 to 5.0.3 Bumps [CodSpeedHQ/action](https://github.com/codspeedhq/action) from 5.0.2 to 5.0.3. - [Release notes](https://github.com/codspeedhq/action/releases) - [Changelog](https://github.com/CodSpeedHQ/action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codspeedhq/action/compare/0ca9cbbf4623b599a6c3ed4fc8a922942705d9f1...4296e51e7041e24dadb86d1d6e8b9320d223dbe8) --- updated-dependencies: - dependency-name: CodSpeedHQ/action dependency-version: 5.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codspeed.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index 94fdaf0dc9..970c53e4c5 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -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 From ab940c1ca09ddf9ce0b05ae3f7a96683f79be67a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:58:59 +0000 Subject: [PATCH 03/30] chore(deps): bump dorny/paths-filter from 4.0.2 to 4.0.3 Bumps [dorny/paths-filter](https://github.com/dorny/paths-filter) from 4.0.2 to 4.0.3. - [Release notes](https://github.com/dorny/paths-filter/releases) - [Changelog](https://github.com/dorny/paths-filter/blob/master/CHANGELOG.md) - [Commits](https://github.com/dorny/paths-filter/compare/7b450fff21473bca461d4b92ce414b9d0420d706...ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d) --- updated-dependencies: - dependency-name: dorny/paths-filter dependency-version: 4.0.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef7026438a..dd6d7c326c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,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: | From 3091c10f7799734b38ac600b2459086bed37d89c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:59:02 +0000 Subject: [PATCH 04/30] chore(deps): bump github.com/pelletier/go-toml/v2 in /packages/vm-agent Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.2.4 to 2.4.3. - [Release notes](https://github.com/pelletier/go-toml/releases) - [Commits](https://github.com/pelletier/go-toml/compare/v2.2.4...v2.4.3) --- updated-dependencies: - dependency-name: github.com/pelletier/go-toml/v2 dependency-version: 2.4.3 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- packages/vm-agent/go.mod | 2 +- packages/vm-agent/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vm-agent/go.mod b/packages/vm-agent/go.mod index 7d88cdf15e..0168c76a04 100644 --- a/packages/vm-agent/go.mod +++ b/packages/vm-agent/go.mod @@ -9,7 +9,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= From 62744a4d32554b7bc2eadc2b253ab4b2f7c27be3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:00:37 +0000 Subject: [PATCH 05/30] chore(deps): bump dompurify from 3.4.11 to 3.4.13 Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.13. - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.13) --- updated-dependencies: - dependency-name: dompurify dependency-version: 3.4.13 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- packages/acp-client/package.json | 2 +- pnpm-lock.yaml | 40 ++++++++++++++++++-------------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 289994d00f..03c86dc953 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:", 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/pnpm-lock.yaml b/pnpm-lock.yaml index f78a152b3b..0827b76758 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -383,8 +383,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) @@ -554,8 +554,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) @@ -5220,8 +5220,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==} @@ -5616,6 +5616,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'} @@ -7067,8 +7071,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 @@ -9206,7 +9210,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 @@ -9214,14 +9218,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)': @@ -12319,7 +12323,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@22.19.7)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.1.3(@types/node@22.19.7)(esbuild@0.28.1)(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.5)(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)) optional: true '@vitest/coverage-v8@4.1.7(vitest@4.1.5)': @@ -13689,7 +13693,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.11: + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -14214,9 +14218,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: @@ -15691,7 +15697,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 @@ -16113,7 +16119,7 @@ snapshots: nanoid@3.3.15: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} nanostores@1.3.0: {} @@ -16569,7 +16575,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 From 678f0e0a480319ac9576768b8f328f0a31160a3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:54:55 +0000 Subject: [PATCH 06/30] chore(deps): bump anthropics/claude-code-action from 1.0.183 to 1.0.189 Bumps [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) from 1.0.183 to 1.0.189. - [Release notes](https://github.com/anthropics/claude-code-action/releases) - [Commits](https://github.com/anthropics/claude-code-action/compare/be7b93b1907a4abad570368f3c74b6fe3807510b...6b082c41935b4c8a3b8b0ef85ba4ba4d9eeb8975) --- updated-dependencies: - dependency-name: anthropics/claude-code-action dependency-version: 1.0.189 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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' From 5aa691bf90e2552372645d6ff1e0edbf549afdbf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:36:10 +0000 Subject: [PATCH 07/30] chore(deps): bump @typescript-eslint/parser from 8.65.0 to 8.67.0 Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 8.65.0 to 8.67.0. - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.67.0/packages/parser) --- updated-dependencies: - dependency-name: "@typescript-eslint/parser" dependency-version: 8.66.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pnpm-lock.yaml | 141 ++++++++++++++++++++++++++++++++++++++------ pnpm-workspace.yaml | 2 +- 2 files changed, 124 insertions(+), 19 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76c754f88a..fe36cc405c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ catalogs: specifier: 8.65.0 version: 8.65.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 @@ -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.65.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) @@ -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.65.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) @@ -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.65.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)) @@ -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.65.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.65.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.65.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.65.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) @@ -4400,16 +4400,33 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@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==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@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' + '@typescript-eslint/scope-manager@8.65.0': resolution: {integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.67.0': + resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.65.0': resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4422,6 +4439,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@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==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4437,12 +4460,22 @@ packages: resolution: {integrity: sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@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.65.0': resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.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==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4454,6 +4487,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 @@ -13050,6 +13087,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.65.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.67.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 + eslint: 9.39.5(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.65.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.65.0 @@ -13062,6 +13115,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@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)': dependencies: '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) @@ -13071,11 +13136,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': + dependencies: + '@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: + - supports-color + '@typescript-eslint/scope-manager@8.65.0': dependencies: '@typescript-eslint/types': 8.65.0 '@typescript-eslint/visitor-keys': 8.65.0 + '@typescript-eslint/scope-manager@8.67.0': + dependencies: + '@typescript-eslint/types': 8.67.0 + '@typescript-eslint/visitor-keys': 8.67.0 + '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -13084,6 +13163,10 @@ snapshots: dependencies: 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)': dependencies: '@typescript-eslint/types': 8.65.0 @@ -13100,6 +13183,8 @@ snapshots: '@typescript-eslint/types@8.66.0': {} + '@typescript-eslint/types@8.67.0': {} + '@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.65.0(typescript@5.9.3) @@ -13115,6 +13200,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': + dependencies: + '@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 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.65.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)) @@ -13131,6 +13231,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': @@ -13554,7 +13659,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 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7cf89d6983..323de48ab1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,7 @@ catalog: # === TypeScript ESLint === '@typescript-eslint/eslint-plugin': 8.65.0 - '@typescript-eslint/parser': 8.65.0 + '@typescript-eslint/parser': 8.67.0 typescript-eslint: 8.65.0 # === Cloudflare === From 4de3bdd651a97dd188cd703d24f2174adf22f891 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 11:36:13 +0000 Subject: [PATCH 08/30] chore(deps): bump recharts from 3.10.0 to 3.10.1 Bumps [recharts](https://github.com/recharts/recharts) from 3.10.0 to 3.10.1. - [Release notes](https://github.com/recharts/recharts/releases) - [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md) - [Commits](https://github.com/recharts/recharts/compare/v3.10.0...v3.10.1) --- updated-dependencies: - dependency-name: recharts dependency-version: 3.10.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- apps/web/package.json | 2 +- pnpm-lock.yaml | 66 +++++++++++++++++++++++-------------------- 2 files changed, 37 insertions(+), 31 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 289994d00f..cb9bc4ad61 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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/pnpm-lock.yaml b/pnpm-lock.yaml index 76c754f88a..85e9bb3d8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -378,7 +378,7 @@ importers: version: 5.101.2(react@19.2.7) '@xyflow/react': specifier: 12.10.2 - version: 12.10.2(@types/react@19.2.17)(immer@11.1.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 12.10.2(@types/react@19.2.17)(immer@11.1.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) better-auth: specifier: 'catalog:' version: 1.6.11(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(better-sqlite3@12.11.1)(drizzle-orm@0.45.2(@cloudflare/workers-types@5.20260707.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(better-sqlite3@12.11.1)(kysely@0.28.17))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.5) @@ -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 @@ -5890,8 +5890,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - es-toolkit@1.49.0: - resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} esast-util-from-estree@2.0.0: resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} @@ -6072,6 +6072,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'} @@ -6604,8 +6608,8 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} - immer@11.1.15: - resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + immer@11.1.17: + resolution: {integrity: sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q==} import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} @@ -7534,8 +7538,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 +8205,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 @@ -9759,7 +9763,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 +9771,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)': @@ -10527,7 +10531,7 @@ snapshots: '@commitlint/ensure@21.2.0': dependencies: '@commitlint/types': 21.2.0 - es-toolkit: 1.49.0 + es-toolkit: 1.50.0 '@commitlint/execute-rule@21.0.1': {} @@ -10556,7 +10560,7 @@ snapshots: '@commitlint/types': 21.2.0 cosmiconfig: 9.0.2(typescript@5.9.3) cosmiconfig-typescript-loader: 6.3.0(@types/node@22.19.7)(cosmiconfig@9.0.2(typescript@5.9.3))(typescript@5.9.3) - es-toolkit: 1.49.0 + es-toolkit: 1.50.0 is-plain-obj: 4.1.0 picocolors: 1.1.1 transitivePeerDependencies: @@ -10585,7 +10589,7 @@ snapshots: dependencies: '@commitlint/config-validator': 21.2.0 '@commitlint/types': 21.2.0 - es-toolkit: 1.49.0 + es-toolkit: 1.50.0 global-directory: 5.0.0 resolve-from: 5.0.0 @@ -12096,7 +12100,7 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 - immer: 11.1.15 + immer: 11.1.17 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.2.0 @@ -13340,13 +13344,13 @@ snapshots: '@xterm/xterm@5.5.0': {} - '@xyflow/react@12.10.2(@types/react@19.2.17)(immer@11.1.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@xyflow/react@12.10.2(@types/react@19.2.17)(immer@11.1.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@xyflow/system': 0.0.76 classcat: 5.0.5 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - zustand: 4.5.7(@types/react@19.2.17)(immer@11.1.15)(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.17)(immer@11.1.17)(react@19.2.7) transitivePeerDependencies: - '@types/react' - immer @@ -14779,7 +14783,7 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es-toolkit@1.49.0: {} + es-toolkit@1.50.0: {} esast-util-from-estree@2.0.0: dependencies: @@ -15113,9 +15117,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.0.6 execa@5.1.1: dependencies: @@ -15857,7 +15863,7 @@ snapshots: ignore@7.0.5: {} - immer@11.1.15: {} + immer@11.1.17: {} import-fresh@3.3.1: dependencies: @@ -17021,7 +17027,7 @@ snapshots: nanoid@3.3.15: {} - nanoid@3.3.17: {} + nanoid@3.3.18: {} nanostores@1.3.0: {} @@ -17537,7 +17543,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,14 +17789,14 @@ 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 decimal.js-light: 2.5.1 - es-toolkit: 1.49.0 + es-toolkit: 1.50.0 eventemitter3: 5.0.4 - immer: 11.1.15 + immer: 11.1.17 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) react-is: 17.0.2 @@ -19708,12 +19714,12 @@ snapshots: zod@4.4.3: {} - zustand@4.5.7(@types/react@19.2.17)(immer@11.1.15)(react@19.2.7): + zustand@4.5.7(@types/react@19.2.17)(immer@11.1.17)(react@19.2.7): dependencies: use-sync-external-store: 1.6.0(react@19.2.7) optionalDependencies: '@types/react': 19.2.17 - immer: 11.1.15 + immer: 11.1.17 react: 19.2.7 zwitch@2.0.4: {} From aa0dcbd00a89c2ef6732417b081a8b1d840df16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:01:52 +0000 Subject: [PATCH 09/30] task: add terminal jwt logout revocation --- ...26-08-16-terminal-jwt-logout-revocation.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tasks/backlog/2026-08-16-terminal-jwt-logout-revocation.md diff --git a/tasks/backlog/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/backlog/2026-08-16-terminal-jwt-logout-revocation.md new file mode 100644 index 0000000000..ad90b91d07 --- /dev/null +++ b/tasks/backlog/2026-08-16-terminal-jwt-logout-revocation.md @@ -0,0 +1,71 @@ +# 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. +- `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 + +- [ ] Add a session-binding claim to browser-minted terminal JWTs using the current BetterAuth session id from `getAuth(c)`. +- [ ] 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. +- [ ] Add a workspace-proxy liveness helper that, after JWT verification, fails closed unless: + - [ ] the token includes a non-empty session id; + - [ ] a BetterAuth session row exists for that session id and token subject; + - [ ] the session is not expired; + - [ ] the user row exists and is not suspended. +- [ ] Apply the liveness helper in `apps/api/src/index.ts` before D1 workspace routing/proxying for token-only workspace subdomain requests. +- [ ] Preserve app-session-cookie workspace proxy behavior for active sessions. +- [ ] Preserve port-access token/cookie behavior and internal `port-proxy` token generation. +- [ ] Move terminal token default expiry fallback to a `DEFAULT_*` constant. +- [ ] Add behavioral tests for: + - [ ] mint token → logout/session row removed → new workspace-proxy WebSocket upgrade rejected; + - [ ] active minting session still allows a new workspace-proxy connection; + - [ ] suspended token subject rejected even with an otherwise live session; + - [ ] missing session claim and missing/ambiguous DB state fail closed; + - [ ] terminal route passes the current auth session id into browser-minted tokens. +- [ ] Run focused API tests, broader validation, specialist review, staging deploy, and live staging verification. +- [ ] Clean up staging workspace/node `01M05DPW6YDCBTJ9EHVXDFXGTZ` or any replacement verification workspace. + +## 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` From 34b2a94ddab68e1eafdded339dabbc350a3e3a1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:02:17 +0000 Subject: [PATCH 10/30] task: start terminal jwt logout revocation --- .../2026-08-16-terminal-jwt-logout-revocation.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tasks/{backlog => active}/2026-08-16-terminal-jwt-logout-revocation.md (100%) diff --git a/tasks/backlog/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md similarity index 100% rename from tasks/backlog/2026-08-16-terminal-jwt-logout-revocation.md rename to tasks/active/2026-08-16-terminal-jwt-logout-revocation.md From bcae5c200651681f5f299eded9e34330201afb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:13:56 +0000 Subject: [PATCH 11/30] fix(security): bind terminal tokens to auth sessions --- apps/api/src/index.ts | 2 + apps/api/src/routes/terminal.ts | 39 ++--- apps/api/src/services/jwt.ts | 18 ++- .../src/services/terminal-token-liveness.ts | 83 +++++++++++ apps/api/tests/unit/routes/terminal.test.ts | 19 ++- .../services/terminal-token-liveness.test.ts | 134 +++++++++++++++++ .../unit/workspace-proxy-ownership.test.ts | 104 +++++++++++++- .../unit/workspace-proxy-port-access.test.ts | 136 +++++++++++------- packages/shared/src/vm-agent-contract.ts | 3 + ...26-08-16-terminal-jwt-logout-revocation.md | 34 ++--- 10 files changed, 470 insertions(+), 102 deletions(-) create mode 100644 apps/api/src/services/terminal-token-liveness.ts create mode 100644 apps/api/tests/unit/services/terminal-token-liveness.test.ts 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/routes/terminal.ts b/apps/api/src/routes/terminal.ts index 27c3d0aa2c..a4f93edd74 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,9 @@ terminalRoutes.post( } // Generate the terminal token - const { token, expiresAt } = await signTerminalToken(userId, body.workspaceId, c.env); + const { token, expiresAt } = await signTerminalToken(userId, body.workspaceId, c.env, { + sessionId: auth.session.id, + }); // 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 +62,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 +85,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 +97,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 +106,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/services/jwt.ts b/apps/api/src/services/jwt.ts index 6ea79c3190..5f9489ef0b 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: { sessionId?: 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.sessionId ? { sessionId: options.sessionId } : {}), }) .setProtectedHeader({ alg: 'RS256', kid: KEY_ID }) .setIssuer(issuer) @@ -180,6 +185,7 @@ export interface CallbackTokenPayload { export interface TerminalTokenPayload { workspace: string; subject: string; + sessionId?: string; } export interface PortAccessTokenPayload { @@ -276,6 +282,10 @@ export async function verifyTerminalToken(token: string, env: Env): Promise 0 + ? payload.sessionId + : 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..32b2d0f510 --- /dev/null +++ b/apps/api/src/services/terminal-token-liveness.ts @@ -0,0 +1,83 @@ +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.sessionId) { + 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.id, payload.sessionId), eq(schema.sessions.userId, payload.subject)) + ) + .get(); + + if (!row) { + log.warn('terminal_token.session_not_found', { + workspaceId: payload.workspace, + userId: payload.subject, + sessionId: payload.sessionId, + 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, + sessionId: payload.sessionId, + 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/terminal.test.ts b/apps/api/tests/unit/routes/terminal.test.ts index 392247b0c0..b379c3bcdf 100644 --- a/apps/api/tests/unit/routes/terminal.test.ts +++ b/apps/api/tests/unit/routes/terminal.test.ts @@ -10,6 +10,20 @@ 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', + expiresAt: new Date(Date.now() + 60_000), + }, + }), requireAuth: () => vi.fn((c: { set: (key: string, value: unknown) => void }, next: () => Promise) => { c.set('auth', { @@ -29,7 +43,6 @@ vi.mock('../../../src/middleware/auth', () => ({ return next(); }), requireApproved: () => vi.fn((_c: unknown, next: () => Promise) => next()), - getUserId: () => 'user-1', })); vi.mock('../../../src/services/jwt', () => ({ signTerminalToken: vi.fn(), @@ -160,7 +173,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, { + sessionId: 'session-1', + }); expect(updateTerminalActivity).not.toHaveBeenCalled(); }); 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..ec9016691f --- /dev/null +++ b/apps/api/tests/unit/services/terminal-token-liveness.test.ts @@ -0,0 +1,134 @@ +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; userId: string; expiresAt: number }> = {} + ): void { + sqlite + .prepare( + `INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)` + ) + .run( + overrides.id ?? 'session-1', + `token-${overrides.id ?? 'session-1'}`, + 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', + sessionId: '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', + sessionId: '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', + sessionId: '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', + sessionId: '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', + sessionId: 'session-1', + }) + ).rejects.toThrow('Your account has been suspended'); + }); +}); diff --git a/apps/api/tests/unit/workspace-proxy-ownership.test.ts b/apps/api/tests/unit/workspace-proxy-ownership.test.ts index 6b3471d052..0fbb981381 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,6 +82,14 @@ 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() }, @@ -70,6 +97,7 @@ describe('workspace subdomain proxy ownership', () => { mockVerifyTerminalToken.mockResolvedValue({ workspace: OWNER_WORKSPACE_ID, subject: 'user-owner', + sessionId: 'session-owner', }); mockSignTerminalToken.mockResolvedValue({ token: 'backend-port-token', @@ -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', + sessionId: 'session-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..57179f52c5 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', + sessionId: '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/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/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md index ad90b91d07..16a9d8dda7 100644 --- a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md +++ b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md @@ -27,23 +27,23 @@ Live staging reproduction on 2026-08-16: ## Implementation Checklist -- [ ] Add a session-binding claim to browser-minted terminal JWTs using the current BetterAuth session id from `getAuth(c)`. -- [ ] 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. -- [ ] Add a workspace-proxy liveness helper that, after JWT verification, fails closed unless: - - [ ] the token includes a non-empty session id; - - [ ] a BetterAuth session row exists for that session id and token subject; - - [ ] the session is not expired; - - [ ] the user row exists and is not suspended. -- [ ] Apply the liveness helper in `apps/api/src/index.ts` before D1 workspace routing/proxying for token-only workspace subdomain requests. -- [ ] Preserve app-session-cookie workspace proxy behavior for active sessions. -- [ ] Preserve port-access token/cookie behavior and internal `port-proxy` token generation. -- [ ] Move terminal token default expiry fallback to a `DEFAULT_*` constant. -- [ ] Add behavioral tests for: - - [ ] mint token → logout/session row removed → new workspace-proxy WebSocket upgrade rejected; - - [ ] active minting session still allows a new workspace-proxy connection; - - [ ] suspended token subject rejected even with an otherwise live session; - - [ ] missing session claim and missing/ambiguous DB state fail closed; - - [ ] terminal route passes the current auth session id into browser-minted tokens. +- [x] Add a session-binding claim to browser-minted terminal JWTs using the current BetterAuth session id 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 id; + - [x] a BetterAuth session row exists for that session id 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 id into browser-minted tokens. - [ ] Run focused API tests, broader validation, specialist review, staging deploy, and live staging verification. - [ ] Clean up staging workspace/node `01M05DPW6YDCBTJ9EHVXDFXGTZ` or any replacement verification workspace. From 22c273c8dbc5fe78ef0b03390baa5ddfcc4b4ed4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:37:59 +0000 Subject: [PATCH 12/30] task: add stale compose release reconciliation --- ...16-stale-compose-release-reconciliation.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tasks/backlog/2026-08-16-stale-compose-release-reconciliation.md diff --git a/tasks/backlog/2026-08-16-stale-compose-release-reconciliation.md b/tasks/backlog/2026-08-16-stale-compose-release-reconciliation.md new file mode 100644 index 0000000000..aaf18ec143 --- /dev/null +++ b/tasks/backlog/2026-08-16-stale-compose-release-reconciliation.md @@ -0,0 +1,98 @@ +# 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 + +- [ ] Add additive D1 schema/migration support for release status timestamps needed to make + stale-state reconciliation race-safe. +- [ ] Update release creation/apply/status-transition paths to maintain status timestamp data + and avoid late apply-fetch overwriting a reconciled terminal status. +- [ ] 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. +- [ ] Preserve observed-applied release and newest rollback protection by keeping terminalized + stale rows subject to the existing terminal release-retention query. +- [ ] 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. +- [ ] Add deterministic tests for fresh applying protection, stale reconciliation, observed + applied protection, cleanup ordering, batching/concurrency/idempotency, disabled/configured + behavior, and malformed/future statuses. +- [ ] Update `Env`, `.env.example`, generated deployment variable allowlists, env reference, and + public configuration/architecture docs for the new knobs and stale definition. +- [ ] Capture the degraded sleeping snapshot purge gap as a SAM Idea unless addressed in this PR + by a clearly shared lifecycle abstraction. +- [ ] Run focused tests while implementing, then full local validation required by `/do`. +- [ ] Run required specialist reviews: Cloudflare, constitution, documentation sync, env + validation, task completion, and test engineering. +- [ ] 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. + +## 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. From c8a87954f8de61a289d5ba30c570901c0cdf25a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:38:29 +0000 Subject: [PATCH 13/30] task: activate stale compose release reconciliation --- .../2026-08-16-stale-compose-release-reconciliation.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tasks/{backlog => active}/2026-08-16-stale-compose-release-reconciliation.md (100%) diff --git a/tasks/backlog/2026-08-16-stale-compose-release-reconciliation.md b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md similarity index 100% rename from tasks/backlog/2026-08-16-stale-compose-release-reconciliation.md rename to tasks/active/2026-08-16-stale-compose-release-reconciliation.md From 0f5c4ce2ab3d6d8fd20618c9caea864402176cb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:46:07 +0000 Subject: [PATCH 14/30] fix(security): preserve internal attachment uploads --- .../task-runner/workspace-steps.ts | 6 +- .../vm-agent-cross-boundary-contract.test.ts | 122 +++++++++++------- 2 files changed, 79 insertions(+), 49 deletions(-) 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 4ebb43a4c2..b0d01f9463 100644 --- a/apps/api/src/durable-objects/task-runner/workspace-steps.ts +++ b/apps/api/src/durable-objects/task-runner/workspace-steps.ts @@ -695,8 +695,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/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'); }); }); From 4eab4d419d447b2e6452c57ce0fdcb7aa219b8d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:47:00 +0000 Subject: [PATCH 15/30] task: document terminal revocation validation --- .../2026-08-16-terminal-jwt-logout-revocation.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md index 16a9d8dda7..93eaab2727 100644 --- a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md +++ b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md @@ -44,9 +44,21 @@ Live staging reproduction on 2026-08-16: - [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 id into browser-minted tokens. -- [ ] Run focused API tests, broader validation, specialist review, staging deploy, and live staging verification. +- [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. +- [ ] Complete specialist review, staging deploy, and live staging verification. - [ ] 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. + ## Acceptance Criteria - Previously minted browser terminal tokens are rejected for new workspace WebSocket/proxy connections after the minting auth session logs out. From 8cfe208ba74ce52db00f48f083a0fda27c4f9372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 14:47:50 +0000 Subject: [PATCH 16/30] task: add terminal revocation review evidence --- tasks/active/2026-08-16-terminal-jwt-logout-revocation.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md index 93eaab2727..43159394c2 100644 --- a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md +++ b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md @@ -59,6 +59,13 @@ Live staging reproduction on 2026-08-16: - `pnpm build` — passed. - `pnpm check:fast` — passed. +## Specialist Review + +- `security-auditor` — passed. Browser terminal token minting now embeds the current auth session id, 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. +- `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 id, and internal VM-agent routing contract. + ## Acceptance Criteria - Previously minted browser terminal tokens are rejected for new workspace WebSocket/proxy connections after the minting auth session logs out. From da37807a3c773ea07c9d1c105163a89ff6e86d53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 15:11:47 +0000 Subject: [PATCH 17/30] fix: reconcile stale compose releases --- .claude/skills/env-reference/SKILL.md | 4 + apps/api/.env.example | 4 + ...2_deployment_release_status_updated_at.sql | 6 + apps/api/src/db/schema.ts | 6 + apps/api/src/env.ts | 4 + .../api/src/routes/deploy-release-callback.ts | 16 +- .../deployment-environment-lifecycle.ts | 5 +- .../routes/deployment-release-submission.ts | 3 +- .../compose-publish-release-callback.ts | 3 + apps/api/src/scheduled/d1-retention.ts | 163 +++++++- apps/api/src/services/deployment-control.ts | 9 +- apps/api/src/services/deployment-volumes.ts | 2 +- .../routes/deploy-release-callback.test.ts | 35 +- ...deployment-custom-domains-vertical.test.ts | 9 +- ...ployment-environment-observability.test.ts | 15 +- .../tests/unit/scheduled/d1-retention.test.ts | 382 +++++++++++++++++- .../unit/services/deployment-control.test.ts | 12 +- .../docs/docs/architecture/overview.md | 24 +- .../docs/docs/reference/configuration.md | 38 +- scripts/deploy/sync-wrangler-config.ts | 4 + ...16-stale-compose-release-reconciliation.md | 37 +- 21 files changed, 720 insertions(+), 61 deletions(-) create mode 100644 apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql diff --git a/.claude/skills/env-reference/SKILL.md b/.claude/skills/env-reference/SKILL.md index 740f7aab11..83abd54e66 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`) ### Operational Control Loops diff --git a/apps/api/.env.example b/apps/api/.env.example index 228187c8e4..173681e81e 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -763,6 +763,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 ed0c8c8a9b..b6ba8d9acd 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -2768,6 +2768,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` @@ -2787,6 +2789,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/env.ts b/apps/api/src/env.ts index fecb069f4f..9ddaeaa3b8 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -199,6 +199,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/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/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/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/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/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 56277e72d4..df58cab0ee 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -167,21 +167,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 @@ -586,7 +596,7 @@ Webhook damping uses Cloudflare KV's eventually consistent read-update-write beh | `TASK_LIVENESS_MAX_ACP_SESSIONS` | `5` | Maximum task-scoped ACP sessions inspected per liveness probe | | `TASK_LIVENESS_PROBE_TIMEOUT_MS` | `5000` (5 sec) | Per-candidate timeout for ACP and Instant lifecycle probes used by ProjectData heartbeat deferral, idle cleanup, and stuck-task reconciliation; a timeout is inconclusive and preserves the task and workspace | | `IDLE_CLEANUP_MAX_CANDIDATES_PER_SWEEP` | `5` | Maximum exact-session task candidates inspected by a ProjectData idle-cleanup pass; workspace deletion is deferred when this bound cannot prove every reporter-scoped runtime conclusively dead | -| `IDLE_CLEANUP_MAX_RESIDENCE_MS` | `7200000` (2 hr) | Maximum residence for a ProjectData idle-cleanup schedule before repeated preserved/error outcomes stop re-arming, preserve the workspace, and surface an attention marker | +| `IDLE_CLEANUP_MAX_RESIDENCE_MS` | `7200000` (2 hr) | Maximum residence for a ProjectData idle-cleanup schedule before repeated preserved/error outcomes stop re-arming, preserve the workspace, and surface an attention marker | | `TASK_RUN_ABSOLUTE_CEILING_MS` | `86400000` (24 hr) | Absolute runaway-cost ceiling; fails even a task with a demonstrably live runtime | | `CLAUDE_CODE_COMPACTION_LOOP_DETECTOR_ENABLED` | `true` | Enable Claude Code compaction-loop shutdown from recent message evidence | | `CLAUDE_CODE_COMPACTION_LOOP_RECENT_MESSAGE_LIMIT` | `40` | Recent task-session messages to inspect for compaction-loop evidence | 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/tasks/active/2026-08-16-stale-compose-release-reconciliation.md b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md index aaf18ec143..6a7a6c3528 100644 --- a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md @@ -55,32 +55,51 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr ## Implementation checklist -- [ ] Add additive D1 schema/migration support for release status timestamps needed to make +- [x] Add additive D1 schema/migration support for release status timestamps needed to make stale-state reconciliation race-safe. -- [ ] Update release creation/apply/status-transition paths to maintain status timestamp data +- [x] Update release creation/apply/status-transition paths to maintain status timestamp data and avoid late apply-fetch overwriting a reconciled terminal status. -- [ ] Add configurable stale non-terminal release reconciliation to the scheduled release +- [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. -- [ ] Preserve observed-applied release and newest rollback protection by keeping terminalized +- [x] Preserve observed-applied release and newest rollback protection by keeping terminalized stale rows subject to the existing terminal release-retention query. -- [ ] Ensure the scheduled ordering is reconciliation → terminal release retention → compose +- [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. -- [ ] Add deterministic tests for fresh applying protection, stale reconciliation, observed +- [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. -- [ ] Update `Env`, `.env.example`, generated deployment variable allowlists, env reference, and +- [x] Update `Env`, `.env.example`, generated deployment variable allowlists, env reference, and public configuration/architecture docs for the new knobs and stale definition. -- [ ] Capture the degraded sleeping snapshot purge gap as a SAM Idea unless addressed in this PR +- [x] Capture the degraded sleeping snapshot purge gap as a SAM Idea unless addressed in this PR by a clearly shared lifecycle abstraction. -- [ ] Run focused tests while implementing, then full local validation required by `/do`. +- [x] Run focused tests while implementing, then full local validation required by `/do`. - [ ] Run required specialist reviews: Cloudflare, constitution, documentation sync, env validation, task completion, and test engineering. - [ ] 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:wrangler-bindings` — passed +- `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 + ## Acceptance criteria - Fresh `created`/`applying` compose releases remain protected. From 8cbc365b5d2105f9affa54ad7e6c218bca4ad862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 15:21:36 +0000 Subject: [PATCH 18/30] task: record stale release review evidence --- ...8-16-stale-compose-release-reconciliation.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md index 6a7a6c3528..4a00cea8a9 100644 --- a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md @@ -75,7 +75,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr - [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`. -- [ ] Run required specialist reviews: Cloudflare, constitution, documentation sync, env +- [x] Run required specialist reviews: Cloudflare, constitution, documentation sync, env validation, task completion, and test engineering. - [ ] 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 @@ -91,7 +91,11 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr - `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 @@ -100,6 +104,17 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr — passed, 8 files / 143 tests - `pnpm --filter @simple-agent-manager/api test` — passed, 547 files / 7,367 tests +## 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. From 24854e6fc7227e5e726060fb432813f1a7321371 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 15:23:02 +0000 Subject: [PATCH 19/30] fix(security): bind terminal tokens to session tokens --- apps/api/src/middleware/auth.ts | 13 +++++++++++-- apps/api/src/routes/terminal.ts | 5 ++++- apps/api/src/services/jwt.ts | 12 ++++++------ .../api/src/services/terminal-token-liveness.ts | 9 +++++---- apps/api/tests/unit/routes/terminal.test.ts | 4 +++- .../services/terminal-token-liveness.test.ts | 17 +++++++++-------- .../unit/workspace-proxy-ownership.test.ts | 8 ++++---- .../unit/workspace-proxy-port-access.test.ts | 2 +- 8 files changed, 43 insertions(+), 27 deletions(-) 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/terminal.ts b/apps/api/src/routes/terminal.ts index a4f93edd74..34bda66a94 100644 --- a/apps/api/src/routes/terminal.ts +++ b/apps/api/src/routes/terminal.ts @@ -51,8 +51,11 @@ terminalRoutes.post( } // Generate the terminal token + if (!auth.session.token) { + throw errors.unauthorized('Authentication required'); + } const { token, expiresAt } = await signTerminalToken(userId, body.workspaceId, c.env, { - sessionId: auth.session.id, + sessionToken: auth.session.token, }); // Canonical workspace URL is derived from workspace ID and base domain. diff --git a/apps/api/src/services/jwt.ts b/apps/api/src/services/jwt.ts index 5f9489ef0b..a1474e67df 100644 --- a/apps/api/src/services/jwt.ts +++ b/apps/api/src/services/jwt.ts @@ -51,7 +51,7 @@ export async function signTerminalToken( userId: string, workspaceId: string, env: Env, - options: { sessionId?: string | null } = {} + options: { sessionToken?: string | null } = {} ): Promise<{ token: string; expiresAt: string }> { const privateKey = await importPKCS8(env.JWT_PRIVATE_KEY, 'RS256'); const expiry = getTerminalTokenExpiry(env); @@ -60,7 +60,7 @@ export async function signTerminalToken( const token = await new SignJWT({ workspace: workspaceId, - ...(options.sessionId ? { sessionId: options.sessionId } : {}), + ...(options.sessionToken ? { sessionToken: options.sessionToken } : {}), }) .setProtectedHeader({ alg: 'RS256', kid: KEY_ID }) .setIssuer(issuer) @@ -185,7 +185,7 @@ export interface CallbackTokenPayload { export interface TerminalTokenPayload { workspace: string; subject: string; - sessionId?: string; + sessionToken?: string; } export interface PortAccessTokenPayload { @@ -282,9 +282,9 @@ export async function verifyTerminalToken(token: string, env: Env): Promise 0 - ? payload.sessionId + sessionToken: + typeof payload.sessionToken === 'string' && payload.sessionToken.length > 0 + ? payload.sessionToken : undefined, }; } diff --git a/apps/api/src/services/terminal-token-liveness.ts b/apps/api/src/services/terminal-token-liveness.ts index 32b2d0f510..2b8364b041 100644 --- a/apps/api/src/services/terminal-token-liveness.ts +++ b/apps/api/src/services/terminal-token-liveness.ts @@ -29,7 +29,7 @@ export async function assertTerminalTokenSessionLive( env: Env, payload: TerminalTokenPayload ): Promise { - if (!payload.sessionId) { + if (!payload.sessionToken) { log.warn('terminal_token.session_missing', { workspaceId: payload.workspace, userId: payload.subject, @@ -50,7 +50,10 @@ export async function assertTerminalTokenSessionLive( .from(schema.sessions) .innerJoin(schema.users, eq(schema.sessions.userId, schema.users.id)) .where( - and(eq(schema.sessions.id, payload.sessionId), eq(schema.sessions.userId, payload.subject)) + and( + eq(schema.sessions.token, payload.sessionToken), + eq(schema.sessions.userId, payload.subject) + ) ) .get(); @@ -58,7 +61,6 @@ export async function assertTerminalTokenSessionLive( log.warn('terminal_token.session_not_found', { workspaceId: payload.workspace, userId: payload.subject, - sessionId: payload.sessionId, action: 'rejected', }); throw new Error('Terminal token auth session is not live'); @@ -69,7 +71,6 @@ export async function assertTerminalTokenSessionLive( log.warn('terminal_token.session_expired', { workspaceId: payload.workspace, userId: payload.subject, - sessionId: payload.sessionId, expiresAt, action: 'rejected', }); diff --git a/apps/api/tests/unit/routes/terminal.test.ts b/apps/api/tests/unit/routes/terminal.test.ts index b379c3bcdf..8a4ec66fd8 100644 --- a/apps/api/tests/unit/routes/terminal.test.ts +++ b/apps/api/tests/unit/routes/terminal.test.ts @@ -21,6 +21,7 @@ vi.mock('../../../src/middleware/auth', () => ({ }, session: { id: 'session-1', + token: 'token-session-1', expiresAt: new Date(Date.now() + 60_000), }, }), @@ -37,6 +38,7 @@ vi.mock('../../../src/middleware/auth', () => ({ }, session: { id: 'session-1', + token: 'token-session-1', expiresAt: new Date(Date.now() + 60_000), }, }); @@ -174,7 +176,7 @@ describe('terminal routes', () => { workspaceUrl: 'https://ws-ws-123.sammy.party', }); expect(signTerminalToken).toHaveBeenCalledWith('user-1', 'ws-123', env, { - sessionId: 'session-1', + sessionToken: 'token-session-1', }); expect(updateTerminalActivity).not.toHaveBeenCalled(); }); diff --git a/apps/api/tests/unit/services/terminal-token-liveness.test.ts b/apps/api/tests/unit/services/terminal-token-liveness.test.ts index ec9016691f..ef2daff617 100644 --- a/apps/api/tests/unit/services/terminal-token-liveness.test.ts +++ b/apps/api/tests/unit/services/terminal-token-liveness.test.ts @@ -38,16 +38,17 @@ describe('terminal token session liveness', () => { } function seedSession( - overrides: Partial<{ id: string; userId: string; expiresAt: number }> = {} + 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( - overrides.id ?? 'session-1', - `token-${overrides.id ?? 'session-1'}`, + id, + overrides.token ?? `token-${id}`, overrides.userId ?? 'user-1', overrides.expiresAt ?? Date.now() + 60_000, Date.now(), @@ -63,7 +64,7 @@ describe('terminal token session liveness', () => { assertTerminalTokenSessionLive(env, { workspace: 'workspace-1', subject: 'user-1', - sessionId: 'session-1', + sessionToken: 'token-session-1', }) ).resolves.toBeUndefined(); }); @@ -75,7 +76,7 @@ describe('terminal token session liveness', () => { assertTerminalTokenSessionLive(env, { workspace: 'workspace-1', subject: 'user-1', - sessionId: 'session-1', + sessionToken: 'token-session-1', }) ).rejects.toThrow('Terminal token auth session is not live'); }); @@ -101,7 +102,7 @@ describe('terminal token session liveness', () => { assertTerminalTokenSessionLive(env, { workspace: 'workspace-1', subject: 'user-1', - sessionId: 'session-1', + sessionToken: 'token-session-1', }) ).rejects.toThrow('Terminal token auth session is not live'); }); @@ -114,7 +115,7 @@ describe('terminal token session liveness', () => { assertTerminalTokenSessionLive(env, { workspace: 'workspace-1', subject: 'user-1', - sessionId: 'session-1', + sessionToken: 'token-session-1', }) ).rejects.toThrow('Terminal token auth session expired'); }); @@ -127,7 +128,7 @@ describe('terminal token session liveness', () => { assertTerminalTokenSessionLive(env, { workspace: 'workspace-1', subject: 'user-1', - sessionId: 'session-1', + sessionToken: 'token-session-1', }) ).rejects.toThrow('Your account has been suspended'); }); diff --git a/apps/api/tests/unit/workspace-proxy-ownership.test.ts b/apps/api/tests/unit/workspace-proxy-ownership.test.ts index 0fbb981381..603c1a4f24 100644 --- a/apps/api/tests/unit/workspace-proxy-ownership.test.ts +++ b/apps/api/tests/unit/workspace-proxy-ownership.test.ts @@ -92,12 +92,12 @@ describe('workspace subdomain proxy ownership', () => { 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', - sessionId: 'session-owner', + sessionToken: 'token-owner', }); mockSignTerminalToken.mockResolvedValue({ token: 'backend-port-token', @@ -124,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( @@ -236,7 +236,7 @@ describe('workspace subdomain proxy ownership', () => { mockVerifyTerminalToken.mockResolvedValue({ workspace: OTHER_WORKSPACE_ID, subject: 'user-other', - sessionId: 'session-other', + sessionToken: 'token-other', }); terminalSessionResult = { sessionId: 'session-other', 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 57179f52c5..92e5e898f4 100644 --- a/apps/api/tests/unit/workspace-proxy-port-access.test.ts +++ b/apps/api/tests/unit/workspace-proxy-port-access.test.ts @@ -322,7 +322,7 @@ describe('workspace proxy port-access auth', () => { mockVerifyTerminalToken.mockResolvedValue({ workspace: WORKSPACE_ID, subject: 'user-1', - sessionId: 'session-1', + sessionToken: 'token-session-1', }); const response = await worker.default.fetch( From bfe27eed6b8659ce706b1ad60300716c2991395f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 15:59:38 +0000 Subject: [PATCH 20/30] task: record stale release staging evidence --- ...08-16-stale-compose-release-reconciliation.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md index 4a00cea8a9..00da82054b 100644 --- a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md @@ -103,6 +103,22 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr - `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. + +## 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. +- No production data was mutated; production evidence was used only to justify the stale-state + lifecycle gap. ## Specialist review evidence From 5a46366ce695c868128b659c75332aeb6d8c4252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:06:48 +0000 Subject: [PATCH 21/30] ci: sync release reconciliation deploy vars --- .github/workflows/deploy-reusable.yml | 8 ++++++++ .../2026-08-16-stale-compose-release-reconciliation.md | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/deploy-reusable.yml b/.github/workflows/deploy-reusable.yml index add8f44a5a..60f2e87761 100644 --- a/.github/workflows/deploy-reusable.yml +++ b/.github/workflows/deploy-reusable.yml @@ -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 }} @@ -759,6 +763,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/tasks/active/2026-08-16-stale-compose-release-reconciliation.md b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md index 00da82054b..5cfdc05e4e 100644 --- a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md @@ -107,6 +107,16 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr 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 From 630ecb6d928f1d935375a82488a8786e51ed9044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:10:19 +0000 Subject: [PATCH 22/30] task: record terminal revocation staging evidence --- ...26-08-16-terminal-jwt-logout-revocation.md | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md index 43159394c2..99cf218c47 100644 --- a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md +++ b/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md @@ -17,7 +17,7 @@ Live staging reproduction on 2026-08-16: - `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. +- 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: @@ -27,11 +27,11 @@ Live staging reproduction on 2026-08-16: ## Implementation Checklist -- [x] Add a session-binding claim to browser-minted terminal JWTs using the current BetterAuth session id from `getAuth(c)`. +- [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 id; - - [x] a BetterAuth session row exists for that session id and token subject; + - [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. @@ -43,11 +43,11 @@ Live staging reproduction on 2026-08-16: - [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 id into browser-minted tokens. + - [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. -- [ ] Complete specialist review, staging deploy, and live staging verification. -- [ ] Clean up staging workspace/node `01M05DPW6YDCBTJ9EHVXDFXGTZ` or any replacement verification workspace. +- [x] Complete specialist review, staging deploy, and live staging verification. +- [x] Clean up staging workspace/node `01M05DPW6YDCBTJ9EHVXDFXGTZ` or any replacement verification workspace. ## Local Validation @@ -58,13 +58,32 @@ Live staging reproduction on 2026-08-16: - 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 id, 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. +- `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 id, and internal VM-agent routing contract. +- `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 `[]`. ## Acceptance Criteria From efee5325ff4e33b6ce784b2a28115642c8fed930 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:11:05 +0000 Subject: [PATCH 23/30] task: archive terminal revocation work --- .../2026-08-16-terminal-jwt-logout-revocation.md | 4 ++++ 1 file changed, 4 insertions(+) rename tasks/{active => archive}/2026-08-16-terminal-jwt-logout-revocation.md (96%) diff --git a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md b/tasks/archive/2026-08-16-terminal-jwt-logout-revocation.md similarity index 96% rename from tasks/active/2026-08-16-terminal-jwt-logout-revocation.md rename to tasks/archive/2026-08-16-terminal-jwt-logout-revocation.md index 99cf218c47..5a767973f4 100644 --- a/tasks/active/2026-08-16-terminal-jwt-logout-revocation.md +++ b/tasks/archive/2026-08-16-terminal-jwt-logout-revocation.md @@ -85,6 +85,10 @@ Live staging reproduction on 2026-08-16: - 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. From 1a6851fc3f005f441920df2d58ea79249cb5c4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:11:07 +0000 Subject: [PATCH 24/30] ci: renumber release reconciliation migration --- ..._at.sql => 0114_deployment_release_status_updated_at.sql} | 0 .../2026-08-16-stale-compose-release-reconciliation.md | 5 ++++- 2 files changed, 4 insertions(+), 1 deletion(-) rename apps/api/src/db/migrations/{0112_deployment_release_status_updated_at.sql => 0114_deployment_release_status_updated_at.sql} (100%) diff --git a/apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql b/apps/api/src/db/migrations/0114_deployment_release_status_updated_at.sql similarity index 100% rename from apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql rename to apps/api/src/db/migrations/0114_deployment_release_status_updated_at.sql diff --git a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md index 5cfdc05e4e..6c2314f759 100644 --- a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/active/2026-08-16-stale-compose-release-reconciliation.md @@ -127,6 +127,9 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr - 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 + `0114_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. @@ -134,7 +137,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr | 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. | +| Cloudflare specialist | PASS | Additive D1 migration only (`apps/api/src/db/migrations/0114_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. | From 3ec550ca12d2358e6a6fd33e21facf6d5395eb2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:21:24 +0000 Subject: [PATCH 25/30] task: archive stale release reconciliation --- .../2026-08-16-stale-compose-release-reconciliation.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tasks/{active => archive}/2026-08-16-stale-compose-release-reconciliation.md (100%) diff --git a/tasks/active/2026-08-16-stale-compose-release-reconciliation.md b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md similarity index 100% rename from tasks/active/2026-08-16-stale-compose-release-reconciliation.md rename to tasks/archive/2026-08-16-stale-compose-release-reconciliation.md From b0432b4f70e42cc5b30553835be5bef06749d17d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:31:46 +0000 Subject: [PATCH 26/30] task: record stale release PR completion --- ...-08-16-stale-compose-release-reconciliation.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md index 6c2314f759..463eefaf69 100644 --- a/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md @@ -77,7 +77,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr - [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. -- [ ] Push the branch, create a PR against `main`, include required preflight/specialist +- [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. @@ -133,6 +133,19 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr - 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 | From 2300968c20fa7d6c1b20c8418ce27597265a6174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sun, 16 Aug 2026 16:42:18 +0000 Subject: [PATCH 27/30] chore: save agent work Auto-committed by SAM on agent completion. --- .codex/config.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From ba1841c8c1ca2bfd078b03abf5f574f6d215e9b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 22:24:59 +0000 Subject: [PATCH 28/30] chore(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 6.0.9 to 6.0.10. - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86) --- updated-dependencies: - dependency-name: pnpm/action-setup dependency-version: 6.0.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 30 +++++++++---------- .github/workflows/codspeed.yml | 2 +- .github/workflows/d1-restore.yml | 2 +- .github/workflows/deploy-reusable.yml | 2 +- .github/workflows/deploy-www.yml | 2 +- .github/workflows/deploy.yml | 4 +-- .../devcontainer-cache-experiments.yml | 6 ++-- .github/workflows/do-wall-time.yml | 2 +- .github/workflows/e2e-smoke.yml | 2 +- .github/workflows/provision-www.yml | 2 +- .github/workflows/pulumi-state-repair.yml | 2 +- .github/workflows/scheduler-lifecycle.yml | 2 +- .github/workflows/teardown.yml | 2 +- 13 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93547e43d5..8e532c68dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -109,7 +109,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 +130,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 +153,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 +192,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 +234,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 +258,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 +282,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 +303,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 +355,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 +413,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 +454,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 +522,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 +551,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 +578,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/codspeed.yml b/.github/workflows/codspeed.yml index 94fdaf0dc9..380158e550 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: 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..b351b7afb8 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 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 From 6c3e03ef814c1dfda01fec3fafccc84c4fe479d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Mon, 17 Aug 2026 07:24:56 +0000 Subject: [PATCH 29/30] fix(ci): resolve batch 3 integration collisions Integration-only fixes required to combine PRs #1838, #1837, #1738 and the routine dependency bumps onto current main: - Renumber PR #1837's D1 migration 0114 -> 0115. Main already claimed 0114 (0114_credential_setup_exchanging_status.sql) after #1837 branched, so the merged tree tripped quality:migration-ordering's duplicate-prefix guard. The migration was never applied to any environment, so renaming cannot replay it. - Align @typescript-eslint/eslint-plugin and typescript-eslint to 8.67.0. Dependabot (#1800) only proposes the parser; the three ship in lockstep and a parser/plugin skew is a known source of AST/rule disagreement. - Regenerate pnpm-lock.yaml for the merged dependency set. - Skip the agent preflight-evidence step for dependabot-authored PRs. Bots open PRs from a fixed template and cannot produce preflight research evidence, so the check was unsatisfiable and left every dependency PR permanently red. The stale-tracked-binary guard in the same job still runs for all authors. --- .github/workflows/ci.yml | 5 + ..._deployment_release_status_updated_at.sql} | 0 pnpm-lock.yaml | 183 +++++------------- pnpm-workspace.yaml | 6 +- ...16-stale-compose-release-reconciliation.md | 4 +- 5 files changed, 57 insertions(+), 141 deletions(-) rename apps/api/src/db/migrations/{0114_deployment_release_status_updated_at.sql => 0115_deployment_release_status_updated_at.sql} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 097eed8a87..f58964df49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,12 @@ 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. - name: Validate agent preflight evidence + if: github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm quality:preflight - name: Validate no stale binary artifacts are tracked diff --git a/apps/api/src/db/migrations/0114_deployment_release_status_updated_at.sql b/apps/api/src/db/migrations/0115_deployment_release_status_updated_at.sql similarity index 100% rename from apps/api/src/db/migrations/0114_deployment_release_status_updated_at.sql rename to apps/api/src/db/migrations/0115_deployment_release_status_updated_at.sql diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c48d0bd89f..f5036dd878 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ 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.67.0 version: 8.67.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,7 +155,7 @@ importers: version: 22.19.7 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -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,7 +297,7 @@ importers: version: 8.0.0 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -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,7 +463,7 @@ importers: version: 3.0.6 '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -604,7 +604,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -677,7 +677,7 @@ importers: devDependencies: '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -711,7 +711,7 @@ 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.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -751,7 +751,7 @@ importers: version: 19.2.3(@types/react@19.2.17) '@typescript-eslint/eslint-plugin': specifier: 'catalog:' - version: 8.65.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) + 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.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) @@ -4385,18 +4385,11 @@ 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==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.65.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/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.67.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' @@ -4407,12 +4400,6 @@ packages: 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==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.67.0': resolution: {integrity: sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -4427,26 +4414,14 @@ packages: resolution: {integrity: sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.65.0': - resolution: {integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==} - 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==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' - '@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 @@ -4464,20 +4439,14 @@ packages: resolution: {integrity: sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.65.0': - resolution: {integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.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 @@ -8242,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 @@ -9041,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 @@ -13075,30 +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)': - 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 - eslint: 9.39.5(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/eslint-plugin@8.65.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/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.67.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/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 @@ -13107,18 +13060,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.65.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 - debug: 4.4.3 - eslint: 9.39.5(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/parser@8.67.0(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.67.0 @@ -13131,15 +13072,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.65.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.66.0(typescript@5.9.3) - '@typescript-eslint/types': 8.66.0 - debug: 4.4.3 - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/project-service@8.67.0(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.67.0(typescript@5.9.3) @@ -13159,23 +13091,15 @@ snapshots: '@typescript-eslint/types': 8.67.0 '@typescript-eslint/visitor-keys': 8.67.0 - '@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - - '@typescript-eslint/tsconfig-utils@8.66.0(typescript@5.9.3)': - dependencies: - 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) @@ -13189,21 +13113,6 @@ snapshots: '@typescript-eslint/types@8.67.0': {} - '@typescript-eslint/typescript-estree@8.65.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 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/typescript-estree@8.67.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.67.0(typescript@5.9.3) @@ -13219,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: @@ -13273,7 +13182,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.5)(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: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.1.3(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) optional: true '@vitest/coverage-v8@4.1.7(vitest@4.1.5)': @@ -13288,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: @@ -17894,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 @@ -19014,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 323de48ab1..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 + # 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.65.0 + typescript-eslint: 8.67.0 # === Cloudflare === '@cloudflare/workers-types': 5.20260707.1 diff --git a/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md index 463eefaf69..f62c9e9a2d 100644 --- a/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md @@ -129,7 +129,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr `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 - `0114_deployment_release_status_updated_at.sql` with no SQL content change. + `0115_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. @@ -150,7 +150,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr | Reviewer | Verdict | Evidence | | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cloudflare specialist | PASS | Additive D1 migration only (`apps/api/src/db/migrations/0114_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. | +| Cloudflare specialist | PASS | Additive D1 migration only (`apps/api/src/db/migrations/0115_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. | From 8ea9638bc40ab0accc943e46746fa93a8d5917e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Mon, 17 Aug 2026 07:31:53 +0000 Subject: [PATCH 30/30] fix(ci): restore applied migration filename and harden dependabot skip Corrects two findings from local specialist review of the previous integration commit (6c3e03ef8). cloudflare-specialist (CRITICAL): renumbering PR #1837's migration was wrong. The file was already applied to staging as 0112_deployment_release_status_updated_at.sql (staging d1_migrations id 134, applied 2026-08-16 15:30:36) and #1837's own branch had already renamed it once (0112 -> 0114) afterwards. Wrangler matches migrations by exact filename, so any renumber makes staging treat it as new and replay 'ALTER TABLE deployment_releases ADD COLUMN status_updated_at' against a table that already has the column, aborting the migration step and failing the deploy. Restored the applied filename and grandfathered prefix 0112 in check-migration-ordering.ts, matching the existing 0105/0106 precedent for this exact situation. Verified against both live databases: staging - ledger has 0112_deployment_release_status_updated_at.sql, column exists -> wrangler skips it prod - filename absent from ledger, column absent -> wrangler applies it security-auditor (MEDIUM): the dependabot preflight skip keyed only on pull_request.user.login, which is frozen when the PR is opened. Dependabot branches live in this repo rather than a fork, so a collaborator can push a commit onto an open dependabot/** branch and keep the skip active for human-authored code. Now requires github.actor to agree, so a human push re-enables the check. --- .github/workflows/ci.yml | 10 +++++++++- ...> 0112_deployment_release_status_updated_at.sql} | 0 scripts/quality/check-migration-ordering.ts | 13 +++++++++++++ ...26-08-16-stale-compose-release-reconciliation.md | 4 ++-- 4 files changed, 24 insertions(+), 3 deletions(-) rename apps/api/src/db/migrations/{0115_deployment_release_status_updated_at.sql => 0112_deployment_release_status_updated_at.sql} (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f58964df49..685fd92d8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -99,8 +99,16 @@ jobs: # 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.event.pull_request.user.login != 'dependabot[bot]' + 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 diff --git a/apps/api/src/db/migrations/0115_deployment_release_status_updated_at.sql b/apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql similarity index 100% rename from apps/api/src/db/migrations/0115_deployment_release_status_updated_at.sql rename to apps/api/src/db/migrations/0112_deployment_release_status_updated_at.sql 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-16-stale-compose-release-reconciliation.md b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md index f62c9e9a2d..adbf4732da 100644 --- a/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md +++ b/tasks/archive/2026-08-16-stale-compose-release-reconciliation.md @@ -129,7 +129,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr `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 - `0115_deployment_release_status_updated_at.sql` with no SQL content change. + `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. @@ -150,7 +150,7 @@ releases, newest rollback releases, and ambiguous/future statuses must remain pr | Reviewer | Verdict | Evidence | | ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Cloudflare specialist | PASS | Additive D1 migration only (`apps/api/src/db/migrations/0115_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. | +| 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. |