From 37ca541cc802645bb3181d66bba4233878f0f1ac Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:10:33 -0400 Subject: [PATCH 1/8] fix: modernize and validate the isolated Claude SDK runner (#270) --- .github/workflows/ci.yml | 4 + CLAUDE.md | 4 +- README.md | 20 +++- package.json | 3 +- vitest.config.ts | 4 +- worker-sdk/package-lock.json | 200 +++++++++++++++++++++++++--------- worker-sdk/package.json | 4 +- worker-sdk/sdk-runner.test.ts | 190 ++++++++++++++++++++++++++++++++ worker-sdk/sdk-runner.ts | 26 ++++- worker-sdk/tsconfig.json | 4 + 10 files changed, 393 insertions(+), 66 deletions(-) create mode 100644 worker-sdk/sdk-runner.test.ts create mode 100644 worker-sdk/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d33b0d5..d3874e08 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,12 +27,16 @@ jobs: with: node-version-file: .nvmrc cache: npm + cache-dependency-path: | + package-lock.json + worker-sdk/package-lock.json # --ignore-scripts blocks EVERY dependency's install/postinstall script # (agent-authored deps land in these PR builds), then we explicitly # rebuild only better-sqlite3 — the one native module the app needs — so # its prebuilt binary is present for the test run. This is the # single-package allowlist the SYD-209 spec calls for. - run: npm ci --ignore-scripts + - run: npm ci --prefix worker-sdk --ignore-scripts - run: npm rebuild better-sqlite3 - run: npm run lint - run: npm run format:check diff --git a/CLAUDE.md b/CLAUDE.md index 360b9438..19845bcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This repo tracks its own work in Switchyard itself (project key `SYD`) via the ` npm run dev # server on :3300 (tsx src/server.ts); SWITCHYARD_DB / PORT env override npm test # vitest run (all tests) npx vitest run tests/services/issues-update.test.ts # single test file -npm run typecheck # checks BOTH tsconfigs: app (tsc --noEmit) and ui (tsc -p ui) +npm run typecheck # checks app, ui, and isolated worker-sdk tsconfigs npm run lint # eslint . npm run format:check # prettier --check . (npm run format to fix) npm run build:ui # vite build → dist/ui (server 404s SPA routes until this exists) @@ -44,7 +44,7 @@ npx tsx scripts/syd.ts whoami --as SWITCHYARD_TOKEN # pick a credenti Before any write it prints `acting as () via `. Read that line. Variable names describe the *actor*, not the tier — `SWITCHYARD_HUMAN_TOKEN` is a person, `SWITCHYARD_GITHUB_POLLER_TOKEN` and `SWITCHYARD_DELIVER_POLLER_TOKEN` are `service` actors, and `SWITCHYARD_TOKEN` is the dispatch worker's *agent* token, which the delivery and GitHub-ingestion endpoints refuse on purpose. -`worker-sdk/` has isolated dependencies (`npm install --prefix worker-sdk`) because the Claude Agent SDK needs zod@4 while the app is on zod@3. +`worker-sdk/` has isolated dependencies (`npm ci --prefix worker-sdk --ignore-scripts`) because the Claude Agent SDK needs zod@4 while the app is on zod@3. Install them before running the required checks; CI installs both lockfiles. `npm run typecheck:worker-sdk` checks the runner against its pinned SDK without adding the SDK to app dependencies. ## Constraints & conventions diff --git a/README.md b/README.md index 70b49716..017a0e0c 100644 --- a/README.md +++ b/README.md @@ -236,9 +236,23 @@ There is more than one way to spin up a session; `runner` in - `"sdk"` — run the session in-process through the Claude Agent SDK. The MCP bearer token is handed over as an in-memory object (never argv, never a temp file), and the worker log gets one line per tool call instead of an - opaque transcript. Setup: `npm install --prefix worker-sdk` (its deps are + opaque transcript. Setup: `npm ci --prefix worker-sdk --ignore-scripts` (its deps are isolated there because the SDK wants zod@4 and the app is on zod@3). - Not combinable with `containerized` yet. + This is a **trusted, host-only Claude runner**, not an isolation boundary: + it runs with the worker host's filesystem and environment access and cannot + be combined with `containerized`. Prefer the containerized CLI for unattended + work. The SDK explicitly uses the `claude_code` system prompt and + `settingSources: ["project"]`, loading repository instructions, skills and + hooks while excluding user/local settings. Global configuration and managed + policy may still apply; project hooks must be trusted. + +The SDK is pinned in its isolated lockfile. After updating it, run +`npm run typecheck:worker-sdk` and the SDK runner tests; both are included in +the normal required checks and CI. These checks mock provider sessions and +do not require provider credentials. SDK behavior follows the official +[system prompt](https://code.claude.com/docs/en/agent-sdk/modifying-system-prompts) +and [settings-source](https://code.claude.com/docs/en/agent-sdk/claude-code-features) +documentation. Registering non-Claude runners (Codex, Antigravity, Cursor) is being researched in SYD-46. @@ -451,6 +465,8 @@ SWITCHYARD_URL=http://localhost:3300 SWITCHYARD_TOKEN=... DREAMER_DRY_RUN=1 sh s ## Development ```bash +npm ci +npm ci --prefix worker-sdk --ignore-scripts npm test npm run typecheck ``` diff --git a/package.json b/package.json index e92049e3..7d1636b9 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "build:ui": "vite build --config ui/vite.config.ts", "dev:ui": "vite --config ui/vite.config.ts", "test": "vitest run", - "typecheck": "tsc --noEmit && tsc -p ui --noEmit", + "typecheck": "tsc --noEmit && tsc -p ui --noEmit && npm run typecheck:worker-sdk", + "typecheck:worker-sdk": "tsc -p worker-sdk --noEmit", "verify": "tsx scripts/verify-worker-parity.ts", "db:generate": "drizzle-kit generate", "deploy": "sh scripts/deploy-nas.sh", diff --git a/vitest.config.ts b/vitest.config.ts index 96f71d68..1cbbc2d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,5 +15,7 @@ enforceNodeEngines(pkg.engines?.node, process.version, { }); export default defineConfig({ - test: { include: ["tests/**/*.test.{ts,mjs}", "ui/src/**/*.test.{ts,tsx}"] }, + test: { + include: ["tests/**/*.test.{ts,mjs}", "ui/src/**/*.test.{ts,tsx}", "worker-sdk/**/*.test.ts"], + }, }); diff --git a/worker-sdk/package-lock.json b/worker-sdk/package-lock.json index 25fd3e76..95da54ac 100644 --- a/worker-sdk/package-lock.json +++ b/worker-sdk/package-lock.json @@ -6,27 +6,27 @@ "": { "name": "switchyard-worker-sdk", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.204", + "@anthropic-ai/claude-agent-sdk": "0.3.283", "zod": "^4.0.0" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.204.tgz", - "integrity": "sha512-nicn6OrlmUIuk4cdIkKQWKpHbkz3rL0EkYIsil6m1QejxD5gVDnVdgHuIUuwhcZB0PriBou9VcYLa3vRoyVFiQ==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.283.tgz", + "integrity": "sha512-KB+mqU5JLbH2sztlSQeCOu71bK6padYAha3uacBzxFSOVfuRTywYzvsC9P+qV6gXmPXcu98FaPqQv6vBF9j8hA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.204", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.204", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.204", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.204", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.204", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.204", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.204", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.204" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.283", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.283", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.283", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.283", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.283", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.283", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.283", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.283" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -35,9 +35,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.204.tgz", - "integrity": "sha512-TYeNVAaeALaTMSLy00n7tUnUCUZEE/rJb7krx3RH5XvWSf/7jGiSDSAvq6aFts1/J7KSefhlctLfA5fPEdmlMQ==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.283.tgz", + "integrity": "sha512-UQkROekjufppyB/qrsrU81sM0fcYNWJBEITrGp7NLbOocJZBUR+uVMIx/UHVpe6j81trXPIeRWyfJWgZI17Kxg==", "cpu": [ "arm64" ], @@ -48,9 +48,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.204.tgz", - "integrity": "sha512-azilRc19MvLajTBGR1fqVzK+j1xabPESjrrnoEGU0Ugrgjm+SHxQDI6itXTKwj6TjjLr6sCxEtDTkV5lUzHelg==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.283.tgz", + "integrity": "sha512-WkwVppmX0cg1DnXTT9od82pmqM4OK9H3O6MIXoX89SWYty0OpMxsB37263C1tiqk4WkWCs2jwxRXhsoO62ArGQ==", "cpu": [ "x64" ], @@ -61,9 +61,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.204.tgz", - "integrity": "sha512-hwlaYrtJDs0Hu+M56QlgAzSSRCOd4JrJfg+nirKKD4dKA/A0J9RzNW7DaaXZu2RWL6+pauOIem72E95LfcLmFw==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.283.tgz", + "integrity": "sha512-47IEX/XWw4DUIzPPC85qiASO90rpz7E+2gWr4AKOz5ZAa7ZqEX0PYqPIcpHEM7WpEREfmXCMZgef2bwim24u8A==", "cpu": [ "arm64" ], @@ -74,9 +74,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.204.tgz", - "integrity": "sha512-btK8EygEeizBFKJxI6xYiJ2EmFqkDcr2l1YPBoOJTg0dPVS6mzH7BUt4Jk6F9c/1UNhzdEWzNq+j2/xUN77W+g==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.283.tgz", + "integrity": "sha512-BRlnlh5fsRMoTtjDSGxZL6TathGRgSCMpJk2kdy9Wtz8l6qDjgkLuQilzTF12CwFIn+yf1Fqfrk+Njw+0EsL3A==", "cpu": [ "arm64" ], @@ -87,9 +87,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.204.tgz", - "integrity": "sha512-0sVBc2IbXYc4E8U9feYSuP83G9Z+U+/2VhaLl+qea3RAByR1JzOHjjPa4bXNWHDJWBhMF1t8g7a3QsBi1l21DQ==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.283.tgz", + "integrity": "sha512-cE5AebMvTlq7t7Oc0FmP5LzMOEtJ3F8XRGxkc3kTGr4aNcQgXplyYNH2yu1teW1yInuMXHzlCeFRdrmxpe2sRg==", "cpu": [ "x64" ], @@ -100,9 +100,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.204.tgz", - "integrity": "sha512-ZB2Tp9h1OMjcrEdY0lqSc0wE5Jj1qctwmBk2qaaxbhT0FLAocl4/aUisTOg737aXDnP6R1q7S+7kYTkhox5NLQ==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.283.tgz", + "integrity": "sha512-T0T8mR7MSe7bVI96tmeK7DgUy2xhG8CZD1i0nCC2C05sSmkDDn/OYgNqpGPxJbo8Ic0Psxd7TOmngQYqPKWtpA==", "cpu": [ "x64" ], @@ -113,9 +113,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.204.tgz", - "integrity": "sha512-Wf2dNtscF6MWYeMaKIPDrIZ8302f9rFjtKDYDAWFbV59Fq+xDbWf4AM6B4hC7ln+NgjwlCf6RY0M8a36hTq0rw==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.283.tgz", + "integrity": "sha512-hZjyeIgZpALMvYq2QfyPzl4AZzOVRUoU8NOB82Z3cVXLcEvQJXJN03Hp3XNsuBq5YMUoSUJAffMannhM7UqVmg==", "cpu": [ "arm64" ], @@ -126,9 +126,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.204", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.204.tgz", - "integrity": "sha512-EJXAEiJzeIwpjxqsNW904YbwkpqjQstX6wvWzvryN47/aUQ7aFKPJJgNT/VVRl9gi1r62PIQrpN5c0Kwdv+m5g==", + "version": "0.3.283", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.283.tgz", + "integrity": "sha512-h5eZxFxk6f1LPSuUOoBH9fslhL9ncywdZN5Qw9XmWuijHYTfevXuoza5nm6N98O+hmSykJ8NjC3/muhONDvzBg==", "cpu": [ "x64" ], @@ -165,6 +165,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -174,6 +175,7 @@ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.14.1" }, @@ -226,13 +228,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", + "peer": true, "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" @@ -246,6 +250,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -262,6 +267,7 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -279,6 +285,7 @@ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", @@ -303,6 +310,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -316,6 +324,7 @@ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -325,6 +334,7 @@ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" @@ -338,6 +348,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -354,6 +365,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -367,6 +379,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -376,6 +389,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -385,6 +399,7 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.6.0" } @@ -394,6 +409,7 @@ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", + "peer": true, "dependencies": { "object-assign": "^4", "vary": "^1" @@ -411,6 +427,7 @@ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", + "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -425,6 +442,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "^2.1.3" }, @@ -442,6 +460,7 @@ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -451,6 +470,7 @@ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", @@ -464,13 +484,15 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -480,6 +502,7 @@ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -489,6 +512,7 @@ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -498,6 +522,7 @@ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0" }, @@ -509,13 +534,15 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -525,6 +552,7 @@ "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", + "peer": true, "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -537,6 +565,7 @@ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" } @@ -590,6 +619,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", + "peer": true, "dependencies": { "ip-address": "^10.2.0" }, @@ -607,13 +637,15 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-sha256": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" + "license": "Unlicense", + "peer": true }, "node_modules/fast-uri": { "version": "3.1.3", @@ -629,13 +661,15 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", @@ -657,6 +691,7 @@ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -666,6 +701,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -675,6 +711,7 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -684,6 +721,7 @@ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", @@ -708,6 +746,7 @@ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "license": "MIT", + "peer": true, "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" @@ -721,6 +760,7 @@ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -733,6 +773,7 @@ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -745,6 +786,7 @@ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", + "peer": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -767,6 +809,7 @@ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "license": "MIT", + "peer": true, "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", @@ -787,6 +830,7 @@ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", + "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -802,13 +846,15 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 12" } @@ -818,6 +864,7 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.10" } @@ -826,19 +873,22 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/jose": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -848,6 +898,7 @@ "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" @@ -860,19 +911,22 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-typed": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "peer": true }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" } @@ -882,6 +936,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -891,6 +946,7 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -903,6 +959,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -912,6 +969,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "^1.54.0" }, @@ -927,13 +985,15 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -943,6 +1003,7 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -952,6 +1013,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -964,6 +1026,7 @@ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", "license": "MIT", + "peer": true, "dependencies": { "ee-first": "1.1.1" }, @@ -976,6 +1039,7 @@ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "license": "ISC", + "peer": true, "dependencies": { "wrappy": "1" } @@ -985,6 +1049,7 @@ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -994,6 +1059,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1003,6 +1069,7 @@ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -1013,6 +1080,7 @@ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.20.0" } @@ -1022,6 +1090,7 @@ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "license": "MIT", + "peer": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -1035,6 +1104,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" @@ -1051,6 +1121,7 @@ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" }, @@ -1064,6 +1135,7 @@ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", + "peer": true, "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", @@ -1079,6 +1151,7 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1088,6 +1161,7 @@ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", @@ -1103,13 +1177,15 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", @@ -1136,6 +1212,7 @@ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", + "peer": true, "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", @@ -1154,13 +1231,15 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -1173,6 +1252,7 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1182,6 +1262,7 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", @@ -1201,6 +1282,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -1217,6 +1299,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -1235,6 +1318,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -1254,6 +1338,7 @@ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", "license": "MIT", + "peer": true, "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" @@ -1264,6 +1349,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -1273,6 +1359,7 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.6" } @@ -1281,13 +1368,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", + "peer": true, "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", @@ -1306,6 +1395,7 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -1319,6 +1409,7 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -1328,6 +1419,7 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -1337,6 +1429,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -1351,14 +1444,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -1368,6 +1461,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.25.28 || ^4" } diff --git a/worker-sdk/package.json b/worker-sdk/package.json index 30f92564..782d3a1c 100644 --- a/worker-sdk/package.json +++ b/worker-sdk/package.json @@ -2,9 +2,9 @@ "name": "switchyard-worker-sdk", "private": true, "type": "module", - "description": "Isolated deps for the Claude Agent SDK runner (runner: \"sdk\"). Separate package on purpose: the SDK peer-depends on zod@4 while the main app is on zod@3, and the server Docker image should never ship the SDK. Install with: npm install --prefix worker-sdk", + "description": "Isolated deps for the Claude Agent SDK runner (runner: \"sdk\"). Separate package on purpose: the SDK peer-depends on zod@4 while the main app is on zod@3, and the server Docker image should never ship the SDK. Install with: npm ci --prefix worker-sdk --ignore-scripts", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.204", + "@anthropic-ai/claude-agent-sdk": "0.3.283", "zod": "^4.0.0" } } diff --git a/worker-sdk/sdk-runner.test.ts b/worker-sdk/sdk-runner.test.ts new file mode 100644 index 00000000..07ab87fe --- /dev/null +++ b/worker-sdk/sdk-runner.test.ts @@ -0,0 +1,190 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { runSdkSession, type SdkSessionOpts } from "./sdk-runner.js"; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: vi.fn() })); + +const queryMock = vi.mocked(query); +// Query also exposes interactive controls that runSdkSession does not use. +const asQuery = (stream: AsyncIterable) => stream as ReturnType; +const result = (subtype = "success") => ({ type: "result", subtype }) as SDKMessage; + +describe("runSdkSession", () => { + let directory: string; + let options: SdkSessionOpts; + + beforeEach(() => { + queryMock.mockReset(); + directory = mkdtempSync(join(tmpdir(), "switchyard-sdk-")); + options = { + prompt: "Fix the claimed issue.", + cwd: directory, + switchyardUrl: "https://switchyard.example/", + switchyardToken: "test-bearer-secret", + switchyardLeaseToken: "test-lease-secret", + allowedTools: ["Read", "Edit", "Bash", "mcp__switchyard__claim_issue"], + logPath: join(directory, "session.log"), + }; + queryMock.mockImplementation(() => + asQuery( + (async function* () { + yield result(); + })(), + ), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + rmSync(directory, { recursive: true, force: true }); + }); + + it("uses the coding prompt, project settings and in-memory MCP credentials", async () => { + expect(await runSdkSession(options)).toBe(0); + expect(queryMock).toHaveBeenCalledExactlyOnceWith({ + prompt: options.prompt, + options: { + cwd: directory, + systemPrompt: { type: "preset", preset: "claude_code" }, + settingSources: ["project"], + permissionMode: "acceptEdits", + allowedTools: options.allowedTools, + abortController: expect.any(AbortController), + mcpServers: { + switchyard: { + type: "http", + url: "https://switchyard.example/mcp", + headers: { + Authorization: "Bearer test-bearer-secret", + "X-Switchyard-Lease": "test-lease-secret", + }, + }, + }, + }, + }); + const log = readFileSync(options.logPath, "utf8"); + expect(log).toContain("result: success"); + expect(log).not.toContain(options.switchyardToken); + expect(log).not.toContain(options.switchyardLeaseToken); + }); + + it("omits an absent lease and passes an explicitly configured model", async () => { + delete options.switchyardLeaseToken; + options.model = "claude-sonnet-4-6"; + await runSdkSession(options); + expect(queryMock.mock.calls[0][0].options).toMatchObject({ + model: options.model, + mcpServers: { + switchyard: { headers: { Authorization: "Bearer test-bearer-secret" } }, + }, + }); + expect(queryMock.mock.calls[0][0].options?.mcpServers?.switchyard).not.toHaveProperty( + "headers.X-Switchyard-Lease", + ); + }); + + it("does not start a provider session after the host cancels its lease", async () => { + const host = new AbortController(); + host.abort(); + expect(await runSdkSession({ ...options, externalAbortSignal: host.signal })).toBe(1); + expect(queryMock).not.toHaveBeenCalled(); + }); + + it("propagates cancellation, refuses late success and removes the host listener", async () => { + const host = new AbortController(); + const remove = vi.spyOn(host.signal, "removeEventListener"); + let querySignal: AbortSignal | undefined; + queryMock.mockImplementation(({ options: sdkOptions }) => + asQuery( + (async function* () { + querySignal = sdkOptions?.abortController?.signal; + host.abort(); + yield result(); + })(), + ), + ); + expect(await runSdkSession({ ...options, externalAbortSignal: host.signal })).toBe(1); + expect(querySignal?.aborted).toBe(true); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + }); + + it("aborts a stalled query through the SDK controller and clears the watchdog", async () => { + vi.useFakeTimers(); + queryMock.mockImplementation(({ options: sdkOptions }) => + asQuery( + (async function* () { + yield { type: "system", subtype: "init" } as SDKMessage; + await new Promise((resolve) => { + sdkOptions?.abortController?.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }); + throw new Error("query aborted"); + })(), + ), + ); + const running = runSdkSession({ ...options, timeoutMs: 1000 }); + await vi.advanceTimersByTimeAsync(1000); + expect(await running).toBe(1); + expect(readFileSync(options.logPath, "utf8")).toContain("watchdog timeout"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cleans up timeout and cancellation hooks after a successful session", async () => { + vi.useFakeTimers(); + const host = new AbortController(); + const remove = vi.spyOn(host.signal, "removeEventListener"); + expect( + await runSdkSession({ ...options, externalAbortSignal: host.signal, timeoutMs: 1000 }), + ).toBe(0); + expect(remove).toHaveBeenCalledWith("abort", expect.any(Function)); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each(["error_during_execution", "error_max_turns"])( + "treats provider result %s as failure", + async (subtype) => { + queryMock.mockImplementation(() => + asQuery( + (async function* () { + yield result(subtype); + })(), + ), + ); + expect(await runSdkSession(options)).toBe(1); + }, + ); + + it("requires a terminal success result", async () => { + queryMock.mockImplementation(() => + asQuery( + (async function* () { + yield { type: "system", subtype: "init" } as SDKMessage; + })(), + ), + ); + expect(await runSdkSession(options)).toBe(1); + }); + + it("settles provider failures and redacts authorization and lease values from errors", async () => { + queryMock.mockImplementation(() => { + throw new Error(`MCP failed: ${options.switchyardToken} ${options.switchyardLeaseToken}`); + }); + expect(await runSdkSession(options)).toBe(1); + expect(readFileSync(options.logPath, "utf8")).toContain( + "session error: MCP failed: [redacted] [redacted]", + ); + }); + + it("still settles when the session log cannot be written", async () => { + expect(await runSdkSession({ ...options, logPath: directory })).toBe(0); + queryMock.mockImplementation(() => { + throw new Error("provider unavailable"); + }); + expect(await runSdkSession({ ...options, logPath: directory })).toBe(1); + }); +}); diff --git a/worker-sdk/sdk-runner.ts b/worker-sdk/sdk-runner.ts index a8f0f4f4..4d5adc13 100644 --- a/worker-sdk/sdk-runner.ts +++ b/worker-sdk/sdk-runner.ts @@ -9,7 +9,8 @@ // This file lives in worker-sdk/ (own package.json, own node_modules) because // the SDK peer-depends on zod@4 while the main app is pinned to zod@3. // agent-worker.ts imports it via a runtime-computed path so the main -// typecheck and the server Docker image never depend on it. Auth is the same +// app typecheck and server Docker image never depend on it. The isolated +// SDK typecheck is part of `npm run typecheck`. Auth is the same // CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY) from the environment. import { appendFileSync } from "node:fs"; @@ -26,6 +27,8 @@ export type SdkSessionOpts = { * argv, never in the LLM transcript. Omitted for non-lease (answer) sessions. */ switchyardLeaseToken?: string; allowedTools: string[]; + /** Optional provider model; omitted to retain the provider's default. */ + model?: string; logPath: string; /** Watchdog (SYD-115): abort the query if it runs longer than this. No timeout when omitted. */ timeoutMs?: number; @@ -43,6 +46,11 @@ export async function runSdkSession(o: SdkSessionOpts): Promise { // the worker relies on it settling to free the concurrency slot. const log = (line: string) => { try { + // Provider errors can echo request metadata. Never persist our MCP + // authorization or session lease even if it appears in an SDK event. + for (const secret of [o.switchyardToken, o.switchyardLeaseToken]) { + if (secret) line = line.replaceAll(secret, "[redacted]"); + } appendFileSync(o.logPath, `${line}\n`); } catch { /* log dir gone or disk full — the session matters more than the log */ @@ -57,9 +65,10 @@ export async function runSdkSession(o: SdkSessionOpts): Promise { const abortController = new AbortController(); // SYD-210 Layer B: fold the host's heartbeat-cancellation signal into the // query's own abort so a lease the worker can no longer renew stops the run. + const cancel = () => abortController.abort(); if (o.externalAbortSignal) { - if (o.externalAbortSignal.aborted) abortController.abort(); - else o.externalAbortSignal.addEventListener("abort", () => abortController.abort()); + if (o.externalAbortSignal.aborted) return 1; + o.externalAbortSignal.addEventListener("abort", cancel, { once: true }); } const watchdog = o.timeoutMs !== undefined @@ -75,6 +84,12 @@ export async function runSdkSession(o: SdkSessionOpts): Promise { prompt: o.prompt, options: { cwd: o.cwd, + // Match the CLI's coding-agent prompt and load the repository's + // CLAUDE.md, rules, skills and hooks. Exclude ambient user/local + // settings deliberately; this host runner still requires trust. + systemPrompt: { type: "preset", preset: "claude_code" }, + settingSources: ["project"], + ...(o.model ? { model: o.model } : {}), permissionMode: "acceptEdits", allowedTools: o.allowedTools, abortController, @@ -95,10 +110,11 @@ export async function runSdkSession(o: SdkSessionOpts): Promise { } } } catch (err) { - log(`[sdk] session error: ${(err as Error).message}`); + log(`[sdk] session error: ${err instanceof Error ? err.message : String(err)}`); exit = 1; } finally { if (watchdog) clearTimeout(watchdog); + o.externalAbortSignal?.removeEventListener("abort", cancel); } - return exit; + return abortController.signal.aborted ? 1 : exit; } diff --git a/worker-sdk/tsconfig.json b/worker-sdk/tsconfig.json new file mode 100644 index 00000000..e6f83d5e --- /dev/null +++ b/worker-sdk/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["*.ts"] +} From af6a15ed7d79b4f8553c132a9ab2d43e550309b0 Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:11:24 -0400 Subject: [PATCH 2/8] fix: honor worker queue order and reject unsupported engines (#266, #267) --- README.md | 24 ++++++++- scripts/agent-worker.ts | 10 +++- scripts/init-worker-lib.ts | 71 +++++++++++++++++--------- scripts/init-worker.ts | 32 +++++++++--- scripts/worker-select.ts | 47 +++-------------- src/services/dependencies.ts | 13 ++--- src/services/queue.ts | 23 +-------- src/services/task-order.ts | 73 ++++++++++++++++++++++++++ tests/init-worker-lib.test.ts | 53 +++++++++++++++---- tests/scripts/agent-worker.test.ts | 66 ++++++++++++++++++++++++ tests/scripts/worker-select.test.ts | 50 ++++++++++++++++++ tests/services/task-order.test.ts | 79 +++++++++++++++++++++++++++++ 12 files changed, 425 insertions(+), 116 deletions(-) create mode 100644 src/services/task-order.ts create mode 100644 tests/services/task-order.test.ts diff --git a/README.md b/README.md index 017a0e0c..d4b98633 100644 --- a/README.md +++ b/README.md @@ -254,8 +254,28 @@ do not require provider credentials. SDK behavior follows the official and [settings-source](https://code.claude.com/docs/en/agent-sdk/claude-code-features) documentation. -Registering non-Claude runners (Codex, Antigravity, Cursor) is being -researched in SYD-46. +`engine` selects Claude (the default), Codex, or Gemini. The supported modes are: + +| Engine | CLI code sessions | SDK code sessions | Process roles | +| --- | --- | --- | --- | +| Claude | Host or container | Host only | `code`, `answer`, `all` | +| Codex | Container only | Unsupported | `code` only | +| Gemini | Container only | Unsupported | `code` only | + +Answer sessions currently use Claude on the host, including when a Claude +worker's code sessions are containerized. Codex and Gemini workers must use +`runner: "cli"`, `containerized: true`, and **`--role code`**; the default +`all` role is rejected instead of silently using Claude to answer. Run a +separate Claude worker for answers. For example, copy the example config, +set `engine: "codex"` and `image: "switchyard-worker-codex"`, then launch: + +```bash +npx tsx scripts/agent-worker.ts --config switchyard-worker.codex.json --role code +``` + +For Gemini, use `engine: "gemini"` and `image: "switchyard-worker-gemini"` +with the same code role. When installing a non-Claude default config through +the doctor, use `npm run init-worker -- --install-launchd-code`. Safety model: the label gate (nothing runs unlabeled), `maxConcurrent` (caps concurrent headless sessions), and the fact that dispatched work still flows diff --git a/scripts/agent-worker.ts b/scripts/agent-worker.ts index 227e8d20..e9af07f1 100644 --- a/scripts/agent-worker.ts +++ b/scripts/agent-worker.ts @@ -61,7 +61,7 @@ import { import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseDotEnv, validateWorkerConfig } from "./init-worker-lib.js"; +import { assertWorkerExecution, parseDotEnv, validateWorkerConfig } from "./init-worker-lib.js"; import { selectDispatchable, filterRetryCapped, @@ -839,9 +839,10 @@ export function dispatch( if (opts.leaseToken) void releaseClaimHost(config, token, opts.leaseToken, issue.ref); }; try { + assertWorkerExecution(config, role); mkdirSync(logDir, { recursive: true }); } catch (err) { - releaseOnSetupFailure(err, "prepare the log dir"); + releaseOnSetupFailure(err, "prepare the session"); return; } const logPath = path.join(logDir, `${issue.ref}.log`); @@ -1191,6 +1192,7 @@ export function dispatchAnswer( token: string, opts: { dryRun: boolean }, ): void { + assertWorkerExecution(config, "answer"); const key = answerKey(ref); if (active.has(key)) return; if (remainingAnswerCapacity(config, active.keys()) <= 0) { @@ -1373,6 +1375,9 @@ export async function runTick( role: WorkerRole, opts: { dryRun: boolean }, ): Promise { + // Reject unsupported engines before polling or claiming any work, including + // callers that bypass loadConfig (tests, integrations, or a mutated config). + assertWorkerExecution(config, role); await runGated(tickGate, async () => { if (roleRunsCode(role)) { try { @@ -1767,6 +1772,7 @@ async function main(): Promise { // switchyard-worker.codex.json`) can name its own token env var (`token: // "SWITCHYARD_CODEX_TOKEN"`) — the secret stays in .env, never in the plist. const config = loadConfig(configPathFromArgs(args, defaultConfigPath(), repoRoot())); + assertWorkerExecution(config, role); const tokenVar = config.token ?? "SWITCHYARD_TOKEN"; const token = process.env[tokenVar]; if (!token) { diff --git a/scripts/init-worker-lib.ts b/scripts/init-worker-lib.ts index 8d4b7c8d..97e10faa 100644 --- a/scripts/init-worker-lib.ts +++ b/scripts/init-worker-lib.ts @@ -4,6 +4,53 @@ import type { WorkerConfig, WorkerRole, WorkerStackCli } from "./worker-select.js"; +/** Capabilities of the implemented runners, shared by doctor and dispatch. */ +export function validateWorkerExecution( + config: Pick, + role?: WorkerRole, +): string[] { + const problems: string[] = []; + const engine = config.engine ?? "claude"; + const runner = config.runner ?? "cli"; + if (config.containerized !== undefined && typeof config.containerized !== "boolean") { + problems.push("`containerized` must be true or false, not a string"); + } + if (runner !== "cli" && runner !== "sdk") { + problems.push('`runner` must be "cli" or "sdk"'); + } + if (engine !== "claude" && engine !== "codex" && engine !== "gemini") { + problems.push('`engine` must be "claude", "codex", or "gemini"'); + } + if (runner === "sdk" && config.containerized === true) { + problems.push( + '`runner: "sdk"` sessions run in-process on the host — remove `containerized: true` (container SDK image is not built yet)', + ); + } + if (engine === "codex" || engine === "gemini") { + if (runner !== "cli" || config.containerized !== true) { + problems.push( + `engine "${engine}" requires \`runner: "cli"\` and \`containerized: true\`; host CLI and SDK runners only support Claude`, + ); + } + if (role !== undefined && role !== "code") { + problems.push( + `engine "${engine}" does not support answer sessions; launch with --role code and use a separate Claude worker for answers`, + ); + } + } + if (engine === "codex" && config.egress === "open") { + problems.push('engine "codex" requires the injecting proxy — remove `egress: "open"`'); + } + return problems; +} + +export function assertWorkerExecution(config: WorkerConfig, role: WorkerRole): void { + const problems = validateWorkerExecution(config, role); + if (problems.length > 0) { + throw new Error(`unsupported worker execution:\n - ${problems.join("\n - ")}`); + } +} + /** * Minimal .env parser: KEY=VALUE lines, optional `export ` prefix, optional * single/double quotes around the value, `#` comments and blank lines skipped. @@ -43,9 +90,7 @@ export function validateWorkerConfig(raw: unknown): string[] { if (typeof c.url !== "string" || !/^https?:\/\/./.test(c.url)) { problems.push('`url` must be an http(s) URL, e.g. "http://100.85.158.109:3300"'); } - if (c.containerized !== undefined && typeof c.containerized !== "boolean") { - problems.push("`containerized` must be true or false, not a string"); - } + problems.push(...validateWorkerExecution(c)); if (c.egress !== undefined && c.egress !== "proxy" && c.egress !== "open") { problems.push('`egress` must be "proxy" or "open"'); } @@ -55,26 +100,6 @@ export function validateWorkerConfig(raw: unknown): string[] { ) { problems.push("`egressAllow` must be an array of hostnames"); } - const runner = c.runner ?? "cli"; - if (runner !== "cli" && runner !== "sdk") { - problems.push('`runner` must be "cli" or "sdk"'); - } - if (runner === "sdk" && c.containerized === true) { - problems.push( - '`runner: "sdk"` sessions run in-process on the host — remove `containerized: true` (container SDK image is not built yet)', - ); - } - if ( - c.engine !== undefined && - c.engine !== "claude" && - c.engine !== "codex" && - c.engine !== "gemini" - ) { - problems.push('`engine` must be "claude", "codex", or "gemini"'); - } - if (c.engine === "codex" && c.egress === "open") { - problems.push('engine "codex" requires the injecting proxy — remove `egress: "open"`'); - } if (c.token !== undefined && (typeof c.token !== "string" || c.token.length === 0)) { problems.push( "`token` must be a non-empty string (the NAME of the env var holding this worker's token)", diff --git a/scripts/init-worker.ts b/scripts/init-worker.ts index 207d0756..1c36a75d 100644 --- a/scripts/init-worker.ts +++ b/scripts/init-worker.ts @@ -93,6 +93,7 @@ import { suggestStackCli, summarizeRoleStatus, validateWorkerConfig, + assertWorkerExecution, workerLaunchdLabel, DELIVER_LAUNCHD_LABEL, POLL_LAUNCHD_LABEL, @@ -477,7 +478,14 @@ async function doctor(): Promise<{ results: CheckResult[]; config: WorkerConfig results.push({ name: "switchyard-worker.json", ok: false, note: problems.join("; ") }); } else { config = raw as WorkerConfig; - results.push({ name: "switchyard-worker.json", ok: true }); + results.push({ + name: "switchyard-worker.json", + ok: true, + note: + config.engine && config.engine !== "claude" + ? `${config.engine}: container CLI only; launch with --role code (or --install-launchd-code). Answers require a separate Claude worker.` + : undefined, + }); } } catch (err) { results.push({ @@ -1395,10 +1403,12 @@ function captureStack(config: WorkerConfig | null, onlyKey: string | undefined): } } -function selfTest(): void { +function selfTest(config: WorkerConfig | null): void { console.log("\nself-test: one dry-run worker tick (nothing is dispatched)\n"); const env = loadEnv(); - const run = spawnSync("npx", ["tsx", "scripts/agent-worker.ts", "--once", "--dry-run"], { + const args = ["tsx", "scripts/agent-worker.ts", "--once", "--dry-run"]; + if (config?.engine && config.engine !== "claude") args.push("--role", "code"); + const run = spawnSync("npx", args, { cwd: repoRoot, env, stdio: "inherit", @@ -1448,10 +1458,18 @@ async function main(): Promise { } console.log("\nall checks passed"); - if (args.includes("--self-test")) selfTest(); - if (args.includes("--install-launchd")) installLaunchd("all"); - if (args.includes("--install-launchd-code")) installLaunchd("code"); - if (args.includes("--install-launchd-answer")) installLaunchd("answer"); + if (args.includes("--self-test")) selfTest(config); + for (const [flag, role] of [ + ["--install-launchd", "all"], + ["--install-launchd-code", "code"], + ["--install-launchd-answer", "answer"], + ] as const) { + if (args.includes(flag)) { + if (!config) throw new Error("a valid worker config is required to install a worker"); + assertWorkerExecution(config, role); + installLaunchd(role); + } + } if (args.includes("--install-launchd-deliver")) installLaunchdDeliver(config); if (args.includes("--install-launchd-poll")) installLaunchdPoll(); diff --git a/scripts/worker-select.ts b/scripts/worker-select.ts index 4bf19d34..f46e00be 100644 --- a/scripts/worker-select.ts +++ b/scripts/worker-select.ts @@ -9,10 +9,14 @@ import { GEMINI_API_KEY_AUTH_TYPE, } from "./engines/gemini.js"; import { INTERACTIVE_PREFERENCE } from "../src/services/worker-preference.js"; +import { compareTaskOrder } from "../src/services/task-order.js"; /** The subset of an /api/issues row the selector needs. */ export type WorkerIssue = { + id?: number; ref: string; // "-" + /** Explicit board order comes before engine affinity, priority, and age. */ + queueRank?: number | null; labels: string[]; assigneeId: number | null; needsInput: boolean; @@ -35,8 +39,7 @@ export type WorkerIssue = { blocked?: boolean; /** * The issue's priority, used to order candidates so dispatch honors priority - * (SYD-160) — mirrors next_task's PRIORITY_RANK. Optional/unknown values sort - * last (see priorityRank). + * (SYD-160). Optional/unknown values sort last in the shared task order. */ priority?: string; /** Creation timestamp, the oldest-first tiebreak within a priority (SYD-160). */ @@ -51,17 +54,6 @@ export type WorkerIssue = { workerPreference?: string | null; }; -/** - * Priority ordering for dispatch selection (SYD-160), mirroring the SQL - * PRIORITY_RANK in src/services/dependencies.ts: urgent first, then high, - * medium, low, and anything unset/unknown last. - */ -const PRIORITY_RANK: Record = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }; - -function priorityRank(priority: string | undefined): number { - return priority !== undefined && priority in PRIORITY_RANK ? PRIORITY_RANK[priority] : 4; -} - /** One extra CLI tool a project's dispatched sessions need beyond the baseline (git, node, claude). */ export type WorkerStackCli = { /** Human-readable name, shown in doctor output and session-start failures. */ @@ -379,9 +371,8 @@ export function checkRoleLockConflict( * alongside claimIssue's own check, for a claim that was released back to todo * while its PR is still open), isn't already running, and fits within remaining * maxConcurrent capacity (existing active dispatches + newly selected <= - * maxConcurrent). Candidates are considered highest-priority-first, then - * oldest-first within a priority (SYD-160), so capacity is filled by priority - * rather than by the feed's arrival order. + * maxConcurrent). Candidates follow the same manual queue, affinity, priority, + * and age order as nextTask, independently of the feed's arrival order. */ // Defined in src/services/worker-preference.ts (a leaf module, no imports) and // re-exported here so this file's existing importers are unaffected. The server @@ -416,29 +407,7 @@ export function selectDispatchable( } } - // Soft routing (SYD-201): this worker's classification is its engine. An - // issue matching it sorts first, neutral (no preference) next, another - // classification's last — ahead of priority, so each worker prefers its own - // but (since nothing is excluded below) still falls back to foreign-preferred - // work when it's all that's left. No preference set anywhere => all neutral, - // i.e. today's behavior unchanged. - const classification = config.engine ?? "claude"; - const affinity = (issue: T): number => { - const pref = issue.workerPreference; - if (pref == null) return 1; // neutral - return pref === classification ? 0 : 2; // match : foreign - }; - - // Then mirror next_task's (PRIORITY_RANK, createdAt) ordering so dispatch - // honors priority regardless of the order /api/issues returned rows in - // (desc(id), i.e. newest-first). Array.sort is stable, so equal keys keep feed order. - const ordered = [...issues].sort((a, b) => { - const byAffinity = affinity(a) - affinity(b); - if (byAffinity !== 0) return byAffinity; - const byPriority = priorityRank(a.priority) - priorityRank(b.priority); - if (byPriority !== 0) return byPriority; - return (a.createdAt ?? 0) - (b.createdAt ?? 0); - }); + const ordered = [...issues].sort(compareTaskOrder(config.engine ?? "claude")); const selected: T[] = []; for (const issue of ordered) { diff --git a/src/services/dependencies.ts b/src/services/dependencies.ts index 77647caa..e560dd60 100644 --- a/src/services/dependencies.ts +++ b/src/services/dependencies.ts @@ -11,7 +11,7 @@ import { recordEvent } from "./events.js"; import { listOpenPrByIssueId } from "./pr-status.js"; import { EXECUTABLE_GATE_ACTIONS, findOrCreatePendingAction, isHardGated } from "./hard-gate.js"; import { getSetting } from "./settings.js"; -import { affinityRank, QUEUE_RANK_ORDER } from "./queue.js"; +import { taskOrderSql } from "./task-order.js"; import { issueIdsCondition } from "./issue-scope.js"; import { callerClassification, @@ -20,8 +20,6 @@ import { } from "./worker-preference.js"; const CLOSED = ["done", "canceled"] as const; -const PRIORITY_RANK = sql`CASE ${issues.priority} - WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`; export function addDependency( db: Db, @@ -287,7 +285,7 @@ export function nextTask(db: Db, actor: Actor, projectKey?: string): IssueView | // headless worker cannot finish this" (SYD-239), and worker-select.ts already // skips it at dispatch — but nextTask ignored it entirely, so an agent asking // for work directly could still be handed one. Everything else about - // worker_preference stays soft, sorted by affinityRank below. + // worker_preference stays soft, sorted by taskOrderSql below. // Keyed on "is a person watching", not "is this a person" — an attended // agent session is the caller this work is FOR (see isAttendedCaller). if (!isAttendedCaller(actor)) { @@ -306,12 +304,7 @@ export function nextTask(db: Db, actor: Actor, projectKey?: string): IssueView | .select() .from(issues) .where(and(...conditions)) - .orderBy( - QUEUE_RANK_ORDER, - affinityRank(callerClassification(actor)), - PRIORITY_RANK, - issues.createdAt, - ) + .orderBy(...taskOrderSql(callerClassification(actor))) .limit(1) .all(); return candidates[0] ? toView(db, candidates[0]) : null; diff --git a/src/services/queue.ts b/src/services/queue.ts index 4257e1f6..7e4bc891 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -26,7 +26,7 @@ // agent from recording the order a review just produced. The move is recorded // as an event either way, so who reordered what is always answerable. -import { asc, eq, isNotNull, sql } from "drizzle-orm"; +import { asc, eq, isNotNull } from "drizzle-orm"; import type { Db, DbOrTx } from "../db/index.js"; import { issues } from "../db/schema.js"; import type { Actor } from "./actors.js"; @@ -114,24 +114,3 @@ export function setQueuePosition( return listQueue(tx); }); } - -/** - * Sort key placing issues the caller's own classification prefers ahead of - * unclaimed ones, and issues preferred by some OTHER worker last. - * - * Soft by design (SYD-201: worker_preference "never restricts — an idle worker - * still falls back to it"), so this only ever reorders; the one hard rule - * lives in nextTask, where a non-human is refused `interactive` issues - * outright. Because ranks are a total order, this can only break ties among - * UNRANKED issues — an explicit human ordering outranks affinity, which is the - * point of having one. - */ -export function affinityRank(classification: string) { - return sql`CASE - WHEN ${issues.workerPreference} IS NULL THEN 1 - WHEN ${issues.workerPreference} = ${classification} THEN 0 - ELSE 2 END`; -} - -/** NULL ranks sort last, spelled portably rather than relying on NULLS LAST. */ -export const QUEUE_RANK_ORDER = sql`CASE WHEN ${issues.queueRank} IS NULL THEN 1 ELSE 0 END, ${issues.queueRank}`; diff --git a/src/services/task-order.ts b/src/services/task-order.ts new file mode 100644 index 00000000..747186e8 --- /dev/null +++ b/src/services/task-order.ts @@ -0,0 +1,73 @@ +import { sql, type SQL } from "drizzle-orm"; +import { issues } from "../db/schema.js"; + +/** Fields shared by the ready-issues feed and the server's issue rows. */ +type OrderedTask = { + id?: number; + queueRank?: number | null; + workerPreference?: string | null; + priority?: string; + createdAt?: number; +}; + +const PRIORITY_RANK: Record = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }; + +/** + * One ordered definition for nextTask's SQL and the worker's in-memory sort. + * Ranked work always comes first; affinity only applies to unranked work. + * The id tiebreak keeps equal timestamps independent of feed arrival order. + */ +function taskOrder(classification: string): { value: (task: OrderedTask) => number; sql: SQL }[] { + return [ + { + value: (task) => (task.queueRank == null ? 1 : 0), + sql: sql`CASE WHEN ${issues.queueRank} IS NULL THEN 1 ELSE 0 END`, + }, + { + value: (task) => task.queueRank ?? 0, + sql: sql`coalesce(${issues.queueRank}, 0)`, + }, + { + value: (task) => + task.queueRank != null + ? 0 + : task.workerPreference == null + ? 1 + : task.workerPreference === classification + ? 0 + : 2, + sql: sql`CASE + WHEN ${issues.queueRank} IS NOT NULL THEN 0 + WHEN ${issues.workerPreference} IS NULL THEN 1 + WHEN ${issues.workerPreference} = ${classification} THEN 0 + ELSE 2 END`, + }, + { + value: (task) => + task.priority !== undefined && Object.hasOwn(PRIORITY_RANK, task.priority) + ? PRIORITY_RANK[task.priority] + : 4, + sql: sql`CASE ${issues.priority} ${sql.join( + Object.entries(PRIORITY_RANK).map(([priority, rank]) => sql`WHEN ${priority} THEN ${rank}`), + sql` `, + )} ELSE 4 END`, + }, + { value: (task) => task.createdAt ?? 0, sql: sql`${issues.createdAt}` }, + { value: (task) => task.id ?? 0, sql: sql`${issues.id}` }, + ]; +} + +export function taskOrderSql(classification: string): SQL[] { + return taskOrder(classification).map((key) => key.sql); +} + +export function compareTaskOrder(classification: string) { + const keys = taskOrder(classification); + return (a: OrderedTask, b: OrderedTask): number => { + for (const key of keys) { + const difference = key.value(a) - key.value(b); + if (difference !== 0) return difference; + } + return 0; + }; +} diff --git a/tests/init-worker-lib.test.ts b/tests/init-worker-lib.test.ts index 27238788..82ef5fd2 100644 --- a/tests/init-worker-lib.test.ts +++ b/tests/init-worker-lib.test.ts @@ -27,6 +27,7 @@ import { suggestStackCli, summarizeRoleStatus, validateWorkerConfig, + validateWorkerExecution, wellKnownCliInstall, workerLaunchdLabel, type UserStackCapture, @@ -208,22 +209,52 @@ describe("validateWorkerConfig", () => { expect(problems.join(" ")).toMatch(/engine/); }); - it("accepts engine: codex", () => { - expect(validateWorkerConfig({ ...good, engine: "codex" })).toEqual([]); - }); - - it("accepts engine: gemini, including with egress: open (its open mode passes the real key)", () => { - expect(validateWorkerConfig({ ...good, engine: "gemini" })).toEqual([]); - expect(validateWorkerConfig({ ...good, engine: "gemini", egress: "open" })).toEqual([]); + for (const engine of [undefined, "claude", "codex", "gemini"] as const) { + for (const runner of [undefined, "cli", "sdk"] as const) { + for (const containerized of [undefined, false, true]) { + const supported = + engine === undefined || engine === "claude" + ? runner !== "sdk" || containerized !== true + : runner !== "sdk" && containerized === true; + it(`${supported ? "accepts" : "rejects"} engine=${engine}, runner=${runner}, containerized=${containerized}`, () => { + const problems = validateWorkerConfig({ ...good, engine, runner, containerized }); + expect(problems.length === 0).toBe(supported); + }); + } + } + } + + it("accepts Gemini container CLI with open egress (its open mode passes the real key)", () => { + expect( + validateWorkerConfig({ ...good, engine: "gemini", containerized: true, egress: "open" }), + ).toEqual([]); }); it("rejects engine: codex with egress: open, and still accepts codex with the default proxy egress", () => { - const problems = validateWorkerConfig({ ...good, engine: "codex", egress: "open" }); + const problems = validateWorkerConfig({ + ...good, + engine: "codex", + containerized: true, + egress: "open", + }); expect(problems.join(" ")).toMatch(/codex/); expect(problems.join(" ")).toMatch(/egress|open|proxy/); - expect(validateWorkerConfig({ ...good, engine: "codex", egress: "proxy" })).toEqual([]); - expect(validateWorkerConfig({ ...good, engine: "codex" })).toEqual([]); - }); + expect( + validateWorkerConfig({ ...good, engine: "codex", containerized: true, egress: "proxy" }), + ).toEqual([]); + expect(validateWorkerConfig({ ...good, engine: "codex", containerized: true })).toEqual([]); + }); + + it.each(["codex", "gemini"] as const)( + "requires the code role for %s instead of silently answering with Claude", + (engine) => { + const config = { ...good, engine, containerized: true }; + expect(validateWorkerExecution(config, "code")).toEqual([]); + for (const role of ["answer", "all"] as const) { + expect(validateWorkerExecution(config, role).join(" ")).toMatch(/--role code/); + } + }, + ); it("accepts a string token env-var name and rejects a non-string / empty one", () => { expect(validateWorkerConfig({ ...good, token: "SWITCHYARD_CODEX_TOKEN" })).toEqual([]); diff --git a/tests/scripts/agent-worker.test.ts b/tests/scripts/agent-worker.test.ts index ff6538d4..e2e88a4e 100644 --- a/tests/scripts/agent-worker.test.ts +++ b/tests/scripts/agent-worker.test.ts @@ -636,6 +636,72 @@ describe("host-side pre-claim before dispatch (SYD-122)", () => { afterEach(() => vi.unstubAllGlobals()); + it.each([ + { engine: "codex", runner: "sdk", containerized: false }, + { engine: "gemini", runner: "cli", containerized: false }, + { engine: "claude", runner: "sdk", containerized: true }, + ] as const)( + "rejects unsupported execution before fetching or claiming: %j", + async (execution) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect( + runTick({ ...config, ...execution }, "tok", "code", { dryRun: false }), + ).rejects.toThrow(/unsupported worker execution/); + expect(fetchMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["codex", "gemini"] as const)( + "does not launch Claude answers for a %s worker", + async (engine) => { + const unsupported = { ...config, engine, containerized: true }; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + await expect(runTick(unsupported, "tok", "all", { dryRun: false })).rejects.toThrow( + /--role code/, + ); + expect(() => dispatchAnswer("SYD-267", unsupported, "tok", { dryRun: false })).toThrow( + /--role code/, + ); + expect(fetchMock).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + expect(answerState.has("SYD-267")).toBe(false); + }, + ); + + it("releases an existing lease if direct dispatch receives an unsupported configuration", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const fetchMock = vi.fn(async () => ({ ok: true })); + vi.stubGlobal("fetch", fetchMock); + dispatch( + { + ref: "SYD-267", + title: "wrong engine", + labels: ["auto"], + assigneeId: null, + needsInput: false, + updatedAt: 1, + }, + { ...config, engine: "codex", runner: "sdk" }, + "tok", + "code", + { leaseToken: "lease_267" }, + ); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:3300/api/issues/SYD-267", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ "X-Switchyard-Lease": "lease_267" }), + body: JSON.stringify({ status: "todo" }), + }), + ); + expect(spawnMock).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + it("claims the issue host-side and dispatches once the claim succeeds", async () => { const ref = "SYD-122a"; const issue = { diff --git a/tests/scripts/worker-select.test.ts b/tests/scripts/worker-select.test.ts index 20ef9c5d..7a58ff4e 100644 --- a/tests/scripts/worker-select.test.ts +++ b/tests/scripts/worker-select.test.ts @@ -82,6 +82,56 @@ const issue = (overrides: Partial): WorkerIssue => ({ }); describe("selectDispatchable", () => { + it("fills capacity in manual queue order before affinity, priority, or age (#266)", () => { + const candidates = [ + issue({ + ref: "SYD-1", + queueRank: null, + priority: "urgent", + workerPreference: "claude", + createdAt: 1, + }), + issue({ + ref: "SYD-2", + queueRank: 200, + priority: "urgent", + workerPreference: "claude", + createdAt: 2, + }), + issue({ + ref: "SYD-3", + queueRank: 100, + priority: "low", + workerPreference: "codex", + createdAt: 3, + }), + ]; + expect(selectDispatchable(candidates, config, []).map((i) => i.ref)).toEqual([ + "SYD-3", + "SYD-2", + ]); + }); + + it("skips ineligible ranked work and keeps label, project, and active-capacity gates (#266)", () => { + const candidates = [ + issue({ ref: "SYD-1", queueRank: 100, blocked: true }), + issue({ ref: "SYD-2", queueRank: 200, labels: [] }), + issue({ ref: "OTHER-1", queueRank: 300 }), + issue({ ref: "SYD-3", queueRank: 400, workerPreference: "interactive" }), + issue({ ref: "SYD-4", queueRank: 500, assigneeId: 1 }), + issue({ ref: "SYD-5", queueRank: 600, needsInput: true }), + issue({ + ref: "SYD-6", + queueRank: 700, + openPr: { prNumber: 1, url: "https://example.com/pr/1" }, + }), + issue({ ref: "SYD-7", queueRank: 800 }), + issue({ ref: "SYD-8", queueRank: 900 }), + issue({ ref: "SYD-9", priority: "urgent" }), + ]; + expect(selectDispatchable(candidates, config, ["SYD-7"]).map((i) => i.ref)).toEqual(["SYD-8"]); + }); + it("suppresses a full project's work, logs once, and resumes below the limit", () => { const issues = [issue({ ref: "SYD-1" }), issue({ ref: "AIPI-1" })]; const limited = { diff --git a/tests/services/task-order.test.ts b/tests/services/task-order.test.ts new file mode 100644 index 00000000..138d6a16 --- /dev/null +++ b/tests/services/task-order.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { openDb } from "../../src/db/index.js"; +import { createActor } from "../../src/services/actors.js"; +import { createProject } from "../../src/services/projects.js"; +import { createIssue, updateIssue, type CreateIssueInput } from "../../src/services/issues.js"; +import { addDependency, listBlockedIssueIds, nextTask } from "../../src/services/dependencies.js"; +import { setQueuePosition } from "../../src/services/queue.js"; +import { searchIssues } from "../../src/services/search.js"; +import { selectDispatchable, type WorkerConfig } from "../../scripts/worker-select.js"; + +describe("server and worker task ordering (#266)", () => { + it.each(["claude", "codex", "gemini"] as const)( + "walks the same eligible queue for %s, including unranked affinity and equal timestamps", + (engine) => { + const db = openDb(":memory:"); + const human = createActor(db, { name: "sean", type: "human" }).actor; + const worker = createActor(db, { name: `${engine}/dev`, type: "agent" }).actor; + createProject(db, human, { key: "SYD", name: "Switchyard" }); + const todo = (title: string, patch: Partial = {}) => { + const issue = createIssue(db, human, { + projectKey: "SYD", + title, + labels: ["auto"], + ...patch, + }); + updateIssue(db, human, issue.ref, { status: "todo" }); + return issue.ref; + }; + const unrankedUrgent = todo("neutral urgent", { priority: "urgent" }); + todo("oldest neutral high", { priority: "high" }); + todo("newer neutral high", { priority: "high" }); + todo("preferred low", { priority: "low", workerPreference: engine }); + todo("foreign urgent", { priority: "urgent", workerPreference: "other" }); + const queuedLow = todo("ranked low foreign", { priority: "low", workerPreference: "other" }); + const queuedUrgent = todo("ranked urgent matching", { + priority: "urgent", + workerPreference: engine, + }); + const blocked = todo("blocked rank one"); + addDependency(db, human, unrankedUrgent, blocked); + const interactive = todo("interactive rank two", { workerPreference: "interactive" }); + for (const [index, ref] of [blocked, interactive, queuedLow, queuedUrgent].entries()) { + setQueuePosition(db, human, ref, { position: index + 1 }); + } + const config: WorkerConfig = { + url: "http://localhost:3300", + label: "auto", + engine, + intervalSeconds: 300, + maxConcurrent: 50, + projects: { SYD: { repo: "/repo" } }, + }; + const feed = () => { + const blockedIds = listBlockedIssueIds(db); + return searchIssues(db, { projectKey: "SYD", status: "todo" }).map((issue) => ({ + ...issue, + blocked: blockedIds.has(issue.id), + })); + }; + expect( + selectDispatchable(feed(), config, []) + .slice(0, 2) + .map((issue) => issue.ref), + ).toEqual([queuedLow, queuedUrgent]); + // Re-read after each completion: completing the urgent blocker makes + // the previously ineligible rank-one issue eligible on the next tick. + let completed = 0; + for (;;) { + const serverNext = nextTask(db, worker, "SYD"); + const workerNext = selectDispatchable(feed(), { ...config, maxConcurrent: 1 }, [])[0]; + expect(workerNext?.ref ?? null).toBe(serverNext?.ref ?? null); + if (!serverNext) break; + updateIssue(db, human, serverNext.ref, { status: "done" }); + expect(++completed).toBeLessThan(20); + } + expect(completed).toBe(8); + }, + ); +}); From 5c31697797bdf2f8e8a9a92f3568d26eabf9c94e Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:12:07 -0400 Subject: [PATCH 3/8] fix: refresh worker CLI and proxy dependencies (#268, #269) --- .github/workflows/worker-images.yml | 103 ++++++++++++++++++++++ Dockerfile.egress-proxy | 2 +- Dockerfile.worker | 2 +- Dockerfile.worker.codex | 2 +- Dockerfile.worker.gemini | 6 +- README.md | 15 +++- docs/worker-upgrades.md | 96 ++++++++++++++++++++ scripts/container-entry.codex.sh | 4 +- scripts/egress-inject-addon.py | 31 +++++-- scripts/engines/codex.ts | 4 +- scripts/init-worker-lib.ts | 7 +- switchyard-worker.example.json | 2 +- tests/init-worker-lib.test.ts | 14 +-- tests/scripts/codex-entry-config.test.ts | 74 ++++++++++++++++ tests/scripts/dockerfile-worker.test.ts | 15 ++++ tests/scripts/egress-inject-addon.test.ts | 91 +++++++++++++++++++ tests/scripts/egress-proxy-contract.py | 102 +++++++++++++++++++++ tests/scripts/engines/codex.test.ts | 3 +- 18 files changed, 544 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/worker-images.yml create mode 100644 docs/worker-upgrades.md create mode 100644 tests/scripts/codex-entry-config.test.ts create mode 100644 tests/scripts/egress-proxy-contract.py diff --git a/.github/workflows/worker-images.yml b/.github/workflows/worker-images.yml new file mode 100644 index 00000000..ca6fb2e7 --- /dev/null +++ b/.github/workflows/worker-images.yml @@ -0,0 +1,103 @@ +name: Worker images + +on: + pull_request: + paths: + - "Dockerfile.worker*" + - "Dockerfile.egress-proxy" + - ".dockerignore" + - "scripts/container-entry*.sh" + - "scripts/egress-*" + - "scripts/worker-engine-runner.mjs" + - "scripts/worker-telemetry.mjs" + - "scripts/prime-workspace-trust.mjs" + - "scripts/install-guard.mjs" + - "scripts/attach.mjs" + - "tests/scripts/egress-proxy-contract.py" + - ".github/workflows/worker-images.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + name: worker-image (${{ matrix.engine }}) + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - engine: claude + dockerfile: Dockerfile.worker + binary: claude + - engine: codex + dockerfile: Dockerfile.worker.codex + binary: codex + - engine: gemini + dockerfile: Dockerfile.worker.gemini + binary: gemini + - engine: proxy + dockerfile: Dockerfile.egress-proxy + binary: mitmdump + env: + IMAGE: switchyard-smoke:${{ matrix.engine }} + DOCKERFILE: ${{ matrix.dockerfile }} + BINARY: ${{ matrix.binary }} + ENGINE: ${{ matrix.engine }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Build image + run: docker build --pull -f "$DOCKERFILE" -t "$IMAGE" . + - name: Check binary and supported headless flags without network or credentials + shell: bash + run: | + set -euo pipefail + version=$(docker run --rm --network none --entrypoint "$BINARY" "$IMAGE" --version) + printf '%s\n' "$version" + if [ "$ENGINE" = proxy ]; then + expected=$(sed -n 's/^FROM mitmproxy\/mitmproxy://p' "$DOCKERFILE") + else + expected=$(sed -n 's/^ARG \(CLAUDE_CODE\|CODEX_CLI\|GEMINI_CLI\)_VERSION=//p' "$DOCKERFILE") + fi + test -n "$expected" + [[ "$version" == *"$expected"* ]] + case "$ENGINE" in + claude) + docker run --rm --network none --entrypoint claude "$IMAGE" --help > /tmp/worker-help + grep -F -- --output-format /tmp/worker-help + grep -F -- --model /tmp/worker-help + ;; + codex) + docker run --rm --network none --entrypoint codex "$IMAGE" exec --help > /tmp/worker-help + grep -F -- --json /tmp/worker-help + grep -F -- --dangerously-bypass-approvals-and-sandbox /tmp/worker-help + ;; + gemini) + docker run --rm --network none --entrypoint gemini "$IMAGE" --help > /tmp/worker-help + grep -F -- --output-format /tmp/worker-help + grep -F -- --approval-mode /tmp/worker-help + ;; + proxy) + docker run --rm --network none --entrypoint python3 \ + -e PYTHONDONTWRITEBYTECODE=1 -v "$PWD:/review:ro" "$IMAGE" \ + /review/tests/scripts/egress-proxy-contract.py + # Load the actual addon and start the actual entrypoint. No ports + # are published and network=none prevents any provider request. + docker run -d --name proxy-smoke --network none \ + -e ALLOWED_DOMAINS=registry.npmjs.org "$IMAGE" + trap 'docker logs proxy-smoke; docker rm -f proxy-smoke >/dev/null' EXIT + for attempt in {1..30}; do + test "$(docker inspect -f '{{.State.Running}}' proxy-smoke)" = true + if docker exec proxy-smoke python3 -c \ + 'import socket; socket.create_connection(("127.0.0.1", 8888), timeout=1).close()'; then + exit 0 + fi + sleep 1 + done + exit 1 + ;; + esac diff --git a/Dockerfile.egress-proxy b/Dockerfile.egress-proxy index 4b036a6a..c34f5d79 100644 --- a/Dockerfile.egress-proxy +++ b/Dockerfile.egress-proxy @@ -14,7 +14,7 @@ # NOTE: confirm the mitmproxy tag exists on Docker Hub at build time and bump as # releases land — pinned for reproducibility (matches the alpine pin the old # image used). -FROM mitmproxy/mitmproxy:11.1.3 +FROM mitmproxy/mitmproxy:12.2.3 # The base image runs as the non-root `mitmproxy` user (uid 1000, home # /home/mitmproxy). Become root only to place files + prep the CA dir, then diff --git a/Dockerfile.worker b/Dockerfile.worker index 71442eb8..758a0b6a 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -15,7 +15,7 @@ RUN apt-get update \ # @anthropic-ai/claude-code` would silently pick up whatever is newest at build # time. Bump via `--build-arg CLAUDE_CODE_VERSION=x.y.z` or by editing the # default below; see README's containerized-mode section for the update steps. -ARG CLAUDE_CODE_VERSION=2.1.220 +ARG CLAUDE_CODE_VERSION=2.1.283 RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ && claude --version diff --git a/Dockerfile.worker.codex b/Dockerfile.worker.codex index 68d0d72e..880d79b1 100644 --- a/Dockerfile.worker.codex +++ b/Dockerfile.worker.codex @@ -21,7 +21,7 @@ RUN apt-get update \ # @openai/codex` would silently pick up whatever is newest at build # time. Bump via `--build-arg CODEX_CLI_VERSION=x.y.z` or by editing the # default below; see README's containerized-mode section for the update steps. -ARG CODEX_CLI_VERSION=0.145.0 +ARG CODEX_CLI_VERSION=0.157.1 RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} \ && codex --version diff --git a/Dockerfile.worker.gemini b/Dockerfile.worker.gemini index 6052270d..f07d31b0 100644 --- a/Dockerfile.worker.gemini +++ b/Dockerfile.worker.gemini @@ -19,7 +19,11 @@ RUN apt-get update \ && apt-get install -y --no-install-recommends git ca-certificates python3 make g++ \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g @google/gemini-cli +# Match the other worker engines: upgrades are explicit and the installed +# version is recorded in build output, never selected by npm's latest tag. +ARG GEMINI_CLI_VERSION=0.61.0 +RUN npm install -g @google/gemini-cli@${GEMINI_CLI_VERSION} \ + && gemini --version # SYD-253: pnpm for target repos that commit pnpm-lock.yaml (yarn is bundled # in the base image; the --version call asserts it) -- see the matching block diff --git a/README.md b/README.md index d4b98633..02a1803b 100644 --- a/README.md +++ b/README.md @@ -303,9 +303,11 @@ branch, and pushes the branch back — it is structurally unable to touch your host filesystem or push to `main`. This is the recommended default; the bare mode above stays available for repos or setups where Docker isn't practical. -Build the worker image once (rebuild after changing `scripts/container-entry.sh`). -The CLI versions are pinned in their respective Dockerfiles — to upgrade, bump -their defaults (or override via build arguments) and rebuild. +Build the worker images before dispatching (rebuild after changing their +entrypoints or helpers). Claude, Codex, and Gemini CLI versions are pinned in +their respective Dockerfiles. Upgrade the defaults and matching doctor install +advice together, then rebuild and smoke-test; the complete procedure and proxy +rollout steps are in [Worker dependency upgrades](docs/worker-upgrades.md). For the Anthropic/Claude worker: ```bash @@ -321,6 +323,13 @@ npm run build:worker-image-codex docker build -f Dockerfile.worker.codex --build-arg CODEX_CLI_VERSION=x.y.z -t switchyard-worker-codex . ``` +For the Gemini worker: +```bash +npm run build:worker-image-gemini +# or, to use a different version without editing the Dockerfile: +docker build -f Dockerfile.worker.gemini --build-arg GEMINI_CLI_VERSION=x.y.z -t switchyard-worker-gemini . +``` + Set `containerized: true` in `switchyard-worker.json` (and optionally `image` if you're using something other than the default `switchyard-worker` tag), and make sure the worker process's environment has one of: diff --git a/docs/worker-upgrades.md b/docs/worker-upgrades.md new file mode 100644 index 00000000..42c0e5ab --- /dev/null +++ b/docs/worker-upgrades.md @@ -0,0 +1,96 @@ +# Worker dependency upgrades + +Worker images use explicit provider CLI versions. Updating a Dockerfile does +not update existing local images or running containers. Node remains on the +supported 24 LTS major; its patch release and Debian packages are selected at +build time, so these are version-pinned CLIs, not byte-for-byte reproducible +images. Record image IDs/digests with deployment records, and rebuild with +`--pull` to receive base-image security updates. + +The defaults reviewed on 2026-09-28 are: + +| Component | Pin | Primary source | +| --- | --- | --- | +| Claude Code | 2.1.283 | [Release](https://github.com/anthropics/claude-code/releases/tag/v2.1.283) | +| Codex CLI | 0.157.1 | [Release](https://github.com/openai/codex/releases/tag/rust-v0.157.1) | +| Gemini CLI | 0.61.0 | [Release](https://github.com/google-gemini/gemini-cli/releases/tag/v0.61.0) | +| mitmproxy | 12.2.3 | [Changelog](https://github.com/mitmproxy/mitmproxy/blob/v12.2.3/CHANGELOG.md) | +| Node | 24 LTS | [Release lifecycle](https://nodejs.org/en/about/previous-releases) | + +The proxy upgrade includes the upstream hyper-h2 fix for HTTP/2-to-HTTP/1 +request smuggling released in mitmproxy 12.1.2. This is a dependency maintenance +reason, not a claim that an exploit was demonstrated against Switchyard. + +## Review and rebuild + +1. Read the stable release notes, especially CLI flags, auth storage, MCP + headers, output schemas, and proxy API changes. Avoid prerelease tags. +2. Change the corresponding Dockerfile default. For Codex/Gemini, update + `WELL_KNOWN_CLI_INSTALL` in `scripts/init-worker-lib.ts` too; a regression + check prevents doctor repair advice from drifting from the image pins. +3. Run the repository checks and the `Worker images` GitHub Actions workflow. + That workflow builds all four images, checks exact installed CLI versions + and headless flags, exercises proxy hooks using real mitmproxy objects, and + starts the proxy entrypoint. Runtime smoke containers use `--network none` + and receive no provider or deployment credentials. It does not run an agent + turn or establish a real upstream TLS connection. +4. On the worker host, build every engine in use and the shared proxy: + + ```sh + docker build --pull -f Dockerfile.worker -t switchyard-worker . + docker build --pull -f Dockerfile.worker.codex -t switchyard-worker-codex . + docker build --pull -f Dockerfile.worker.gemini -t switchyard-worker-gemini . + docker build --pull -f Dockerfile.egress-proxy -t switchyard-egress-proxy . + ``` + + Build arguments `CLAUDE_CODE_VERSION`, `CODEX_CLI_VERSION`, and + `GEMINI_CLI_VERSION` allow a candidate version to be tried under a separate + image tag before changing defaults. Preserve the previous image IDs/tags + for rollback. + +## Roll out on the worker host + +Pause new dispatches and let active sessions finish before replacing the +shared egress sidecar: it carries every engine's live connections. After +rebuilding, remove the stopped `syd-egress` container and restart the worker +processes so `ensureEgressGuard` recreates it from the new image. Keep the +`syd-egress-ca` volume; deleting it would rotate the CA unexpectedly. A rebuilt +image alone does not force an existing sidecar to reload. + +Run `npm run init-worker -- --self-test` with each engine's normal configuration, +then do a controlled task using that engine. Check MCP authentication and the +`X-Switchyard-Lease` header, incremental model output, cancellation, commit +import, and host-side PR publication. Confirm the image ID actually running. +For the proxy, verify allowed dependency installs and provider traffic, a denied +destination, and provider streaming through TLS. Restore the previous tags +and recreate the sidecar if the checks fail; retain the CA volume on rollback. + +## Codex auth compatibility boundary + +The Codex container holds a dummy, JWT-shaped `auth.json` with the non-secret +account ID. The real ChatGPT OAuth bearer remains in the proxy and replaces +the placeholder on requests to `chatgpt.com`. This handwritten file is an +integration dependency, not a documented stable provider API. + +For 0.157.1, the upstream +[auth manager](https://github.com/openai/codex/blob/rust-v0.157.1/codex-rs/login/src/auth/manager.rs) +still accepts token storage and checks a parseable access-token expiry before +falling back to `last_refresh`. The placeholder's future expiry therefore avoids +proactive refresh by this code path; a real expired/revoked proxy token can +still fail and requires host-side renewal. No real credentials belong in a +fixture or smoke workflow. Recheck this source contract on every Codex upgrade +and retain the authenticated task check above before production rollout. + +Codex `exec --dangerously-bypass-approvals-and-sandbox` is used only inside +the disposable worker container. It is not a host-runner recommendation, and +does not imply the separate `--ask-for-approval` CLI option was removed. + +## Proxy behavior + +Credential injection requires HTTPS and agreement between the request target +and Host header. Provider requests over plain HTTP are denied even if the +provider is in `ALLOWED_DOMAINS`. Redirects are returned to the client; each new +request is checked again. Provider `text/event-stream` responses stream at the +response-header hook so long turns are not buffered until completion. Request +bodies remain buffered so destination validation and injection happen before +upstream transmission. See the upstream [hook lifecycle](https://docs.mitmproxy.org/stable/api/events.html). diff --git a/scripts/container-entry.codex.sh b/scripts/container-entry.codex.sh index b04ef9cb..ea09a9be 100755 --- a/scripts/container-entry.codex.sh +++ b/scripts/container-entry.codex.sh @@ -138,8 +138,8 @@ chmod 600 "$CODEX_HOME/auth.json" # The container is the sandbox here, not codex's own approval/sandbox layer # -- headless full-auto is fine inside a disposable, network-scoped clone. -# Spike (Task 1): codex 0.142.5 dropped `--ask-for-approval`; the headless -# full-auto flag is --dangerously-bypass-approvals-and-sandbox. +# --dangerously-bypass-approvals-and-sandbox disables the inner sandbox and +# approval prompts; this entrypoint is only for the isolated container path. set +e codex exec --dangerously-bypass-approvals-and-sandbox "$WORKER_PROMPT" < /dev/null CODEX_EXIT=$? diff --git a/scripts/egress-inject-addon.py b/scripts/egress-inject-addon.py index 4eefd6c6..360f08d3 100755 --- a/scripts/egress-inject-addon.py +++ b/scripts/egress-inject-addon.py @@ -95,18 +95,20 @@ def request_decision(scheme: str, host: str, pretty_host: str, allowlist: set) - arbitrary target. Credentials are never injected over cleartext, so plain HTTP to a provider host is denied outright, not injected. - MITM'd TLS flows re-check the CONNECT decision (defense in depth) and - inject only when the Host header names a provider host. + MITM'd TLS flows require agreement between the target and Host header. + Otherwise an allowed destination could receive another provider's key. """ h = host.lower() p = pretty_host.lower() if (scheme or "").lower() != "https": + if h in PROVIDER_HOSTS or p in PROVIDER_HOSTS: + return "deny" if host_allowed(h, allowlist) and host_allowed(p, allowlist): return "allow" return "deny" - if connect_decision(h, allowlist) == "deny": + if h != p or connect_decision(h, allowlist) == "deny": return "deny" - return "inject" if p in PROVIDER_HOSTS else "allow" + return "inject" if h in PROVIDER_HOSTS else "allow" def parse_allowlist(env: Mapping) -> set: @@ -177,6 +179,19 @@ def request(flow) -> None: # noqa: ANN001 _apply(flow.request.headers, ops) +def responseheaders(flow) -> None: # noqa: ANN001 + """Forward provider SSE chunks immediately, without buffering a turn. + + Only the response body streams. Request bodies remain buffered so request() + validates the destination and injects credentials before upstream delivery. + Redirects are returned to the client unchanged; every follow-up request is + independently subject to the same CONNECT/request gates. + """ + content_type = flow.response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if flow.request.host.lower() in PROVIDER_HOSTS and content_type == "text/event-stream": + flow.response.stream = True + + def _selftest() -> None: # Anthropic OAuth vs API-key branch. a = injection_for("api.anthropic.com", {"CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat-XYZ"}) @@ -224,10 +239,12 @@ def _selftest() -> None: # Host-header smuggle: allowlisted target, provider Host header -> deny. assert request_decision("http", "registry.npmjs.org", "api.anthropic.com", allow) == "deny" assert request_decision("http", "api.anthropic.com", "registry.npmjs.org", allow) == "deny" - # MITM'd TLS flows: provider -> inject; non-provider Host header on a - # provider connection -> forward without injection; denied host -> deny. + # MITM'd TLS flows: only matching provider target/Host pairs get a key. assert request_decision("https", "api.anthropic.com", "api.anthropic.com", allow) == "inject" - assert request_decision("https", "api.anthropic.com", "elsewhere.example", allow) == "allow" + assert request_decision("https", "api.anthropic.com", "elsewhere.example", allow) == "deny" + assert request_decision("https", "registry.npmjs.org", "api.anthropic.com", allow) == "deny" + assert request_decision("https", "api.openai.com", "api.anthropic.com", allow) == "deny" + assert request_decision("http", "api.anthropic.com", "api.anthropic.com", PROVIDER_HOSTS) == "deny" assert request_decision("https", "evil.example.com", "evil.example.com", allow) == "deny" # Allowlist parsing from the env string. diff --git a/scripts/engines/codex.ts b/scripts/engines/codex.ts index 5651bb24..865e816a 100644 --- a/scripts/engines/codex.ts +++ b/scripts/engines/codex.ts @@ -42,8 +42,8 @@ export function buildCodexConfigToml( return toml; } -// Spike (Task 1): headless full-auto in codex 0.142.5 (the container is the -// sandbox). `--ask-for-approval never` was removed in this version. +// The disposable container supplies isolation. This flag disables Codex's +// inner sandbox and approval prompts; it is not appropriate for a host runner. export function buildCodexExecArgs(prompt: string): string[] { return ["exec", "--dangerously-bypass-approvals-and-sandbox", prompt]; } diff --git a/scripts/init-worker-lib.ts b/scripts/init-worker-lib.ts index 97e10faa..5d64906b 100644 --- a/scripts/init-worker-lib.ts +++ b/scripts/init-worker-lib.ts @@ -223,7 +223,7 @@ function validateWorkerStack(projectKey: string, raw: unknown): string[] { const stack = raw as Record; if (stack.node !== undefined && (typeof stack.node !== "string" || stack.node.trim() === "")) { - problems.push(`projects.${projectKey}.stack.node must be a non-empty string, e.g. "20"`); + problems.push(`projects.${projectKey}.stack.node must be a non-empty string, e.g. "24"`); } if (stack.ports !== undefined) { @@ -838,8 +838,9 @@ export function formatUserStackCapture(capture: UserStackCapture): string { */ const WELL_KNOWN_CLI_INSTALL: Record = { gh: "brew install gh", - codex: "npm install -g @openai/codex", - gemini: "npm install -g @google/gemini-cli", + // Keep repair/capture advice aligned with the worker image defaults (#268). + codex: "npm install -g @openai/codex@0.157.1", + gemini: "npm install -g @google/gemini-cli@0.61.0", }; /** Looks up a well-known install command by CLI name, case-insensitively. */ diff --git a/switchyard-worker.example.json b/switchyard-worker.example.json index 8f521e52..f79c7bb0 100644 --- a/switchyard-worker.example.json +++ b/switchyard-worker.example.json @@ -9,7 +9,7 @@ "SYD": { "repo": "/Users/sean/sites/switchyard", "stack": { - "node": "20", + "node": "24", "cli": [{ "name": "gh", "check": "gh --version", "install": "brew install gh" }], "ports": [3300] } diff --git a/tests/init-worker-lib.test.ts b/tests/init-worker-lib.test.ts index 82ef5fd2..5f179cbd 100644 --- a/tests/init-worker-lib.test.ts +++ b/tests/init-worker-lib.test.ts @@ -1096,8 +1096,8 @@ describe("formatUserStackCapture (SYD-82)", () => { describe("wellKnownCliInstall (SYD-87)", () => { it("returns an install command for well-known reviewer CLIs, case-insensitively", () => { expect(wellKnownCliInstall("gh")).toBe("brew install gh"); - expect(wellKnownCliInstall("Codex")).toBe("npm install -g @openai/codex"); - expect(wellKnownCliInstall("GEMINI")).toBe("npm install -g @google/gemini-cli"); + expect(wellKnownCliInstall("Codex")).toBe("npm install -g @openai/codex@0.157.1"); + expect(wellKnownCliInstall("GEMINI")).toBe("npm install -g @google/gemini-cli@0.61.0"); }); it("returns undefined for an unrecognized CLI", () => { @@ -1108,8 +1108,12 @@ describe("wellKnownCliInstall (SYD-87)", () => { describe("suggestStackCli (SYD-82, SYD-87)", () => { it("builds a --version check per name and pre-fills install for well-known CLIs only", () => { expect(suggestStackCli(["codex", "gemini", "some-internal-tool"])).toEqual([ - { name: "codex", check: "codex --version", install: "npm install -g @openai/codex" }, - { name: "gemini", check: "gemini --version", install: "npm install -g @google/gemini-cli" }, + { name: "codex", check: "codex --version", install: "npm install -g @openai/codex@0.157.1" }, + { + name: "gemini", + check: "gemini --version", + install: "npm install -g @google/gemini-cli@0.61.0", + }, { name: "some-internal-tool", check: "some-internal-tool --version" }, ]); }); @@ -1128,7 +1132,7 @@ describe("formatDockerfileStackGuidance (SYD-87)", () => { ), ).toEqual([ " - gh: brew install gh", - " - codex (captured, not yet in stack.cli): npm install -g @openai/codex", + " - codex (captured, not yet in stack.cli): npm install -g @openai/codex@0.157.1", " - some-internal-tool (captured, not yet in stack.cli): (no install command known)", ]); }); diff --git a/tests/scripts/codex-entry-config.test.ts b/tests/scripts/codex-entry-config.test.ts new file mode 100644 index 00000000..15df9526 --- /dev/null +++ b/tests/scripts/codex-entry-config.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { buildCodexConfigToml } from "../../scripts/engines/codex.js"; + +// Exercise the actual shell config/auth handoff without cloning a repo or +// contacting a provider. Only its fixed output directory is relocated. Image +// startup/CLI parsing are covered separately by the worker-image smoke job. +const entry = readFileSync( + path.resolve(__dirname, "../../scripts/container-entry.codex.sh"), + "utf8", +); +const start = entry.indexOf("export CODEX_HOME=/tmp/codex-home"); +const end = entry.indexOf("# The container is the sandbox here", start); +const account = "11111111-2222-3333-4444-555555555555"; + +describe("Codex entrypoint generated configuration (#268)", () => { + it.each([false, true])( + "writes private, secret-free config and placeholder auth (lease=%s)", + (lease) => { + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const dir = mkdtempSync(path.join(tmpdir(), "codex-entry-config-")); + try { + const body = entry + .slice(start, end) + .replace("export CODEX_HOME=/tmp/codex-home", 'export CODEX_HOME="$TEST_CODEX_HOME"'); + const run = spawnSync("sh", ["-eu", "-c", body], { + encoding: "utf8", + env: { + PATH: process.env.PATH, + TEST_CODEX_HOME: dir, + CODEX_ACCOUNT_ID: account, + SWITCHYARD_URL: "https://tracker.example", + SWITCHYARD_TOKEN: "fixture-mcp-secret", + SWITCHYARD_LEASE: lease ? "fixture-lease-secret" : "", + }, + }); + expect(run.stderr).toBe(""); + expect(run.status).toBe(0); + const config = readFileSync(path.join(dir, "config.toml"), "utf8"); + expect(config).toBe( + buildCodexConfigToml("https://tracker.example", "SWITCHYARD_TOKEN", { + leaseEnvVar: lease ? "SWITCHYARD_LEASE" : undefined, + }), + ); + const authText = readFileSync(path.join(dir, "auth.json"), "utf8"); + const auth = JSON.parse(authText); + expect(auth.OPENAI_API_KEY).toBeNull(); + expect(auth.tokens.account_id).toBe(account); + expect(auth.tokens.refresh_token).toBe("rt-placeholder"); + expect(auth.tokens.id_token).toBe(auth.tokens.access_token); + const jwt = auth.tokens.access_token.split("."); + expect(jwt).toHaveLength(3); + const claims = JSON.parse(Buffer.from(jwt[1], "base64url").toString("utf8")); + expect(claims["https://api.openai.com/auth"].chatgpt_account_id).toBe(account); + // 0.157.1's should_refresh_proactively checks JWT exp before last_refresh. + expect(claims.exp).toBeGreaterThan(Date.now() / 1000 + 300); + expect(Number.isNaN(Date.parse(auth.last_refresh))).toBe(false); + for (const file of ["config.toml", "auth.json"]) { + expect(statSync(path.join(dir, file)).mode & 0o777).toBe(0o600); + } + for (const text of [config, authText, run.stdout, run.stderr]) { + expect(text).not.toContain("fixture-mcp-secret"); + expect(text).not.toContain("fixture-lease-secret"); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/tests/scripts/dockerfile-worker.test.ts b/tests/scripts/dockerfile-worker.test.ts index 66de37fa..eae8ca0f 100644 --- a/tests/scripts/dockerfile-worker.test.ts +++ b/tests/scripts/dockerfile-worker.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import path from "node:path"; +import { wellKnownCliInstall } from "../../scripts/init-worker-lib.js"; // SYD-224: better-sqlite3's prebuild-install fetch can't reach github.com // inside the worker egress allowlist, so it must fall back to `node-gyp @@ -103,3 +104,17 @@ describe("Dockerfile.worker.codex pinned CLI version (SYD-227)", () => { expect(raw).toContain("codex --version"); }); }); + +describe("all worker image dependency pins (#268)", () => { + it.each([ + ["codex", "Dockerfile.worker.codex", "CODEX_CLI_VERSION", "@openai/codex"], + ["gemini", "Dockerfile.worker.gemini", "GEMINI_CLI_VERSION", "@google/gemini-cli"], + ])("keeps the %s doctor repair command aligned with its image", (engine, file, arg, pkg) => { + const raw = readFileSync(path.join(__dirname, "../..", file), "utf8"); + const version = raw.match(new RegExp(`^ARG ${arg}=(\\d+\\.\\d+\\.\\d+)$`, "m"))?.[1]; + expect(version).toBeDefined(); + expect(raw).toContain(`npm install -g ${pkg}@\${${arg}}`); + expect(raw).toContain(`${engine} --version`); + expect(wellKnownCliInstall(engine)).toBe(`npm install -g ${pkg}@${version}`); + }); +}); diff --git a/tests/scripts/egress-inject-addon.test.ts b/tests/scripts/egress-inject-addon.test.ts index 50a28022..e5f2b388 100644 --- a/tests/scripts/egress-inject-addon.test.ts +++ b/tests/scripts/egress-inject-addon.test.ts @@ -24,6 +24,8 @@ class Response: r = types.SimpleNamespace() r.status_code = status_code r.content = content + r.headers = {} if headers is None else headers + r.stream = False return r http_mod.Response = Response mitm = types.ModuleType("mitmproxy") @@ -141,3 +143,92 @@ assert f.response is not None and f.response.status_code == 403, f.response expectOk(r); }); }); + +describe("provider proxy upgrade contract (#269)", () => { + it.each([ + ["api.anthropic.com", "registry.npmjs.org"], + ["registry.npmjs.org", "api.anthropic.com"], + ["api.openai.com", "api.anthropic.com"], + ])("rejects HTTPS target %s with a different Host %s before injection", (host, prettyHost) => { + const r = runHook(` +f = Flow(Req("https", "${host}", pretty_host="${prettyHost}")) +addon.request(f) +assert f.response.status_code == 403 +assert "sk-ant-oat-REALSECRET" not in repr(f.request.headers) +`); + expectOk(r); + }); + + it("rejects cleartext provider traffic even if the provider is explicitly allowlisted", () => { + expectOk( + runHook( + ` +f = Flow(Req("http", "api.anthropic.com")) +addon.request(f) +assert f.response.status_code == 403 +`, + { ALLOWED_DOMAINS: "api.anthropic.com" }, + ), + ); + }); + + it("replaces all caller auth headers for each supported provider without changing its body", () => { + expectOk( + runHook( + ` +cases = [ + ("api.anthropic.com", "authorization", "Bearer sk-ant-oat-REALSECRET"), + ("api.openai.com", "authorization", "Bearer openai-fixture"), + ("chatgpt.com", "authorization", "Bearer codex-fixture"), + ("generativelanguage.googleapis.com", "x-goog-api-key", "gemini-fixture"), +] +for host, header, value in cases: + f = Flow(Req("https", host, headers={key: "placeholder" for key in addon._AUTH_HEADERS})) + f.request.content = b'{"stream":true}' + addon.request(f) + assert f.response is None + assert f.request.headers == {header: value}, f.request.headers + assert f.request.content == b'{"stream":true}' +`, + { + OPENAI_API_KEY: "openai-fixture", + CODEX_OAUTH_TOKEN: "codex-fixture", + GEMINI_API_KEY: "gemini-fixture", + }, + ), + ); + }); + + it("rechecks a redirect target and never grants it the original provider credential", () => { + expectOk( + runHook(` +initial = Flow(Req("https", "api.anthropic.com")) +addon.request(initial) +initial.response = Response.make(307, headers={"location": "https://evil.example.com/collect"}) +addon.responseheaders(initial) +assert initial.response.status_code == 307 +assert initial.response.headers["location"] == "https://evil.example.com/collect" +followup = Flow(Req("https", "evil.example.com")) +addon.http_connect(followup) +assert followup.response.status_code == 403 +addon.request(followup) +assert followup.response.status_code == 403 +assert followup.request.headers == {} +`), + ); + }); + + it("streams SSE responses without changing headers or content, while leaving JSON buffered", () => { + expectOk( + runHook(` +for content_type, expected in [("text/event-stream; charset=utf-8", True), ("application/json", False)]: + f = Flow(Req("https", "chatgpt.com")) + f.response = Response.make(200, b"data: chunk\\n\\n", {"content-type": content_type}) + addon.responseheaders(f) + assert f.response.stream is expected + assert f.response.content == b"data: chunk\\n\\n" + assert f.response.headers == {"content-type": content_type} +`), + ); + }); +}); diff --git a/tests/scripts/egress-proxy-contract.py b/tests/scripts/egress-proxy-contract.py new file mode 100644 index 00000000..e683dd0d --- /dev/null +++ b/tests/scripts/egress-proxy-contract.py @@ -0,0 +1,102 @@ +"""Run inside the built proxy image: exercise real mitmproxy 12 API objects. + +No sockets, provider credentials, or paid model calls are used. The ordinary +Vitest suite also tests the policy without requiring Python dependencies. +""" +import importlib.util +import os +from pathlib import Path +import unittest +from unittest.mock import patch + +from mitmproxy import connection, http + +spec = importlib.util.spec_from_file_location( + "addon", Path(__file__).resolve().parents[2] / "scripts/egress-inject-addon.py" +) +addon = importlib.util.module_from_spec(spec) +spec.loader.exec_module(addon) + + +def flow(url, headers=None): + result = http.HTTPFlow( + connection.Client(peername=("127.0.0.1", 1234), sockname=("127.0.0.1", 8888)), + connection.Server(address=("api.anthropic.com", 443)), + ) + result.request = http.Request.make("POST", url, b'{"stream":true}', headers or {}) + return result + + +class ProxyContract(unittest.TestCase): + def setUp(self): + env = patch.dict(os.environ, { + "ALLOWED_DOMAINS": "api.anthropic.com,registry.npmjs.org", + "CLAUDE_CODE_OAUTH_TOKEN": "sk-ant-oat-fixture", + "CODEX_OAUTH_TOKEN": "codex-fixture", + "OPENAI_API_KEY": "openai-fixture", + "GEMINI_API_KEY": "gemini-fixture", + }, clear=True) + env.start() + self.addCleanup(env.stop) + + def test_provider_injection_replaces_case_insensitive_duplicate_auth(self): + cases = [ + ("api.anthropic.com", "authorization", "Bearer sk-ant-oat-fixture"), + ("chatgpt.com", "authorization", "Bearer codex-fixture"), + ("api.openai.com", "authorization", "Bearer openai-fixture"), + ("generativelanguage.googleapis.com", "x-goog-api-key", "gemini-fixture"), + ] + for host, header, value in cases: + with self.subTest(host=host): + f = flow(f"https://{host}/v1/test") + f.request.headers.add("Authorization", "Bearer caller-1") + f.request.headers.add("authorization", "Bearer caller-2") + f.request.headers["X-API-Key"] = "caller-3" + f.request.headers["X-Goog-Api-Key"] = "caller-4" + addon.request(f) + self.assertIsNone(f.response) + self.assertEqual(f.request.headers.get_all(header), [value]) + self.assertEqual(f.request.content, b'{"stream":true}') + for other in set(addon._AUTH_HEADERS) - {header}: + self.assertNotIn(other, f.request.headers) + + def test_rejects_forbidden_cleartext_and_mismatched_destinations(self): + for url, host in [ + ("https://evil.example/test", "evil.example"), + ("http://api.anthropic.com/test", "api.anthropic.com"), + ("https://registry.npmjs.org/test", "api.anthropic.com"), + ("https://api.openai.com/test", "api.anthropic.com"), + ]: + with self.subTest(url=url, host=host): + f = flow(url, {"Host": host}) + addon.request(f) + self.assertEqual(f.response.status_code, 403) + self.assertNotIn("authorization", f.request.headers) + + def test_redirect_remains_client_side_and_followup_is_denied(self): + initial = flow("https://api.anthropic.com/test") + addon.request(initial) + initial.response = http.Response.make(307, headers={"Location": "https://evil.example/"}) + addon.responseheaders(initial) + self.assertEqual(initial.response.status_code, 307) + self.assertEqual(initial.response.headers["Location"], "https://evil.example/") + followup = flow(initial.response.headers["Location"]) + addon.http_connect(followup) + self.assertEqual(followup.response.status_code, 403) + addon.request(followup) + self.assertEqual(followup.response.status_code, 403) + self.assertNotIn("authorization", followup.request.headers) + + def test_streaming_preserves_events_and_leaves_json_buffered(self): + for content_type, streams in [("text/event-stream; charset=utf-8", True), ("application/json", False)]: + with self.subTest(content_type=content_type): + f = flow("https://chatgpt.com/backend-api/codex/responses") + f.response = http.Response.make(200, b"data: chunk\n\n", {"Content-Type": content_type}) + addon.responseheaders(f) + self.assertIs(f.response.stream, streams) + self.assertEqual(f.response.content, b"data: chunk\n\n") + self.assertEqual(f.response.headers["Content-Type"], content_type) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/scripts/engines/codex.test.ts b/tests/scripts/engines/codex.test.ts index 5c7b023e..dae6f32f 100644 --- a/tests/scripts/engines/codex.test.ts +++ b/tests/scripts/engines/codex.test.ts @@ -32,8 +32,7 @@ describe("codex engine builders", () => { }); it("builds a headless codex exec argv (container is the sandbox)", () => { - // Spike (Task 1): codex 0.142.5 dropped --ask-for-approval; headless - // full-auto is --dangerously-bypass-approvals-and-sandbox. + // The container supplies isolation; the CLI must not wait for approvals. expect(buildCodexExecArgs("do the thing")).toEqual([ "exec", "--dangerously-bypass-approvals-and-sandbox", From 0b7b6782bf19ff1b726dbfb9b6831e8d34e10a89 Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:12:47 -0400 Subject: [PATCH 4/8] fix: isolate worker Git exchange from host checkout (#265) --- README.md | 71 ++-- scripts/agent-worker.ts | 60 +++- scripts/container-entry.codex.sh | 16 +- scripts/container-entry.gemini.sh | 16 +- scripts/container-entry.sh | 16 +- scripts/delivery-exec.ts | 14 +- scripts/delivery-lib.ts | 18 +- scripts/worker-exchange.ts | 356 +++++++++++++++++++++ scripts/worker-publications.ts | 2 + scripts/worker-select.ts | 3 +- tests/scripts/agent-worker-publish.test.ts | 28 ++ tests/scripts/agent-worker.test.ts | 17 +- tests/scripts/delivery-lib.test.ts | 5 +- tests/scripts/worker-exchange.test.ts | 289 +++++++++++++++++ tests/scripts/worker-select.test.ts | 6 +- 15 files changed, 833 insertions(+), 84 deletions(-) create mode 100644 scripts/worker-exchange.ts create mode 100644 tests/scripts/worker-exchange.test.ts diff --git a/README.md b/README.md index 02a1803b..26cad621 100644 --- a/README.md +++ b/README.md @@ -298,10 +298,10 @@ less than it does bare on the host. By default, dispatched sessions run bare on the host: same process, same working tree, same filesystem access as anything else you run locally. Set `containerized: true` and Switchyard instead runs the session inside a -disposable Docker container that clones the repo internally, works on a -branch, and pushes the branch back — it is structurally unable to touch your -host filesystem or push to `main`. This is the recommended default; the bare -mode above stays available for repos or setups where Docker isn't practical. +disposable Docker container that receives a sanitized Git exchange, works on +an issue branch, and returns commits for host-side review and publication. +The live checkout, host Git configuration, credentials, and worker state are +outside its mount. Bare mode remains available for trusted local sessions. Build the worker images before dispatching (rebuild after changing their entrypoints or helpers). Claude, Codex, and Gemini CLI versions are pinned in @@ -339,32 +339,52 @@ CLAUDE_CODE_OAUTH_TOKEN=... # from `claude setup-token` ANTHROPIC_API_KEY=... # or a raw API key ``` -`scripts/agent-worker.ts` passes these through to the container via bare -`-e VAR` (no value embedded in argv) — see `buildDockerArgs` in -`scripts/worker-select.ts`. Inside the container, `scripts/container-entry.sh` -clones `/origin` (the host repo, mounted read-write) into `/work`, checks out -`agent/`, runs the same `claude -p` session as bare mode (with an -addendum reminding it to commit and to name the branch in its issue -comment), and pushes `agent/` back to `/origin` if it produced any -commits. The container gets no host filesystem beyond that one mount, and can -only ever push that one branch name — merging stays a human decision, same -as bare mode. +In the default proxy mode, provider credentials stay in the egress sidecar; +the session receives a placeholder. With `egress: "open"`, provider credentials +are passed through via bare `-e VAR` arguments (their values never enter argv). +The tracker token and session lease authorize the session's tracker operations. + +Each dispatch creates a bare exchange at +`~/.switchyard/worker-exchanges///origin.git`. +Only that repository is mounted at `/origin`; its host-owned recovery metadata +is outside the mount. The exchange contains the configured base branch and, +when present, the existing `agent/` branch. Entrypoints clone it into +`/work`, continue existing agent work or start at the base branch, then push +commits back to the exchange. Untracked files, working-tree edits, other +branches, host Git config/hooks, and credentials are not exported. + +After Docker confirms the container has stopped, the host reconstructs a +private quarantine from regular Git object files and exactly `agent/`. +It never runs Git against worker-controlled config or hooks, follows object +alternates, or imports arbitrary refs. Strict object checks, ancestry checks, +and an atomic comparison against the original host ref prevent overwriting +concurrent host work. Imports are limited to 2 GiB and 100,000 object files; +Git operations time out after two minutes. A failure retains the exchange +and publication intent for investigation/retry. Successful import and publication +remove it; disabling automatic PRs still imports and recovers committed work. + +Exported history is source data: committed secrets must be removed from that +history before dispatch. The worker rejects root `.env`, local/production/ +development/staging env variants, `.superpowers/`, and `switchyard-worker.json` +in exported history. Example env files, `.env.test`, and fixture env files are +allowed. This targeted check is not a general credential scanner. Pending +exchanges survive host-worker restarts; do not delete them before recovering +unpublished commits. Existing pre-upgrade sessions retain their original mounts +until they finish, so drain those sessions when rolling out this isolation change. ### Egress allowlist (SYD-110) -Containerized sessions carry secrets in env (`SWITCHYARD_TOKEN`, and -`CLAUDE_CODE_OAUTH_TOKEN`/`ANTHROPIC_API_KEY`); with open networking, a -prompt-injected session — or a malicious npm lifecycle script during the -pre-session `npm ci` — could simply POST them somewhere. Two layers close -this: +Containerized sessions need scoped tracker credentials. Provider credentials +stay outside the session in proxy mode; open networking deliberately gives up +that protection. The default egress controls reduce what a compromised session +or dependency-install script can reach: 1. **Network egress allowlist (default on).** Session containers join an `--internal` Docker network (`syd-workers`) with no route out; their only - exit is a tinyproxy sidecar (`syd-egress`, built by + HTTP(S) exit is a mitmproxy sidecar (`syd-egress`, built by `npm run build:worker-image` from `Dockerfile.egress-proxy`) that forwards - only to `api.anthropic.com`, `registry.npmjs.org`, `registry.yarnpkg.com` - (yarn's default registry — SYD-269), and the tracker host - from `url`. Add hosts with `egressAllow: ["host.name"]`; opt out entirely + to the configured provider, package-registry, and tracker allowlist. It + injects provider credentials only for the matching provider hosts. Add hosts with `egressAllow: ["host.name"]`; opt out entirely with `egress: "open"` in `switchyard-worker.json`. The worker stands the network and sidecar up automatically at startup (and refuses to start if it can't — deliver.ts warns instead, since merges shouldn't stop). Known @@ -373,7 +393,7 @@ this: could in principle relay. 2. **Secret-free dependency install.** `scripts/install-guard.mjs` strips the - three secret vars from the install's environment, so third-party lifecycle + session credentials from the install's environment, so third-party lifecycle scripts never see them (native-module builds still work — the reason we don't use `--ignore-scripts`). It runs whichever frozen install the target repo's lockfile calls for — `npm ci`, `yarn install --frozen-lockfile` or @@ -388,7 +408,8 @@ Unattended agent work never lands on `main` (or the NAS) until a human stamps the issue `done`. Three pieces (SYD-49): 1. **Workers open PRs.** When a containerized session exits having pushed - `agent/` into the host repo, the worker pushes that branch to GitHub + `agent/` into its exchange, the host validates and imports that branch, + then pushes it to GitHub and opens a PR titled with the ref — host-side, so containers never hold GitHub credentials. Controlled by `delivery.openPrs` (default true when the `delivery` block exists). diff --git a/scripts/agent-worker.ts b/scripts/agent-worker.ts index e9af07f1..e902c102 100644 --- a/scripts/agent-worker.ts +++ b/scripts/agent-worker.ts @@ -117,6 +117,12 @@ import { clearPublication, type PendingPublication, } from "./worker-publications.js"; +import { + prepareWorkerExchange, + hasWorkerExchange, + importWorkerExchange, + clearWorkerExchange, +} from "./worker-exchange.js"; import { publishAgentBranch, prFreshness, originOwnerRepo } from "./delivery-exec.js"; import { agentBranch, @@ -721,7 +727,13 @@ async function publishPending( // an inspection error leaves the durable obligation intact for retry. const liveNames = knownLiveNames ?? (await listLiveContainerNames()); if (liveNames.has(containerNameFor(ref))) return; + const imported = importWorkerExchange(project, config, ref); await reportSessionEnd(config, token, Promise.resolve(sessionId), exitCode); + if (!publishesContainers(config) || (publication.exchange && !imported)) { + clearWorkerExchange(project.repo, config, ref); + clearPublication(project.repo, config, ref); + return; + } const outcome = await publishAgentBranch(project.repo, ref, issueTitle, config.url, exitCode); const line = formatPublishOutcome(agentBranch(ref), outcome); console.log(`${ref}: ${line}`); @@ -751,6 +763,7 @@ async function publishPending( // exist. A crash/failed POST retries the idempotent already-open path. await postDeliveryEvent(config, token, ref, event); } + clearWorkerExchange(project.repo, config, ref); clearPublication(project.repo, config, ref); } catch (err) { const message = (err as Error).message; @@ -783,12 +796,19 @@ async function finishSessionExit( ): Promise { console.log(`${ref} exited with code ${exitCode}`); logLine(`\n[worker] exited with code ${exitCode}\n`); - if (!publishesContainers(config)) { + if (!config.containerized) { await reportSessionEnd(config, token, sessionId, exitCode); return; } try { - const publication = { ref, issueTitle, sessionId: (await sessionId) ?? null, exitCode }; + const pending = readPublications(project.repo, config).find((entry) => entry.ref === ref); + const publication = { + ...pending, + ref, + issueTitle, + sessionId: (await sessionId) ?? null, + exitCode, + }; // Save before closing the tracker session: if the process stops after // the PATCH, the server no longer lists it as active but this survives. savePublication(project.repo, config, publication); @@ -805,7 +825,7 @@ export async function recoverPublications( token: string, knownLiveNames?: Set, ): Promise { - if (!publishesContainers(config)) return; + if (!config.containerized) return; const pending = Object.values(config.projects).flatMap((project) => readPublications(project.repo, config).map((publication) => ({ project, publication })), ); @@ -878,6 +898,7 @@ export function dispatch( const clearNeverStartedPublication = () => { if (!publicationSaved) return; try { + clearWorkerExchange(project.repo, config, issue.ref); clearPublication(project.repo, config, issue.ref); } catch (err) { console.error( @@ -887,18 +908,29 @@ export function dispatch( }; try { if (config.containerized) { - if (publishesContainers(config)) { - savePublication(project.repo, config, { - ref: issue.ref, - issueTitle: issue.title, - sessionId: null, - exitCode: null, - }); - publicationSaved = true; + if ( + readPublications(project.repo, config).some((entry) => entry.ref === issue.ref) || + hasWorkerExchange(project.repo, config, issue.ref) + ) { + throw new Error( + `unfinished worker exchange/publication for ${issue.ref}; recover it before redispatch`, + ); } - // The container is the sandbox: it clones the repo internally, works on - // a branch, and pushes it back out — it never touches this host - // filesystem beyond the /origin mount. See scripts/container-entry.sh. + // Persist before preparation too: a crash while copying objects leaves + // a recoverable obligation, never an orphan that blocks redispatch. + // Persist for every container, even when automatic PR publication is + // disabled: importing its output after a restart is still mandatory. + savePublication(project.repo, config, { + ref: issue.ref, + issueTitle: issue.title, + sessionId: null, + exitCode: null, + exchange: true, + }); + publicationSaved = true; + prepareWorkerExchange(project, config, issue.ref); + // Only the sanitized exchange is mounted. The live checkout, .env, + // Git config and host recovery/lease state stay outside the container. const dockerArgs = buildDockerArgs(issue, project, config, process.env, opts); child = spawn("docker", dockerArgs, { detached: true, diff --git a/scripts/container-entry.codex.sh b/scripts/container-entry.codex.sh index ea09a9be..48334db9 100755 --- a/scripts/container-entry.codex.sh +++ b/scripts/container-entry.codex.sh @@ -1,12 +1,12 @@ #!/bin/sh # Container entrypoint for Switchyard's Codex engine (SYD-187) -- the # codex-exec counterpart of container-entry.sh (SYD-30). Same contract: clone -# the host repo (mounted read-write at /origin) into /work, check out a fresh +# the sanitized session exchange (mounted read-write at /origin) into /work, check out a fresh # agent/ branch, run a headless codex session against the clone, and push # the branch back to /origin if the session produced any commits. The # container has no access to the host filesystem beyond the /origin mount, -# and only ever pushes agent/ branches -- merging to main stays a human -# decision made outside the container. +# and the host imports only its expected agent/ branch after validation. +# Merging to main stays a human decision outside the container. # # Auth is the user's ChatGPT subscription login: the real OAuth token lives # only in the syd-egress sidecar, which injects it via MITM (SYD-186); this @@ -26,7 +26,7 @@ # verified before codex exec runs; see stack-check.mjs # BASE_BRANCH integration branch to base agent/ on (default "main") # -# Host repo mounted read-write at /origin. +# Only the sanitized session exchange is mounted read-write at /origin. set -eu @@ -56,7 +56,7 @@ fi # refuses the clone with "detected dubious ownership". git config --global --add safe.directory /origin -git clone /origin /work +git clone --no-local /origin /work cd /work # Pre-trust the workspace (SYD-80): otherwise Claude Code treats /work as @@ -71,7 +71,11 @@ node /prime-workspace-trust.mjs /work # which is nondeterministic from the container's point of view. BASE_BRANCH="${BASE_BRANCH:-main}" git fetch origin "$BASE_BRANCH" -git checkout -b "agent/$ISSUE_REF" "origin/$BASE_BRANCH" +if git show-ref --verify --quiet "refs/remotes/origin/agent/$ISSUE_REF"; then + git checkout -b "agent/$ISSUE_REF" "origin/agent/$ISSUE_REF" +else + git checkout -b "agent/$ISSUE_REF" "origin/$BASE_BRANCH" +fi # Recorded after the branch is set up, so the commit count below reflects # only what the session itself produced. diff --git a/scripts/container-entry.gemini.sh b/scripts/container-entry.gemini.sh index 8f0e6cc7..6be4b083 100755 --- a/scripts/container-entry.gemini.sh +++ b/scripts/container-entry.gemini.sh @@ -1,12 +1,12 @@ #!/bin/sh # Container entrypoint for Switchyard's Gemini engine (SYD-225) -- the gemini # counterpart of container-entry.sh (SYD-30) / container-entry.codex.sh -# (SYD-187). Same contract: clone the host repo (mounted read-write at /origin) +# (SYD-187). Same contract: clone the sanitized session exchange (mounted read-write at /origin) # into /work, check out a fresh agent/ branch, run a headless gemini # session against the clone, and push the branch back to /origin if the session # produced any commits. The container has no access to the host filesystem -# beyond the /origin mount, and only ever pushes agent/ branches -- merging -# to main stays a human decision made outside the container. +# beyond the /origin mount; the host imports only its expected agent/ +# branch after validation. Merging to main remains a human decision. # # Auth is a static AI-Studio API key: the real GEMINI_API_KEY lives only in the # syd-egress sidecar, which injects it as the x-goog-api-key header for @@ -27,7 +27,7 @@ # STACK_CHECKS JSON array of {name, check, install} (SYD-76) # BASE_BRANCH integration branch to base agent/ on (default "main") # -# Host repo mounted read-write at /origin. +# Only the sanitized session exchange is mounted read-write at /origin. set -eu @@ -49,7 +49,7 @@ fi # refuses the clone with "detected dubious ownership". git config --global --add safe.directory /origin -git clone /origin /work +git clone --no-local /origin /work cd /work # Pre-trust the workspace (SYD-80): kept for parity with the other entry @@ -62,7 +62,11 @@ node /prime-workspace-trust.mjs /work # whatever branch the host happened to have checked out at clone time. BASE_BRANCH="${BASE_BRANCH:-main}" git fetch origin "$BASE_BRANCH" -git checkout -b "agent/$ISSUE_REF" "origin/$BASE_BRANCH" +if git show-ref --verify --quiet "refs/remotes/origin/agent/$ISSUE_REF"; then + git checkout -b "agent/$ISSUE_REF" "origin/agent/$ISSUE_REF" +else + git checkout -b "agent/$ISSUE_REF" "origin/$BASE_BRANCH" +fi # Recorded after the branch is set up, so the commit count below reflects only # what the session itself produced. diff --git a/scripts/container-entry.sh b/scripts/container-entry.sh index 32583dd4..7bf125ee 100755 --- a/scripts/container-entry.sh +++ b/scripts/container-entry.sh @@ -2,12 +2,12 @@ # Container entrypoint for Switchyard's containerized dispatch mode (SYD-30). # # Runs inside a disposable Docker container (see ../Dockerfile.worker): clones -# the host repo (mounted read-write at /origin) into /work, checks out a +# the sanitized session exchange (mounted read-write at /origin) into /work, checks out a # fresh branch, runs a headless Claude Code session against the clone, and # pushes the branch back to /origin if the session produced any commits. The # container has no access to the host filesystem beyond the /origin mount, -# and only ever pushes agent/ branches — merging to main stays a human -# decision made outside the container. +# and the host imports only its expected agent/ branch after validation. +# Merging to main stays a human decision outside the container. # # Required env: # ISSUE_REF e.g. "SYD-30" @@ -33,7 +33,7 @@ # the entrypoint's only job afterward is to fail loudly if a rebase was left # unresolved. # -# Host repo mounted read-write at /origin. +# Only the sanitized session exchange is mounted read-write at /origin. set -eu @@ -68,7 +68,7 @@ fi # refuses the clone with "detected dubious ownership". git config --global --add safe.directory /origin -git clone /origin /work +git clone --no-local /origin /work cd /work # Pre-trust the workspace (SYD-80): otherwise Claude Code treats /work as @@ -96,7 +96,11 @@ else # back whatever branch the host happened to have checked out at clone time, # which is nondeterministic from the container's point of view. git fetch origin "$BASE_BRANCH" - git checkout -b "agent/$ISSUE_REF" "origin/$BASE_BRANCH" + if git show-ref --verify --quiet "refs/remotes/origin/agent/$ISSUE_REF"; then + git checkout -b "agent/$ISSUE_REF" "origin/agent/$ISSUE_REF" + else + git checkout -b "agent/$ISSUE_REF" "origin/$BASE_BRANCH" + fi fi # Recorded after the branch is set up, so the commit count below (work mode diff --git a/scripts/delivery-exec.ts b/scripts/delivery-exec.ts index 1b39af11..e1d672a6 100644 --- a/scripts/delivery-exec.ts +++ b/scripts/delivery-exec.ts @@ -65,16 +65,10 @@ export async function run( } /** - * Runs git with repo hooks disabled (`-c core.hooksPath=/dev/null`). Every - * git invocation in this file targets a directory a containerized dispatch - * session mounts read-write as /origin — project.repo for ordinary work - * dispatch (buildDockerArgs, worker-select.ts) or the shared deliver.ts - * cloneDir for conflict-resolution dispatch (buildConflictResolutionDockerArgs, - * delivery-lib.ts). A prompt-injected session has Bash+Write in there and can - * plant e.g. .git/hooks/pre-push directly on the mount; without this flag a - * later host-side git command against that same directory (push, checkout, - * rebase, ...) would execute the planted hook as the host/worker user, which - * holds GitHub push credentials — container-to-host RCE (SYD-109). + * Runs host-side Git with hooks disabled. Worker exchange import reconstructs + * a private repository rather than trusting the mounted Git config/hooks; + * keep this extra boundary for delivery repositories and legacy sessions + * that began before the exchange isolation rollout. */ export async function runGit( args: string[], diff --git a/scripts/delivery-lib.ts b/scripts/delivery-lib.ts index 0a683158..54367661 100644 --- a/scripts/delivery-lib.ts +++ b/scripts/delivery-lib.ts @@ -794,21 +794,17 @@ export function deliveryFailureComment(ref: string, message: string): string { } /** - * Posted when the host-side publish step (SYD-49: `git push` + `gh pr - * create`, run by agent-worker.ts right after a containerized session exits) - * fails (SYD-257). Distinct from deliveryFailureComment (a merge-time - * failure once a PR already exists) — here the session's work is committed - * on `agent/` in the host repo but never reached GitHub, so there is no - * PR yet for "Retry delivery" to re-authorize; a human has to fix the - * publish problem (e.g. host git/gh auth) and push/open the PR by hand, or - * re-dispatch the issue. + * Import, GitHub publication, or tracker-observation failures retain a durable + * retry obligation. The branch may still be in the session exchange, and a + * PR may already exist, so do not promise either outcome before recovery. */ export function publishFailureComment(ref: string, message: string): string { return ( `Publish FAILED for ${ref}: ${message}\n` + - `The session's work is committed on ${agentBranch(ref)} in the host repo, but \`git push\` / ` + - `\`gh pr create\` did not reach GitHub — there is no PR yet. Check the worker log, fix the ` + - `underlying problem, then push ${agentBranch(ref)} and open the PR by hand (or re-dispatch the issue).` + `The host could not finish importing or publishing ${agentBranch(ref)}. ` + + `Its publication intent and any session exchange are retained for retry. ` + + `Check the worker log and fix the underlying problem; do not delete the exchange or ` + + `re-dispatch until the committed work has been recovered.` ); } diff --git a/scripts/worker-exchange.ts b/scripts/worker-exchange.ts new file mode 100644 index 00000000..adbee37d --- /dev/null +++ b/scripts/worker-exchange.ts @@ -0,0 +1,356 @@ +// The only writable host mount exposed to a container is a disposable bare +// repository. Its parent contains host-owned recovery metadata and is NEVER +// mounted. Git must never execute against the worker-writable repository on +// the host: even its config, refs and object directories are untrusted input. +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmodSync, + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + mkdirSync, + mkdtempSync, + openSync, + readFileSync, + readdirSync, + renameSync, + readSync, + writeSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { z } from "zod"; +import type { WorkerConfig, WorkerProject } from "./worker-select.js"; + +const MAX_IMPORT_BYTES = 2 * 1024 * 1024 * 1024; +const MAX_METADATA_BYTES = 1024 * 1024; +const MAX_OBJECT_FILES = 100_000; + +const refSchema = z.string().regex(/^[A-Z]{2,10}-\d+$/); +const oidSchema = z.string().regex(/^[0-9a-f]{40}$/); +const stateSchema = z.object({ + ref: refSchema, + base: oidSchema, + previous: oidSchema.nullable(), +}); + +function directory(repo: string, config: WorkerConfig, ref: string): string { + refSchema.parse(ref); + const scope = createHash("sha256") + .update( + JSON.stringify([ + path.resolve(repo), + config.url, + config.label, + config.token ?? "SWITCHYARD_TOKEN", + ]), + ) + .digest("hex"); + return path.join(os.homedir(), ".switchyard", "worker-exchanges", scope, ref); +} + +export function hasWorkerExchange(repo: string, config: WorkerConfig, ref: string): boolean { + return existsSync(directory(repo, config, ref)); +} + +export function workerExchangeRepo(repo: string, config: WorkerConfig, ref: string): string { + return path.join(directory(repo, config, ref), "origin.git"); +} + +// Do not inherit alternate object databases, injected -c options, replace +// refs or user/system config. Host repository configuration stays host-owned. +function git(repo: string, args: string[]): string { + const env = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")), + ); + return execFileSync( + "git", + [ + "-c", + "core.hooksPath=/dev/null", + "-c", + "core.fsmonitor=false", + "-c", + "protocol.ext.allow=never", + "-c", + "gc.auto=0", + "-c", + "maintenance.auto=false", + "-C", + repo, + ...args, + ], + { + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + timeout: 120_000, + env: { + ...env, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_NO_REPLACE_OBJECTS: "1", + GIT_TERMINAL_PROMPT: "0", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ).trim(); +} + +function optionalHead(repo: string, ref: string): string | null { + const value = git(repo, ["for-each-ref", "--format=%(objectname)", ref]); + return value ? oidSchema.parse(value) : null; +} + +function checkTrackedSecrets(repo: string, refs: string[]): void { + // Exported Git history is source data, not a secret store. Fail closed for + // known host-only paths, including historical versions. Example env files + // remain usable. This is deliberately not a general credential scanner. + const forbidden = git(repo, [ + "log", + "--format=", + "--full-history", + "-m", + "--name-only", + "-z", + ...refs, + "--", + ".env", + ".env.local", + ".env.production", + ".env.production.local", + ".env.development", + ".env.development.local", + ".env.staging", + ".env.staging.local", + ".superpowers", + "switchyard-worker.json", + ]) + .split("\0") + .map((name) => name.trim()) + .find(Boolean); + if (forbidden) + throw new Error( + `worker exchange refuses tracked host-only path: ${forbidden}; remove it from exported history before dispatch`, + ); +} + +export function prepareWorkerExchange( + project: WorkerProject, + config: WorkerConfig, + ref: string, +): string { + const dir = directory(project.repo, config, ref); + const relative = path.relative(realpathSync(project.repo), path.resolve(dir)); + if (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)) + throw new Error("worker exchange must live outside the host checkout"); + if (existsSync(dir)) + throw new Error( + `unfinished worker exchange for ${ref}; recover its publication before redispatch`, + ); + const branch = `refs/heads/agent/${ref}`; + const baseRef = `refs/heads/${project.baseBranch ?? "main"}`; + git(project.repo, ["check-ref-format", baseRef]); + const base = oidSchema.parse(git(project.repo, ["rev-parse", "--verify", `${baseRef}^{commit}`])); + const previous = optionalHead(project.repo, branch); + const refs = [baseRef, ...(previous ? [branch] : [])]; + checkTrackedSecrets(project.repo, refs); + mkdirSync(path.dirname(dir), { recursive: true, mode: 0o700 }); + mkdirSync(dir, { mode: 0o700 }); + const exchange = workerExchangeRepo(project.repo, config, ref); + try { + const bundle = path.join(dir, "seed.bundle"); + git(project.repo, ["bundle", "create", bundle, ...refs]); + mkdirSync(exchange); + git(exchange, ["init", "--bare", "--template="]); + git(exchange, ["fetch", "--no-tags", bundle, ...refs.map((r) => `${r}:${r}`)]); + git(exchange, ["symbolic-ref", "HEAD", baseRef]); + rmSync(bundle); + // Linux bind mounts preserve UIDs. Only the mounted exchange is writable + // by the image's non-root UID; its parent and recovery state remain 0700. + function writable(root: string): void { + for (const item of readdirSync(root, { withFileTypes: true })) { + const name = path.join(root, item.name); + if (item.isDirectory()) writable(name); + else chmodSync(name, 0o666); + } + chmodSync(root, 0o777); + } + writable(exchange); + writeFileSync( + path.join(dir, "state.json.tmp"), + JSON.stringify({ ref, base, previous }) + "\n", + { + mode: 0o600, + flush: true, + }, + ); + renameSync(path.join(dir, "state.json.tmp"), path.join(dir, "state.json")); + return exchange; + } catch (error) { + rmSync(dir, { recursive: true, force: true }); + throw error; + } +} + +// The container has exited before these reads. Reject symlinks on EVERY path +// component; O_NOFOLLOW also protects the leaf. Do not open FIFOs/devices. +function openRegularFile(root: string, relative: string): number | null { + let current = root; + if (!lstatSync(current).isDirectory()) throw new Error("worker exchange root is not a directory"); + const parts = relative.split("/"); + for (const [index, part] of parts.entries()) { + current = path.join(current, part); + let stat; + try { + stat = lstatSync(current); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if (index < parts.length - 1) { + if (!stat.isDirectory()) throw new Error(`unsafe worker exchange directory: ${relative}`); + } else if (!stat.isFile() || stat.nlink !== 1) + throw new Error(`unsafe worker exchange file: ${relative}`); + } + const fd = openSync(current, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + if (!fstatSync(fd).isFile()) { + closeSync(fd); + throw new Error(`unsafe worker exchange file: ${relative}`); + } + return fd; +} + +function regularFile(root: string, relative: string): Buffer | null { + const fd = openRegularFile(root, relative); + if (fd === null) return null; + try { + if (fstatSync(fd).size > MAX_METADATA_BYTES) + throw new Error("worker exchange metadata exceeds size limit"); + return readFileSync(fd); + } finally { + closeSync(fd); + } +} + +function outputHead(exchange: string, ref: string): string | null { + const name = `refs/heads/agent/${ref}`; + const loose = regularFile(exchange, name); + if (loose) return oidSchema.parse(loose.toString("utf8").trim()); + const packed = regularFile(exchange, "packed-refs"); + const line = packed + ?.toString("utf8") + .split("\n") + .find((line) => line.endsWith(` ${name}`)); + return line ? oidSchema.parse(line.split(" ")[0]) : null; +} + +function copyObjects(exchange: string, quarantine: string): void { + let bytesLeft = MAX_IMPORT_BYTES; + let filesLeft = MAX_OBJECT_FILES; + const buffer = Buffer.alloc(1024 * 1024); + const objectDir = path.join(exchange, "objects"); + if (!lstatSync(objectDir).isDirectory()) + throw new Error("unsafe worker exchange objects directory"); + for (const dir of readdirSync(objectDir)) { + if (dir === "info") continue; // Never copy alternates, commit-graphs or paths. + if (dir !== "pack" && !/^[0-9a-f]{2}$/.test(dir)) + throw new Error("unexpected worker object directory"); + const source = path.join(objectDir, dir); + if (!lstatSync(source).isDirectory()) throw new Error("unsafe worker object directory"); + const dest = path.join(quarantine, "objects", dir); + mkdirSync(dest, { recursive: true }); + for (const name of readdirSync(source)) { + if (dir === "pack" && !/^pack-[0-9a-f]{40}\.(pack|idx)$/.test(name)) continue; + if (dir !== "pack" && !/^[0-9a-f]{38}$/.test(name)) + throw new Error("unexpected loose Git object"); + if (--filesLeft < 0) throw new Error("worker exchange exceeds object count limit"); + const fd = openRegularFile(exchange, `objects/${dir}/${name}`); + if (fd === null) throw new Error("worker object disappeared during import"); + let output: number | undefined; + try { + const size = fstatSync(fd).size; + if (size > bytesLeft) throw new Error("worker exchange exceeds 2 GiB import size limit"); + bytesLeft -= size; + output = openSync(path.join(dest, name), "wx", 0o600); + let copied = 0; + for (;;) { + const count = readSync(fd, buffer); + if (!count) break; + copied += count; + if (copied > size) throw new Error("worker object changed during import"); + let written = 0; + while (written < count) written += writeSync(output, buffer, written, count - written); + } + if (copied !== size) throw new Error("worker object changed during import"); + } finally { + closeSync(fd); + if (output !== undefined) closeSync(output); + } + } + } +} + +/** Import only after the daemon confirms the container no longer exists. + * Returns null for pre-upgrade sessions (no exchange) or no pushed branch. + * Safe to repeat after a crash between import and publication. */ +export function importWorkerExchange( + project: WorkerProject, + config: WorkerConfig, + ref: string, +): string | null { + const dir = directory(project.repo, config, ref); + const statePath = path.join(dir, "state.json"); + if (!existsSync(statePath)) return null; + const state = stateSchema.parse(JSON.parse(readFileSync(statePath, "utf8"))); + if (state.ref !== ref) throw new Error("worker exchange ref mismatch"); + const exchange = workerExchangeRepo(project.repo, config, ref); + const head = outputHead(exchange, ref); + if (!head || head === state.previous) return null; + const quarantine = mkdtempSync(path.join(dir, "import-")); + try { + git(quarantine, ["init", "--bare", "--template="]); + copyObjects(exchange, quarantine); + git(quarantine, ["update-ref", "refs/heads/output", head]); + git(quarantine, ["fsck", "--full", "--strict", "--no-reflogs"]); + oidSchema.parse(git(quarantine, ["rev-parse", "--verify", `${head}^{commit}`])); + git(quarantine, ["merge-base", "--is-ancestor", state.previous ?? state.base, head]); + checkTrackedSecrets(quarantine, [head]); + const branch = `refs/heads/agent/${ref}`; + const current = optionalHead(project.repo, branch); + if (current === head) return head; // An earlier attempt already imported. + if (current !== state.previous) + throw new Error(`host branch ${branch} changed during worker session; refusing to overwrite`); + const checkedOut = git(project.repo, ["worktree", "list", "--porcelain"]); + if (checkedOut.split("\n").includes(`branch ${branch}`)) + throw new Error(`host branch ${branch} is checked out; refusing to change its HEAD`); + // Fetch from the trusted reconstructed repository, never from the mount. + // No refspec destination means this only transfers objects/FETCH_HEAD. + git(project.repo, [ + "fetch", + "--no-tags", + "--no-write-fetch-head", + quarantine, + "refs/heads/output", + ]); + git(project.repo, ["update-ref", branch, head, current ?? "0".repeat(40)]); + return head; + } finally { + rmSync(quarantine, { recursive: true, force: true }); + } +} + +export function clearWorkerExchange(repo: string, config: WorkerConfig, ref: string): void { + const dir = directory(repo, config, ref); + if (!existsSync(dir)) return; + // Rename first: a partial recursive deletion is never mistaken for a + // recoverable session. Node's rm removes symlinks rather than following them. + const retired = `${dir}.retired-${process.pid}`; + renameSync(dir, retired); + rmSync(retired, { recursive: true, force: true }); +} diff --git a/scripts/worker-publications.ts b/scripts/worker-publications.ts index b2e967bd..75fe2d95 100644 --- a/scripts/worker-publications.ts +++ b/scripts/worker-publications.ts @@ -12,6 +12,8 @@ const publicationSchema = z.object({ issueTitle: z.string(), sessionId: z.number().int().positive().nullable(), exitCode: z.number().int().nullable(), + /** New sessions import a sanitized exchange; absent on pre-upgrade markers. */ + exchange: z.boolean().optional(), }); export type PendingPublication = z.infer; diff --git a/scripts/worker-select.ts b/scripts/worker-select.ts index f46e00be..1efbdaf1 100644 --- a/scripts/worker-select.ts +++ b/scripts/worker-select.ts @@ -1,6 +1,7 @@ // Pure dispatch-selection logic for scripts/agent-worker.ts. // Kept separate from the polling/spawning loop so it's trivially unit-testable. +import { workerExchangeRepo } from "./worker-exchange.js"; import { DEFAULT_CODEX_IMAGE } from "./engines/codex.js"; import { DEFAULT_GEMINI_IMAGE, @@ -1281,7 +1282,7 @@ export function buildDockerArgs( "no-new-privileges", ...egressDockerArgs(config), "-v", - `${project.repo}:/origin`, + `${workerExchangeRepo(project.repo, config, issue.ref)}:/origin`, "-e", `ISSUE_REF=${issue.ref}`, "-e", diff --git a/tests/scripts/agent-worker-publish.test.ts b/tests/scripts/agent-worker-publish.test.ts index e083d46e..75737329 100644 --- a/tests/scripts/agent-worker-publish.test.ts +++ b/tests/scripts/agent-worker-publish.test.ts @@ -1,3 +1,4 @@ +import { prepareWorkerExchange, clearWorkerExchange } from "../../scripts/worker-exchange.js"; // The host-side publish step's pr_opened event gains repo + headSha + // ghUpdatedAt (SYD-205): the worker is the named freshness producer for agent // PRs at publish time — without headSha here, pr_state (SYD-206) has no @@ -23,6 +24,13 @@ vi.mock("../../scripts/worker-publications.js", () => ({ clearPublication: vi.fn(), })); +vi.mock("../../scripts/worker-exchange.js", async (importOriginal) => ({ + ...(await importOriginal()), + prepareWorkerExchange: vi.fn(), + importWorkerExchange: vi.fn(), + clearWorkerExchange: vi.fn(), +})); + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { @@ -220,6 +228,26 @@ describe("publish failure surfaces on the board (SYD-257)", () => { }); describe("publication intent cleanup", () => { + it("cleans this attempt when exchange preparation fails before spawn", () => { + vi.mocked(prepareWorkerExchange).mockImplementationOnce(() => { + throw new Error("root .env tracked"); + }); + dispatch(issue, config, "tok", "code"); + expect(savePublication).toHaveBeenCalledOnce(); + expect(clearWorkerExchange).toHaveBeenCalledWith("/repo/syd", config, "SYD-9"); + expect(clearPublication).toHaveBeenCalledWith("/repo/syd", config, "SYD-9"); + expect(spawnMock).not.toHaveBeenCalled(); + }); + it("does not overwrite or clear an existing publication obligation", () => { + vi.mocked(readPublications).mockReturnValue([ + { ref: issue.ref, issueTitle: "Existing", sessionId: 99, exitCode: null, exchange: true }, + ]); + dispatch(issue, config, "tok", "code"); + expect(savePublication).not.toHaveBeenCalled(); + expect(clearPublication).not.toHaveBeenCalled(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + it("clears the intent when spawn throws before starting a container", () => { spawnMock.mockImplementation(() => { throw new Error("spawn setup failed"); diff --git a/tests/scripts/agent-worker.test.ts b/tests/scripts/agent-worker.test.ts index e2e88a4e..b6fb66f7 100644 --- a/tests/scripts/agent-worker.test.ts +++ b/tests/scripts/agent-worker.test.ts @@ -11,9 +11,24 @@ vi.mock("../../scripts/worker-publications.js", () => ({ clearPublication: vi.fn(), })); +vi.mock("../../scripts/worker-exchange.js", async (importOriginal) => ({ + ...(await importOriginal()), + prepareWorkerExchange: vi.fn(), + importWorkerExchange: vi.fn(), + clearWorkerExchange: vi.fn(), +})); + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, spawn: (...args: unknown[]) => spawnMock(...args) }; + return { + ...actual, + spawn: (...args: unknown[]) => spawnMock(...args), + execFile: ( + _command: string, + _args: string[], + callback: (error: Error | null, result: { stdout: string; stderr: string }) => void, + ) => callback(null, { stdout: "", stderr: "" }), + }; }); vi.mock("node:fs", async (importOriginal) => { diff --git a/tests/scripts/delivery-lib.test.ts b/tests/scripts/delivery-lib.test.ts index 87de4c1b..b74489ec 100644 --- a/tests/scripts/delivery-lib.test.ts +++ b/tests/scripts/delivery-lib.test.ts @@ -648,9 +648,10 @@ describe("publish-failure comment (SYD-257)", () => { expect(body).toContain("ssh: connect to host github.com: config error"); }); - it("says there is no PR yet, unlike a merge-time delivery failure", () => { + it("retains recoverable work without claiming import or PR creation succeeded", () => { const body = publishFailureComment("SYD-9", "boom"); - expect(body).toContain("no PR yet"); + expect(body).toContain("retained for retry"); + expect(body).not.toContain("in the host repo"); }); }); diff --git a/tests/scripts/worker-exchange.test.ts b/tests/scripts/worker-exchange.test.ts new file mode 100644 index 00000000..77d241ce --- /dev/null +++ b/tests/scripts/worker-exchange.test.ts @@ -0,0 +1,289 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, + truncateSync, +} from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + prepareWorkerExchange, + importWorkerExchange, + clearWorkerExchange, +} from "../../scripts/worker-exchange.js"; +import type { WorkerConfig } from "../../scripts/worker-select.js"; + +let root: string; +let repo: string; +let config: WorkerConfig; +const ref = "SYD-265"; +const publish = vi.fn(); +vi.mock("../../scripts/delivery-exec.js", () => ({ + publishAgentBranch: (...args: unknown[]) => publish(...args), + prFreshness: vi.fn(), + originOwnerRepo: vi.fn(), +})); +const { recoverPublications } = await import("../../scripts/agent-worker.js"); +const { savePublication, readPublications } = await import("../../scripts/worker-publications.js"); +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-C", cwd, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + GIT_AUTHOR_NAME: "test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }).trim(); +} +function commit(cwd: string, name: string, value = name): string { + writeFileSync(path.join(cwd, name), value); + git(cwd, "add", name); + git(cwd, "commit", "-m", name); + return git(cwd, "rev-parse", "HEAD"); +} +function worker(exchange: string): string { + const clone = path.join(root, `clone-${Math.random()}`); + git(root, "clone", "--no-local", exchange, clone); + git( + clone, + "checkout", + "-B", + `agent/${ref}`, + git(clone, "branch", "-r").includes(`origin/agent/${ref}`) + ? `origin/agent/${ref}` + : "origin/main", + ); + return clone; +} +beforeEach(() => { + publish.mockClear(); + root = mkdtempSync(path.join(os.tmpdir(), "worker-exchange-")); + vi.spyOn(os, "homedir").mockReturnValue(path.join(root, "home")); + repo = path.join(root, "host"); + mkdirSync(repo); + git(repo, "init", "--initial-branch=main"); + commit(repo, "source.ts"); + config = { + url: "http://tracker", + label: "worker", + intervalSeconds: 5, + maxConcurrent: 1, + containerized: true, + projects: { SYD: { repo } }, + }; +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + rmSync(root, { recursive: true, force: true }); +}); + +describe("container Git exchange boundary", () => { + it("exports only selected committed history and imports only the expected branch without host changes", () => { + writeFileSync(path.join(repo, ".env"), "DUMMY_SECRET=test-only"); + mkdirSync(path.join(repo, ".superpowers")); + writeFileSync(path.join(repo, ".superpowers", "lease"), "dummy"); + writeFileSync(path.join(repo, "source.ts"), "uncommitted host work"); + git(repo, "branch", "private"); + const base = git(repo, "rev-parse", "main"); + const exchange = prepareWorkerExchange({ repo }, config, ref); + expect(exchange.startsWith(repo)).toBe(false); + expect(git(exchange, "config", "--get-regexp", "core")).not.toContain(repo); + expect(git(exchange, "for-each-ref", "--format=%(refname)")).toBe("refs/heads/main"); + const clone = worker(exchange); + expect(existsSync(path.join(clone, ".env"))).toBe(false); + expect(existsSync(path.join(clone, ".superpowers"))).toBe(false); + expect(readFileSync(path.join(clone, "source.ts"), "utf8")).toBe("source.ts"); + const output = commit(clone, "fix.ts"); + git(clone, "push", "origin", `agent/${ref}`); + git(clone, "push", "origin", "HEAD:refs/heads/main", "HEAD:refs/heads/evil"); + expect(importWorkerExchange({ repo }, config, ref)).toBe(output); + expect(git(repo, "rev-parse", `agent/${ref}`)).toBe(output); + expect(git(repo, "rev-parse", "main")).toBe(base); + expect(git(repo, "branch", "--list", "evil")).toBe(""); + expect(readFileSync(path.join(repo, "source.ts"), "utf8")).toBe("uncommitted host work"); + expect(importWorkerExchange({ repo }, config, ref)).toBe(output); + clearWorkerExchange(repo, config, ref); + expect(existsSync(exchange)).toBe(false); + }); + + it("never executes exchange config/hooks, follows alternates, or honors inherited Git overrides", () => { + const exchange = prepareWorkerExchange({ repo }, config, ref); + const clone = worker(exchange); + const head = commit(clone, "fix.ts"); + git(clone, "push", "origin", `agent/${ref}`); + const marker = path.join(root, "host-code-executed"); + writeFileSync( + path.join(exchange, "config"), + `[core]\n bare = true\n hooksPath = ${root}\n fsmonitor = touch ${marker}\n[include]\n path = ${path.join(repo, ".env")}\n`, + ); + writeFileSync( + path.join(exchange, "objects", "info", "alternates"), + path.join(repo, ".git", "objects"), + ); + mkdirSync(path.join(exchange, "hooks")); + writeFileSync( + path.join(exchange, "hooks", "reference-transaction"), + `#!/bin/sh\ntouch ${marker}\n`, + { mode: 0o755 }, + ); + vi.stubEnv("GIT_CONFIG_COUNT", "1"); + vi.stubEnv("GIT_CONFIG_KEY_0", "core.hooksPath"); + vi.stubEnv("GIT_CONFIG_VALUE_0", root); + expect(importWorkerExchange({ repo }, config, ref)).toBe(head); + expect(existsSync(marker)).toBe(false); + }); + + it.each(["ref", "refs directory", "objects directory", "pack file"])( + "rejects a worker-planted symlink in %s without importing", + (kind) => { + const exchange = prepareWorkerExchange({ repo }, config, ref); + const clone = worker(exchange); + commit(clone, "fix.ts"); + git(clone, "push", "origin", `agent/${ref}`); + const victim = path.join(root, "dummy-host-file"); + writeFileSync(victim, "test-only"); + const target = + kind === "ref" + ? path.join(exchange, "refs", "heads", "agent", ref) + : kind === "refs directory" + ? path.join(exchange, "refs") + : kind === "objects directory" + ? path.join(exchange, "objects") + : path.join(exchange, "objects", "pack", `pack-${"a".repeat(40)}.pack`); + rmSync(target, { recursive: true, force: true }); + symlinkSync(kind.endsWith("directory") ? repo : victim, target); + expect(() => importWorkerExchange({ repo }, config, ref)).toThrow(/unsafe/); + expect(git(repo, "branch", "--list", `agent/${ref}`)).toBe(""); + expect(readFileSync(victim, "utf8")).toBe("test-only"); + }, + ); + + it("resumes existing agent work, rejects divergent output and concurrent host changes", () => { + git(repo, "checkout", "-b", `agent/${ref}`); + const previous = commit(repo, "prior.ts"); + git(repo, "checkout", "main"); + const exchange = prepareWorkerExchange({ repo }, config, ref); + const clone = worker(exchange); + expect(git(clone, "rev-parse", "HEAD")).toBe(previous); + git(clone, "reset", "--hard", "origin/main"); + commit(clone, "divergent.ts"); + git(clone, "push", "--force", "origin", `agent/${ref}`); + expect(() => importWorkerExchange({ repo }, config, ref)).toThrow(); + git(clone, "reset", "--hard", previous); + commit(clone, "fix.ts"); + git(clone, "push", "--force", "origin", `agent/${ref}`); + git(repo, "update-ref", `refs/heads/agent/${ref}`, git(repo, "rev-parse", "main")); + expect(() => importWorkerExchange({ repo }, config, ref)).toThrow(/changed during/); + }); + + it("blocks root operational secrets in history but permits fixtures and examples", () => { + commit(repo, ".env.example", "DUMMY=test"); + mkdirSync(path.join(repo, "fixtures")); + commit(repo, "fixtures/.env", "DUMMY=test"); + const exchange = prepareWorkerExchange({ repo }, config, ref); + expect(existsSync(exchange)).toBe(true); + clearWorkerExchange(repo, config, ref); + commit(repo, ".env", "DUMMY=test"); + git(repo, "rm", ".env"); + git(repo, "commit", "-m", "remove dummy secret"); + expect(() => prepareWorkerExchange({ repo }, config, ref)).toThrow(/tracked host-only path/); + }); + it("rejects merge-introduced root .env even when ordinary history hides its diff", () => { + git(repo, "checkout", "-b", "topic"); + commit(repo, "topic.ts"); + git(repo, "checkout", "main"); + commit(repo, "main.ts"); + git(repo, "merge", "--no-commit", "topic"); + writeFileSync(path.join(repo, ".env"), "DUMMY=test-only"); + git(repo, "add", ".env"); + git(repo, "commit", "-m", "merge with dummy env"); + expect(() => prepareWorkerExchange({ repo }, config, ref)).toThrow(/tracked host-only/); + }); + + it("bounds worker-controlled sparse pack sizes before reading them", () => { + const exchange = prepareWorkerExchange({ repo }, config, ref); + const clone = worker(exchange); + commit(clone, "fix.ts"); + git(clone, "push", "origin", `agent/${ref}`); + const pack = path.join(exchange, "objects", "pack", `pack-${"a".repeat(40)}.pack`); + writeFileSync(pack, ""); + truncateSync(pack, 2 * 1024 * 1024 * 1024 + 1); + expect(() => importWorkerExchange({ repo }, config, ref)).toThrow(/size limit/); + expect(git(repo, "branch", "--list", `agent/${ref}`)).toBe(""); + }); + + it.each([undefined, { openPrs: false }])( + "recovers real committed output with PR publication disabled: %j", + async (delivery) => { + config.delivery = delivery; + const exchange = prepareWorkerExchange({ repo }, config, ref); + const clone = worker(exchange); + const head = commit(clone, "fix.ts"); + git(clone, "push", "origin", `agent/${ref}`); + savePublication(repo, config, { + ref, + issueTitle: "Fix", + sessionId: null, + exitCode: null, + exchange: true, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ); + await recoverPublications(config, "dummy", new Set([`syd-${ref}`])); + expect(readPublications(repo, config)).toHaveLength(1); + expect(git(repo, "branch", "--list", `agent/${ref}`)).toBe(""); + await recoverPublications(config, "dummy", new Set()); + expect(git(repo, "rev-parse", `agent/${ref}`)).toBe(head); + expect(readPublications(repo, config)).toEqual([]); + expect(existsSync(exchange)).toBe(false); + expect(publish).not.toHaveBeenCalled(); + }, + ); + + it("cleans interrupted preparation without publishing an older host agent branch", async () => { + git(repo, "branch", `agent/${ref}`); + config.delivery = { openPrs: true }; + const exchange = prepareWorkerExchange({ repo }, config, ref); + savePublication(repo, config, { + ref, + issueTitle: "Fix", + sessionId: null, + exitCode: null, + exchange: true, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ); + // Death before spawning: only the pre-existing agent branch was seeded. + await recoverPublications(config, "dummy", new Set()); + expect(publish).not.toHaveBeenCalled(); + expect(readPublications(repo, config)).toEqual([]); + // Death mid-preparation: state.json was not atomically installed yet. + savePublication(repo, config, { + ref, + issueTitle: "Fix", + sessionId: null, + exitCode: null, + exchange: true, + }); + mkdirSync(exchange, { recursive: true }); + await recoverPublications(config, "dummy", new Set()); + expect(publish).not.toHaveBeenCalled(); + expect(readPublications(repo, config)).toEqual([]); + expect(existsSync(exchange)).toBe(false); + }); +}); diff --git a/tests/scripts/worker-select.test.ts b/tests/scripts/worker-select.test.ts index 7a58ff4e..b4aaebda 100644 --- a/tests/scripts/worker-select.test.ts +++ b/tests/scripts/worker-select.test.ts @@ -1,3 +1,4 @@ +import { workerExchangeRepo } from "../../scripts/worker-exchange.js"; import { describe, it, expect, vi, afterEach } from "vitest"; import { selectDispatchable, @@ -1076,10 +1077,11 @@ describe("buildDockerArgs", () => { expect(args.some((a) => a.includes("_PROXY") || a.includes("_proxy"))).toBe(false); }); - it("mounts the right repo", () => { + it("mounts only the per-session exchange outside the host checkout", () => { const args = buildDockerArgs(issue({ ref: "SYD-1" }), project, config, oauthEnv); const vIndex = args.indexOf("-v"); - expect(args[vIndex + 1]).toBe("/repo/syd:/origin"); + expect(args[vIndex + 1]).toBe(`${workerExchangeRepo(project.repo, config, "SYD-1")}:/origin`); + expect(args).not.toContain("/repo/syd:/origin"); }); it("passes the issue ref through as ISSUE_REF and the container name", () => { From bdf36ca04d3d1ddc9885a29527166eb6742522c6 Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:13:08 -0400 Subject: [PATCH 5/8] fix: check configured worker engine prerequisites in doctor (#267) --- scripts/init-worker.ts | 91 +++++++++++++----- scripts/worker-select.ts | 20 ++-- tests/scripts/init-worker.test.ts | 153 +++++++++++++++++++++++++++++- 3 files changed, 232 insertions(+), 32 deletions(-) diff --git a/scripts/init-worker.ts b/scripts/init-worker.ts index 1c36a75d..2b2d66d4 100644 --- a/scripts/init-worker.ts +++ b/scripts/init-worker.ts @@ -67,7 +67,7 @@ import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { WorkerConfig, WorkerProject, WorkerRole } from "./worker-select.js"; -import { workerPidFileName } from "./worker-select.js"; +import { workerImage, workerPidFileName } from "./worker-select.js"; import { resolveDeliveryToken, resolvePollerToken, tokenSourceName } from "./delivery-lib.js"; import { isLocked } from "./pidfile.js"; import { @@ -176,7 +176,7 @@ function checkPlistPinnedNode(opts: { */ export function runsOk(cmd: string, config: WorkerConfig): boolean { if (config.containerized) { - const image = config.image ?? "switchyard-worker"; + const image = workerImage(config); return ( spawnSync("docker", ["run", "--rm", "--entrypoint", "sh", image, "-c", cmd], { stdio: "ignore", @@ -199,7 +199,7 @@ export function checkProjectStack( if (stack.node) { let actual: string | null; if (config.containerized) { - const image = config.image ?? "switchyard-worker"; + const image = workerImage(config); const out = spawnSync("docker", ["run", "--rm", "--entrypoint", "node", image, "--version"], { encoding: "utf8", }); @@ -238,6 +238,66 @@ export function checkProjectStack( return results; } +/** Check the image and credentials the selected container engine actually uses. */ +export function checkContainerPrerequisites( + config: WorkerConfig, + env: NodeJS.ProcessEnv, +): CheckResult[] { + const engine = config.engine ?? "claude"; + const image = workerImage(config); + const hasDocker = commandExists("docker"); + const results: CheckResult[] = [{ name: "docker CLI", ok: hasDocker }]; + if (hasDocker) { + const inspect = spawnSync("docker", ["image", "inspect", image], { stdio: "ignore" }); + const suffix = engine === "claude" ? "" : `-${engine}`; + const buildHint = + image === workerImage({ engine }) + ? `run: npm run build:worker-image${suffix}` + : `build or pull the configured ${engine}-compatible image "${image}"`; + results.push({ + name: `worker image "${image}"`, + ok: inspect.status === 0, + note: inspect.status === 0 ? undefined : `not built — ${buildHint}`, + }); + } + if (engine === "codex") { + results.push( + { + name: "CODEX_OAUTH_TOKEN", + ok: Boolean(env.CODEX_OAUTH_TOKEN), + note: env.CODEX_OAUTH_TOKEN + ? "in .env / environment" + : "required for the credential-injecting proxy's ChatGPT authentication", + }, + { + name: "CODEX_ACCOUNT_ID", + ok: Boolean(env.CODEX_ACCOUNT_ID), + note: env.CODEX_ACCOUNT_ID + ? "in .env / environment" + : "required by the Codex container: the non-secret ChatGPT account UUID", + }, + ); + } else if (engine === "gemini") { + results.push({ + name: "GEMINI_API_KEY", + ok: Boolean(env.GEMINI_API_KEY), + note: env.GEMINI_API_KEY + ? "in .env / environment" + : "required for Gemini API-key authentication", + }); + } else { + const hasClaudeAuth = Boolean(env.CLAUDE_CODE_OAUTH_TOKEN || env.ANTHROPIC_API_KEY); + results.push({ + name: "CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY)", + ok: hasClaudeAuth, + note: hasClaudeAuth + ? "in .env / environment" + : "required for containerized sessions — `claude setup-token`", + }); + } + return results; +} + /** * Fetches actually-reporting check-runs and statuses for the latest commit * on the specified ref (branch/commit/etc.) using the `gh api` CLI. @@ -564,25 +624,7 @@ async function doctor(): Promise<{ results: CheckResult[]; config: WorkerConfig : "not set — SDK sessions fall back to the local claude login", }); } else if (config?.containerized) { - const hasDocker = commandExists("docker"); - results.push({ name: "docker CLI", ok: hasDocker }); - if (hasDocker) { - const image = config.image ?? "switchyard-worker"; - const inspect = spawnSync("docker", ["image", "inspect", image], { stdio: "ignore" }); - results.push({ - name: `worker image "${image}"`, - ok: inspect.status === 0, - note: inspect.status === 0 ? undefined : "not built — run: npm run build:worker-image", - }); - } - const hasClaudeAuth = Boolean(env.CLAUDE_CODE_OAUTH_TOKEN || env.ANTHROPIC_API_KEY); - results.push({ - name: "CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY)", - ok: hasClaudeAuth, - note: hasClaudeAuth - ? "in .env / environment" - : "required for containerized sessions — `claude setup-token`", - }); + results.push(...checkContainerPrerequisites(config, env)); } else { results.push({ name: "claude CLI", @@ -609,8 +651,9 @@ async function doctor(): Promise<{ results: CheckResult[]; config: WorkerConfig : `mode ${mode.toString(8)} is group/world-readable — run: chmod 600 .env`, }); } - const token = env.SWITCHYARD_TOKEN; - results.push({ name: "SWITCHYARD_TOKEN", ok: Boolean(token) }); + const tokenVar = config?.token ?? "SWITCHYARD_TOKEN"; + const token = env[tokenVar]; + results.push({ name: tokenVar, ok: Boolean(token) }); if (config) { const base = config.url.replace(/\/$/, ""); diff --git a/scripts/worker-select.ts b/scripts/worker-select.ts index 1efbdaf1..d48118ad 100644 --- a/scripts/worker-select.ts +++ b/scripts/worker-select.ts @@ -191,6 +191,18 @@ const DEFAULT_ALLOWED_TOOLS = [ "Glob", ]; const DEFAULT_WORKER_IMAGE = "switchyard-worker"; + +/** Use the same image for dispatch and every doctor probe. */ +export function workerImage(config: Pick): string { + return ( + config.image ?? + (config.engine === "codex" + ? DEFAULT_CODEX_IMAGE + : config.engine === "gemini" + ? DEFAULT_GEMINI_IMAGE + : DEFAULT_WORKER_IMAGE) + ); +} export const DEFAULT_MAX_ANSWER_CONCURRENT = 2; const DEFAULT_BASE_BRANCH = "main"; export const DEFAULT_SESSION_TIMEOUT_SECONDS = 3600; @@ -1211,13 +1223,7 @@ export function buildDockerArgs( const allowedTools = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS; const baseBranch = project.baseBranch ?? DEFAULT_BASE_BRANCH; const prompt = buildContainerizedPrompt(issue.ref, { ...opts, baseBranch }); - const image = - config.image ?? - (engine === "codex" - ? DEFAULT_CODEX_IMAGE - : engine === "gemini" - ? DEFAULT_GEMINI_IMAGE - : DEFAULT_WORKER_IMAGE); + const image = workerImage(config); const stackChecks = stackChecksEnv(project.stack); // Provider-credential handling depends on the egress mode (SYD-186) and, diff --git a/tests/scripts/init-worker.test.ts b/tests/scripts/init-worker.test.ts index f32d2a23..8719036a 100644 --- a/tests/scripts/init-worker.test.ts +++ b/tests/scripts/init-worker.test.ts @@ -12,7 +12,158 @@ vi.mock("node:child_process", async (importOriginal) => { }); // Import after mocking -const { runsOk, checkProjectStack } = await import("../../scripts/init-worker.js"); +const { runsOk, checkProjectStack, checkContainerPrerequisites } = + await import("../../scripts/init-worker.js"); + +describe("engine-aware container prerequisites (#267)", () => { + const base: WorkerConfig = { + url: "http://localhost:3300", + label: "auto", + intervalSeconds: 30, + maxConcurrent: 1, + projects: {}, + containerized: true, + }; + const engines = [ + { + engine: "claude", + image: "switchyard-worker", + env: { ANTHROPIC_API_KEY: "test-claude" }, + suffix: "", + }, + { + engine: "codex", + image: "switchyard-worker-codex", + env: { CODEX_OAUTH_TOKEN: "test-codex", CODEX_ACCOUNT_ID: "test-account" }, + suffix: "-codex", + }, + { + engine: "gemini", + image: "switchyard-worker-gemini", + env: { GEMINI_API_KEY: "test-gemini" }, + suffix: "-gemini", + }, + ] as const; + + beforeEach(() => { + spawnSyncMock.mockReset(); + spawnSyncMock.mockReturnValue({ status: 0, stdout: "v24.19.0\n" }); + }); + + it.each(engines)( + "accepts only $engine credentials and probes its default image", + ({ engine, image, env }) => { + const config = { ...base, engine }; + const results = checkContainerPrerequisites(config, env); + expect(results.every((result) => result.ok)).toBe(true); + expect(spawnSyncMock).toHaveBeenCalledWith("docker", ["image", "inspect", image], { + stdio: "ignore", + }); + expect(runsOk("git --version", config)).toBe(true); + expect(spawnSyncMock).toHaveBeenCalledWith( + "docker", + ["run", "--rm", "--entrypoint", "sh", image, "-c", "git --version"], + { stdio: "ignore" }, + ); + expect(checkProjectStack("SYD", { repo: "/repo", stack: { node: "24" } }, config)[0].ok).toBe( + true, + ); + expect(spawnSyncMock).toHaveBeenCalledWith( + "docker", + ["run", "--rm", "--entrypoint", "node", image, "--version"], + { encoding: "utf8" }, + ); + for (const value of Object.values(env)) expect(JSON.stringify(results)).not.toContain(value); + }, + ); + + it.each(engines)("honors an explicit image override for $engine", ({ engine, env }) => { + const config = { ...base, engine, image: "registry.example/worker:custom" }; + expect(checkContainerPrerequisites(config, env).every((result) => result.ok)).toBe(true); + expect(spawnSyncMock).toHaveBeenCalledWith("docker", ["image", "inspect", config.image], { + stdio: "ignore", + }); + runsOk("git --version", config); + expect(spawnSyncMock).toHaveBeenCalledWith( + "docker", + ["run", "--rm", "--entrypoint", "sh", config.image, "-c", "git --version"], + { stdio: "ignore" }, + ); + }); + + it.each(engines)( + "gives the correct build command for a missing $engine image", + ({ engine, env, suffix }) => { + spawnSyncMock.mockReturnValueOnce({ status: 0 }).mockReturnValueOnce({ status: 1 }); + const results = checkContainerPrerequisites({ ...base, engine }, env); + expect(results.find((result) => result.name.startsWith("worker image"))).toMatchObject({ + ok: false, + note: `not built — run: npm run build:worker-image${suffix}`, + }); + }, + ); + + it("does not suggest building a default image when an overridden image is missing", () => { + spawnSyncMock.mockReturnValueOnce({ status: 0 }).mockReturnValueOnce({ status: 1 }); + const results = checkContainerPrerequisites( + { ...base, engine: "gemini", image: "custom-image" }, + { GEMINI_API_KEY: "test-gemini" }, + ); + expect(results.find((result) => result.name.startsWith("worker image"))).toMatchObject({ + ok: false, + note: 'not built — build or pull the configured gemini-compatible image "custom-image"', + }); + }); + + it("accepts either Claude credential, including the default engine", () => { + for (const env of [ + { CLAUDE_CODE_OAUTH_TOKEN: "test-oauth" }, + { ANTHROPIC_API_KEY: "test-key" }, + ]) { + expect(checkContainerPrerequisites(base, env).every((result) => result.ok)).toBe(true); + } + expect( + checkContainerPrerequisites(base, {}) + .filter((result) => !result.ok) + .map((result) => result.name), + ).toEqual(["CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY)"]); + }); + + it("requires both Codex OAuth and account ID without accepting Claude credentials", () => { + const config = { ...base, engine: "codex" as const }; + expect( + checkContainerPrerequisites(config, { ANTHROPIC_API_KEY: "test-claude" }) + .filter((result) => !result.ok) + .map((result) => result.name), + ).toEqual(["CODEX_OAUTH_TOKEN", "CODEX_ACCOUNT_ID"]); + expect( + checkContainerPrerequisites(config, { CODEX_OAUTH_TOKEN: "test-codex" }) + .filter((result) => !result.ok) + .map((result) => result.name), + ).toEqual(["CODEX_ACCOUNT_ID"]); + }); + + it("requires a Gemini API key without accepting another engine's credentials", () => { + expect( + checkContainerPrerequisites( + { ...base, engine: "gemini" }, + { CODEX_OAUTH_TOKEN: "test-codex", ANTHROPIC_API_KEY: "test-claude" }, + ) + .filter((result) => !result.ok) + .map((result) => result.name), + ).toEqual(["GEMINI_API_KEY"]); + }); + + it("reports missing Docker without attempting an image inspection", () => { + spawnSyncMock.mockReturnValue({ status: 1 }); + expect( + checkContainerPrerequisites({ ...base, engine: "gemini" }, { GEMINI_API_KEY: "test-gemini" }) + .filter((result) => !result.ok) + .map((result) => result.name), + ).toEqual(["docker CLI"]); + expect(spawnSyncMock).toHaveBeenCalledTimes(1); + }); +}); describe("init-worker: runsOk", () => { beforeEach(() => { From b63033965a95a211d635aab2ec2a2b8908427c9b Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:13:35 -0400 Subject: [PATCH 6/8] feat: configure and observe worker models and runtimes (#271) --- Dockerfile.worker | 1 + Dockerfile.worker.codex | 1 + Dockerfile.worker.gemini | 1 + README.md | 4 + docs/worker-model-benchmarks.md | 72 +++++ scripts/agent-worker.ts | 106 ++++++-- scripts/container-entry.codex.sh | 2 +- scripts/container-entry.gemini.sh | 2 +- scripts/container-entry.sh | 2 +- scripts/init-worker-lib.ts | 11 + scripts/worker-engine-runner.mjs | 143 ++++++++++ scripts/worker-image-metadata.ts | 42 +++ scripts/worker-select.ts | 10 + scripts/worker-telemetry.d.mts | 14 + scripts/worker-telemetry.mjs | 154 +++++++++++ tests/scripts/agent-worker.test.ts | 17 ++ tests/scripts/worker-telemetry.test.ts | 354 +++++++++++++++++++++++++ worker-sdk/sdk-runner.test.ts | 40 +++ worker-sdk/sdk-runner.ts | 33 ++- 19 files changed, 987 insertions(+), 22 deletions(-) create mode 100644 docs/worker-model-benchmarks.md create mode 100644 scripts/worker-engine-runner.mjs create mode 100644 scripts/worker-image-metadata.ts create mode 100644 scripts/worker-telemetry.d.mts create mode 100644 scripts/worker-telemetry.mjs create mode 100644 tests/scripts/worker-telemetry.test.ts diff --git a/Dockerfile.worker b/Dockerfile.worker index 758a0b6a..493b9aba 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -43,6 +43,7 @@ RUN npm install -g pnpm@${PNPM_VERSION} \ COPY scripts/container-entry.sh /entry.sh COPY scripts/prime-workspace-trust.mjs /prime-workspace-trust.mjs COPY scripts/install-guard.mjs /install-guard.mjs +COPY scripts/worker-engine-runner.mjs scripts/worker-telemetry.mjs / RUN chmod +x /entry.sh # Out-of-band attachment uploader (SYD-182): baked in so any dispatched worker diff --git a/Dockerfile.worker.codex b/Dockerfile.worker.codex index 880d79b1..9a0cf571 100644 --- a/Dockerfile.worker.codex +++ b/Dockerfile.worker.codex @@ -36,6 +36,7 @@ RUN npm install -g pnpm@${PNPM_VERSION} \ COPY scripts/container-entry.codex.sh /entry.sh COPY scripts/prime-workspace-trust.mjs /prime-workspace-trust.mjs COPY scripts/install-guard.mjs /install-guard.mjs +COPY scripts/worker-engine-runner.mjs scripts/worker-telemetry.mjs / RUN chmod +x /entry.sh # Out-of-band attachment uploader (SYD-182): baked in so any dispatched worker diff --git a/Dockerfile.worker.gemini b/Dockerfile.worker.gemini index f07d31b0..b59274ae 100644 --- a/Dockerfile.worker.gemini +++ b/Dockerfile.worker.gemini @@ -36,6 +36,7 @@ RUN npm install -g pnpm@${PNPM_VERSION} \ COPY scripts/container-entry.gemini.sh /entry.sh COPY scripts/prime-workspace-trust.mjs /prime-workspace-trust.mjs COPY scripts/install-guard.mjs /install-guard.mjs +COPY scripts/worker-engine-runner.mjs scripts/worker-telemetry.mjs / RUN chmod +x /entry.sh # Out-of-band attachment uploader (SYD-182): baked in so any dispatched worker diff --git a/README.md b/README.md index 26cad621..af139ea8 100644 --- a/README.md +++ b/README.md @@ -524,3 +524,7 @@ npm run typecheck Token-lean architecture maps for coding agents live in `codemaps/` (generated — regenerate with the `/update-codemaps` skill rather than hand-editing). + +For optional per-worker/project model selection, recorded runtime versions and +provider-reported models, and an operator-run comparison procedure, see +[worker model selection and benchmarks](docs/worker-model-benchmarks.md). diff --git a/docs/worker-model-benchmarks.md b/docs/worker-model-benchmarks.md new file mode 100644 index 00000000..bb6b5081 --- /dev/null +++ b/docs/worker-model-benchmarks.md @@ -0,0 +1,72 @@ +# Worker model selection and session metadata + +Set optional `model` on a worker configuration to request a provider model or +alias. A project's `model` overrides the worker value. Omitting both preserves +the engine's existing defaults; Switchyard never automatically upgrades or +switches models. Model identifiers are limited to 200 letters, digits and +`._:/-`, beginning with a letter or digit. This validates argument safety, not +provider availability or account entitlement. + +```json +{ + "engine": "claude", + "model": "sonnet", + "projects": { + "SYD": { "repo": "/repos/switchyard", "model": "opus" }, + "APP": { "repo": "/repos/app" } + } +} +``` + +Merge these fields into a complete worker configuration. SYD requests `opus`, +APP requests `sonnet`. The same precedence applies to work and answer sessions, +Claude CLI/SDK, and supported containerized Codex/Gemini workers. A model name +must belong to that worker's engine. Run `npm run init-worker -- --self-test` +after editing configuration, then restart the worker. + +Session logs in `.superpowers/worker-logs/` contain JSON records correlated by a unique `session_key`: + +- `worker.runtime`: requested model, installed CLI or SDK version, start time. + A missing version is `null`, never inferred from a Dockerfile or lockfile. +- `worker.image`: the actual running container's image ID and available registry + digests. A local build may have an ID without registry digests; unavailable + Docker inspection is recorded as unknown. Host CLI/SDK sessions have no image. +- `worker.event`: engine, provider event type, emitted model names, usage, + cost/duration where supplied, tool names and completion/error status. + `effective_models` is present only when the provider emits model metadata; + Codex exec streams may omit it. Aliases and routing may resolve to multiple + models. Requested and observed values are deliberately separate. +- `worker.diagnostic`, `worker.launch_error`, `worker.exit`: static, actionable + error classifications and process outcomes. CLI startup errors identify a + missing or non-executable binary; authentication, argument, quota, network, + permission and MCP problems have separate troubleshooting hints. + +CLI logs now retain **structured metadata, not verbatim transcripts**. Arbitrary +text, tool arguments/results, stderr and auth objects can expose credentials, so +unrecognized lines are counted in `worker.output_omitted`. If a failure has no +recognized diagnostic, use its exit status and check the installed CLI version, +worker configuration, provider status, and proxy health before a supervised +reproduction. Do not add raw request/auth dumps to worker logs. Existing SDK +human-readable summaries remain alongside sanitized structured event metadata. + +## Compare models on representative work + +1. Select a small fixed set of previously completed tasks: a bug with a regression + test, a scoped feature, a multi-file refactor and a task requiring MCP context. + Use isolated branches from the same base, identical tools and prompts, and + keep provider accounts, quotas and human acceptance criteria fixed. +2. Run each candidate more than once. Keep the requested and provider-emitted + model names, runtime versions and image identities with each result. If an + effective model is unknown, do not present it as verified. +3. Compare test pass rate, reviewer acceptance, follow-up edits, elapsed time, + provider-reported usage/cost and infrastructure failures. Separate unsupported + auth/configuration failures from model-quality failures. No emitted cost means + unknown cost; do not equate token count with a monetary amount. +4. Choose a model only after human review of those results. Update the explicit + worker/project value, roll out to one worker, and keep the previous setting + available for rollback. Benchmarks consume provider quota and are run by an + operator; the worker does not run evaluations or switch models automatically. + +The implementation is covered by offline fake-CLI and mocked-SDK tests; these +verify argument boundaries, metadata parsing, version capture and cancellation, +without purchasing provider sessions or establishing live provider compatibility. diff --git a/scripts/agent-worker.ts b/scripts/agent-worker.ts index e902c102..082ca02a 100644 --- a/scripts/agent-worker.ts +++ b/scripts/agent-worker.ts @@ -47,6 +47,7 @@ import { spawn, execFile, type ChildProcess } from "node:child_process"; import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; import { existsSync, readFileSync, @@ -110,6 +111,8 @@ import { type DispatchPolicy, type RunningContainerSessionRow, } from "./worker-select.js"; +import { resolveWorkerModel } from "./worker-telemetry.mjs"; +import { containerImageMetadata } from "./worker-image-metadata.js"; import { acquirePidLock, isLocked } from "./pidfile.js"; import { savePublication, @@ -866,9 +869,10 @@ export function dispatch( return; } const logPath = path.join(logDir, `${issue.ref}.log`); + const sessionKey = randomUUID(); if ((config.runner ?? "cli") === "sdk") { - dispatchSdk(issue, project.repo, config, token, role, logPath, opts); + dispatchSdk(issue, project.repo, config, token, role, logPath, { ...opts, sessionKey }); return; } @@ -950,6 +954,7 @@ export function dispatch( env: { ...process.env, SWITCHYARD_TOKEN: token, + WORKER_SESSION_KEY: sessionKey, ...(opts.leaseToken ? { SWITCHYARD_LEASE: opts.leaseToken } : {}), }, }); @@ -987,11 +992,20 @@ export function dispatch( cliMcpTmpDir = tmpDir; cliArgs.push("--mcp-config", configPath); } - child = spawn("claude", cliArgs, { - cwd: project.repo, - detached: true, - stdio: ["ignore", fd, fd], - }); + child = spawn( + process.execPath, + [path.join(repoRoot(), "scripts", "worker-engine-runner.mjs"), "claude", ...cliArgs], + { + env: { + ...process.env, + WORKER_MODEL: resolveWorkerModel(config, project) ?? "", + WORKER_SESSION_KEY: sessionKey, + }, + cwd: project.repo, + detached: true, + stdio: ["ignore", fd, fd, "ipc"], + }, + ); } } catch (err) { console.error(`failed to dispatch ${issue.ref}: ${(err as Error).message}`); @@ -1009,6 +1023,20 @@ export function dispatch( activeMode.set(issue.ref, config.containerized ? "container" : "cli"); console.log(`dispatched ${issue.ref} (pid ${child.pid}) -> ${logPath}`); + if (!config.containerized) { + child.on("message", (message) => { + if ( + message && + typeof message === "object" && + "type" in message && + message.type === "worker.launch_error" && + opts.leaseToken + ) { + void releaseClaimHost(config, token, opts.leaseToken, issue.ref); + } + }); + } + // SYD-210 Layer B: persist the lease for a container so a worker restart can // re-adopt it and resume heartbeats (see adoptContainerSession). Bare-CLI // sessions are killed on restart (not adopted), so they don't need this. @@ -1049,6 +1077,20 @@ export function dispatch( let sessionId: Promise = Promise.resolve(null); let spawned = false; child.on("spawn", () => { + if (config.containerized) { + void containerImageMetadata(containerNameFor(issue.ref)) + .then((metadata) => + logLine( + `${JSON.stringify({ type: "worker.image", session_key: sessionKey, ...metadata })}\n`, + ), + ) + .catch(() => + logLine( + `${JSON.stringify({ type: "worker.image", session_key: sessionKey, image_id: null, image_digests: [] })}\n`, + ), + ); + } + spawned = true; sessionId = reportSessionStart( config, @@ -1116,7 +1158,7 @@ function dispatchSdk( token: string, role: WorkerRole, logPath: string, - opts: { resumed?: boolean; leaseToken?: string }, + opts: { resumed?: boolean; leaseToken?: string; sessionKey: string }, ): void { const allowedTools = config.allowedTools ?? [ "mcp__switchyard__*", @@ -1167,6 +1209,8 @@ function dispatchSdk( .then((mod: { runSdkSession: (o: object) => Promise }) => mod.runSdkSession({ prompt: buildPrompt(issue.ref, opts), + model: resolveWorkerModel(config, config.projects[projectKeyOf(issue.ref)]), + sessionKey: opts.sessionKey, cwd: repo, switchyardUrl: config.url, switchyardToken: token, @@ -1247,10 +1291,11 @@ export function dispatchAnswer( const logDir = path.join(project.repo, ".superpowers", "worker-logs"); mkdirSync(logDir, { recursive: true }); const logPath = path.join(logDir, `${ref}.answer.log`); + const sessionKey = randomUUID(); if ((config.runner ?? "cli") === "sdk") { recordAnswerAttempt(answerState, ref); - dispatchAnswerSdk(ref, project.repo, config, token, logPath); + dispatchAnswerSdk(ref, project.repo, config, token, logPath, sessionKey); return; } @@ -1258,9 +1303,25 @@ export function dispatchAnswer( let child: ChildProcess; try { child = spawn( - "claude", - ["-p", buildAnswerPrompt(ref), "--allowedTools", ANSWER_ALLOWED_TOOLS.join(",")], - { cwd: project.repo, detached: true, stdio: ["ignore", fd, fd] }, + process.execPath, + [ + path.join(repoRoot(), "scripts", "worker-engine-runner.mjs"), + "claude", + "-p", + buildAnswerPrompt(ref), + "--allowedTools", + ANSWER_ALLOWED_TOOLS.join(","), + ], + { + cwd: project.repo, + detached: true, + stdio: ["ignore", fd, fd, "ipc"], + env: { + ...process.env, + WORKER_MODEL: resolveWorkerModel(config, project) ?? "", + WORKER_SESSION_KEY: sessionKey, + }, + }, ); } catch (err) { console.error(`failed to dispatch answer session for ${ref}: ${(err as Error).message}`); @@ -1284,13 +1345,19 @@ export function dispatchAnswer( killSession(child, null); }, timeoutMs); - // `child.pid` is only populated once the OS has actually spawned the - // process; reading it synchronously here printed `pid undefined` on a - // spawn failure (e.g. ENOENT for a bare `claude` not on launchd's PATH — - // SYD-74), since the 'error' event fires on a later tick. Waiting for - // 'spawn' also means a failed spawn never reaches recordAnswerAttempt, so - // environment errors can't eat the answers-per-issue cap. - child.on("spawn", () => { + // A running Node wrapper does not prove the provider binary exists. Only + // its IPC acknowledgment of the provider's spawn consumes an answer attempt. + let providerStarted = false; + child.on("message", (message) => { + if ( + providerStarted || + !message || + typeof message !== "object" || + !("type" in message) || + message.type !== "worker.provider_started" + ) + return; + providerStarted = true; recordAnswerAttempt(answerState, ref); console.log(`dispatched answer session for ${ref} (pid ${child.pid}) -> ${logPath}`); }); @@ -1317,6 +1384,7 @@ function dispatchAnswerSdk( config: WorkerConfig, token: string, logPath: string, + sessionKey: string, ): void { const key = answerKey(ref); const safeAppend = (text: string) => { @@ -1335,6 +1403,8 @@ function dispatchAnswerSdk( .then((mod: { runSdkSession: (o: object) => Promise }) => mod.runSdkSession({ prompt: buildAnswerPrompt(ref), + model: resolveWorkerModel(config, config.projects[projectKeyOf(ref)]), + sessionKey, cwd: repo, switchyardUrl: config.url, switchyardToken: token, diff --git a/scripts/container-entry.codex.sh b/scripts/container-entry.codex.sh index 48334db9..5e8f0a30 100755 --- a/scripts/container-entry.codex.sh +++ b/scripts/container-entry.codex.sh @@ -145,7 +145,7 @@ chmod 600 "$CODEX_HOME/auth.json" # --dangerously-bypass-approvals-and-sandbox disables the inner sandbox and # approval prompts; this entrypoint is only for the isolated container path. set +e -codex exec --dangerously-bypass-approvals-and-sandbox "$WORKER_PROMPT" < /dev/null +node /worker-engine-runner.mjs codex --dangerously-bypass-approvals-and-sandbox "$WORKER_PROMPT" < /dev/null CODEX_EXIT=$? set -e diff --git a/scripts/container-entry.gemini.sh b/scripts/container-entry.gemini.sh index 6be4b083..3f1a21a4 100755 --- a/scripts/container-entry.gemini.sh +++ b/scripts/container-entry.gemini.sh @@ -119,7 +119,7 @@ chmod 600 "$HOME/.gemini/settings.json" # --prompt runs one non-interactive turn. SWITCHYARD_TOKEN / SWITCHYARD_LEASE # stay exported so gemini expands them into the MCP headers at connect time. set +e -gemini --yolo --prompt "$WORKER_PROMPT" < /dev/null +node /worker-engine-runner.mjs gemini --yolo --prompt "$WORKER_PROMPT" < /dev/null GEMINI_EXIT=$? set -e diff --git a/scripts/container-entry.sh b/scripts/container-entry.sh index 7bf125ee..c62e5038 100755 --- a/scripts/container-entry.sh +++ b/scripts/container-entry.sh @@ -153,7 +153,7 @@ unset SWITCHYARD_LEASE # The container is the sandbox here, not the tool allowlist -- a generous # allowlist inside a disposable, network-scoped clone is fine. set +e -claude -p "$WORKER_PROMPT" --mcp-config /tmp/switchyard-mcp.json --permission-mode acceptEdits --allowedTools "$ALLOWED_TOOLS" +node /worker-engine-runner.mjs claude -p "$WORKER_PROMPT" --mcp-config /tmp/switchyard-mcp.json --permission-mode acceptEdits --allowedTools "$ALLOWED_TOOLS" CLAUDE_EXIT=$? set -e diff --git a/scripts/init-worker-lib.ts b/scripts/init-worker-lib.ts index 5d64906b..1e0db536 100644 --- a/scripts/init-worker-lib.ts +++ b/scripts/init-worker-lib.ts @@ -2,6 +2,7 @@ // Kept separate from the CLI so parsing, validation, and plist rendering are // trivially unit-testable without touching the filesystem or network. +import { isValidWorkerModel } from "./worker-telemetry.mjs"; import type { WorkerConfig, WorkerRole, WorkerStackCli } from "./worker-select.js"; /** Capabilities of the implemented runners, shared by doctor and dispatch. */ @@ -100,6 +101,11 @@ export function validateWorkerConfig(raw: unknown): string[] { ) { problems.push("`egressAllow` must be an array of hostnames"); } + if (c.model !== undefined && !isValidWorkerModel(c.model)) { + problems.push( + "`model` must be a non-empty model identifier (letters, digits, ., _, :, /, -; max 200 chars)", + ); + } if (c.token !== undefined && (typeof c.token !== "string" || c.token.length === 0)) { problems.push( "`token` must be a non-empty string (the NAME of the env var holding this worker's token)", @@ -133,6 +139,11 @@ export function validateWorkerConfig(raw: unknown): string[] { if (typeof project?.repo !== "string" || project.repo.trim() === "") { problems.push(`projects.${key}.repo must be a path to a local git repo`); } + if (project?.model !== undefined && !isValidWorkerModel(project.model)) { + problems.push( + `projects.${key}.model must be a non-empty model identifier (letters, digits, ., _, :, /, -; max 200 chars)`, + ); + } if (project?.stack !== undefined) { problems.push(...validateWorkerStack(key, project.stack)); } diff --git a/scripts/worker-engine-runner.mjs b/scripts/worker-engine-runner.mjs new file mode 100644 index 00000000..e5ecf6f8 --- /dev/null +++ b/scripts/worker-engine-runner.mjs @@ -0,0 +1,143 @@ +// No provider call is made by --version. Session arguments are arrays, never a +// shell command. This wrapper is baked into images and also used on the host. +import { spawn, execFileSync } from "node:child_process"; +import { + buildEngineArgs, + parseProviderLine, + parseRuntimeVersion, + classifyWorkerDiagnostic, +} from "./worker-telemetry.mjs"; + +const [engine, ...baseArgs] = process.argv.slice(2); +const requestedModel = process.env.WORKER_MODEL || undefined; +let args; +try { + args = buildEngineArgs(engine, baseArgs, requestedModel); +} catch { + process.stderr.write("[worker] invalid engine or model configuration\n"); + process.exit(2); +} +const sessionKey = /^[a-f0-9-]{36}$/.test(process.env.WORKER_SESSION_KEY ?? "") + ? process.env.WORKER_SESSION_KEY + : undefined; +const emit = (event) => + process.stdout.write(`${JSON.stringify({ session_key: sessionKey, ...event })}\n`); +let version = null; +try { + version = parseRuntimeVersion( + execFileSync(engine, ["--version"], { + encoding: "utf8", + timeout: 5000, + maxBuffer: 16384, + stdio: ["ignore", "pipe", "ignore"], + }), + ); +} catch { + /* version unavailable is not an invented default */ +} +emit({ + type: "worker.runtime", + engine, + requested_model: requestedModel ?? null, + cli_version: version, + started_at: new Date().toISOString(), +}); +let child; +try { + child = spawn(engine, args, { stdio: ["ignore", "pipe", "pipe"] }); +} catch { + emit({ + type: "worker.launch_error", + engine, + code: "launch_failed", + message: "Check the CLI executable and worker configuration.", + }); + process.exit(127); +} +let launchFailed = false; +let stopping = false; +let forceStop; +const diagnostics = new Set(); +// Bounded line buffers avoid a malicious/verbose tool exhausting host memory. +// Unstructured output is counted, not persisted: arbitrary stderr may contain +// auth headers. Provider event envelopes retain useful status/usage metadata. +for (const [stream, source] of [ + [child.stdout, "stdout"], + [child.stderr, "stderr"], +]) { + let buffer = ""; + let dropping = false; + let omitted = 0; + stream.setEncoding("utf8"); + const consume = (line) => { + const event = source === "stdout" ? parseProviderLine(engine, line) : null; + if (event) emit(event); + else if (line) { + omitted++; + const diagnostic = classifyWorkerDiagnostic(line); + if (diagnostic && !diagnostics.has(diagnostic.code)) { + diagnostics.add(diagnostic.code); + emit({ type: "worker.diagnostic", engine, ...diagnostic }); + } + } + }; + stream.on("data", (chunk) => { + for (const part of chunk.split(/(?<=\n)/)) { + if (!dropping) buffer += part; + if (buffer.length > 1024 * 1024) { + buffer = ""; + dropping = true; + } + if (part.endsWith("\n")) { + if (dropping) omitted++; + else consume(buffer.trimEnd()); + buffer = ""; + dropping = false; + } + } + }); + stream.on("end", () => { + if (dropping) omitted++; + else if (buffer) consume(buffer); + if (omitted) emit({ type: "worker.output_omitted", stream: source, lines: omitted }); + }); +} +// The host starts this wrapper as a process-group leader. Keep the CLI and +// its tools in that group so host cancellation can reach every descendant. +for (const signal of ["SIGTERM", "SIGINT"]) + process.on(signal, () => { + if (stopping) return; + stopping = true; + child.kill(signal); + try { + process.kill(-process.pid, signal); + } catch { + /* container: Docker owns the process boundary */ + } + forceStop = setTimeout(() => { + try { + process.kill(-process.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }, 3000); + // Keep the wrapper alive for the grace period even if the CLI exits first: + // ignored-stdio tool descendants may still need the group SIGKILL. + }); +child.on("spawn", () => process.send?.({ type: "worker.provider_started" })); +child.on("error", (error) => { + process.send?.({ type: "worker.launch_error" }); + launchFailed = true; + emit({ + type: "worker.launch_error", + engine, + code: ["ENOENT", "EACCES"].includes(error.code) ? error.code : "launch_failed", + message: "Check that the CLI is installed and executable on the worker PATH.", + }); + process.exitCode = 127; +}); +child.on("close", (code, signal) => { + if (forceStop && !stopping) clearTimeout(forceStop); + emit({ type: "worker.exit", engine, code: launchFailed ? 127 : code, signal }); + process.exitCode = launchFailed ? 127 : code !== null && code >= 0 ? code : 1; +}); diff --git a/scripts/worker-image-metadata.ts b/scripts/worker-image-metadata.ts new file mode 100644 index 00000000..41d83b24 --- /dev/null +++ b/scripts/worker-image-metadata.ts @@ -0,0 +1,42 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +type Inspect = (args: string[]) => Promise; +const inspect: Inspect = async (args) => + (await execFileAsync("docker", args, { timeout: 1500, maxBuffer: 16_384 })).stdout; + +/** Inspect the actual running container, never infer the image from a mutable + * tag or dump its config (which contains secret environment variables). */ +export async function containerImageMetadata( + name: string, + run: Inspect = inspect, +): Promise<{ image_id: string | null; image_digests: string[] }> { + let imageId: string | null = null; + for (let attempt = 0; attempt < 8; attempt++) { + try { + const result = (await run(["container", "inspect", "--format", "{{.Image}}", name])).trim(); + if (/^sha256:[a-f0-9]{64}$/.test(result)) imageId = result; + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") break; + if (attempt < 7) await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + if (imageId === null) return { image_id: null, image_digests: [] }; + let digests: string[] = []; + try { + const parsed: unknown = JSON.parse( + await run(["image", "inspect", "--format", "{{json .RepoDigests}}", imageId]), + ); + if (Array.isArray(parsed)) { + digests = parsed.filter( + (item): item is string => + typeof item === "string" && /^[A-Za-z0-9._:/-]+@sha256:[a-f0-9]{64}$/.test(item), + ); + } + } catch { + /* local-only images have an ID without registry digests */ + } + return { image_id: imageId, image_digests: digests }; +} diff --git a/scripts/worker-select.ts b/scripts/worker-select.ts index d48118ad..0b77de4e 100644 --- a/scripts/worker-select.ts +++ b/scripts/worker-select.ts @@ -2,6 +2,7 @@ // Kept separate from the polling/spawning loop so it's trivially unit-testable. import { workerExchangeRepo } from "./worker-exchange.js"; +import { resolveWorkerModel } from "./worker-telemetry.mjs"; import { DEFAULT_CODEX_IMAGE } from "./engines/codex.js"; import { DEFAULT_GEMINI_IMAGE, @@ -82,6 +83,8 @@ export type WorkerStack = { export type WorkerProject = { repo: string; + /** Optional model override for this project; takes precedence over worker.model. */ + model?: string; stack?: WorkerStack; /** Integration branch containerized dispatch bases agent/ on (default "main"). */ baseBranch?: string; @@ -114,6 +117,8 @@ export type GithubPollConfig = { export type WorkerConfig = { url: string; + /** Optional requested model; absent preserves the engine/provider default. */ + model?: string; label: string; intervalSeconds: number; /** How often to scan the event feed for answered escalations (default 15s). */ @@ -1310,6 +1315,11 @@ export function buildDockerArgs( `WORKER_PROMPT=${prompt}`, "-e", `ALLOWED_TOOLS=${allowedTools.join(",")}`, + "-e", + "WORKER_SESSION_KEY", + ...(resolveWorkerModel(config, project) + ? ["-e", `WORKER_MODEL=${resolveWorkerModel(config, project)}`] + : []), ...(stackChecks ? ["-e", `STACK_CHECKS=${stackChecks}`] : []), "-e", `BASE_BRANCH=${baseBranch}`, diff --git a/scripts/worker-telemetry.d.mts b/scripts/worker-telemetry.d.mts new file mode 100644 index 00000000..6d920e3c --- /dev/null +++ b/scripts/worker-telemetry.d.mts @@ -0,0 +1,14 @@ +export function isValidWorkerModel(value: unknown): value is string; +export function resolveWorkerModel( + config: { model?: string }, + project?: { model?: string }, +): string | undefined; +export function buildEngineArgs(engine: string, args: string[], model?: string): string[]; +export function normalizeProviderEvent( + engine: string, + event: unknown, +): Record | null; +export function parseProviderLine(engine: string, line: string): Record | null; +export function parseRuntimeVersion(output: unknown): string | null; + +export function classifyWorkerDiagnostic(text: unknown): { code: string; message: string } | null; diff --git a/scripts/worker-telemetry.mjs b/scripts/worker-telemetry.mjs new file mode 100644 index 00000000..3dd6d6ed --- /dev/null +++ b/scripts/worker-telemetry.mjs @@ -0,0 +1,154 @@ +// Shared by host, container CLI wrapper, and isolated SDK runner. Never serialize +// provider events wholesale: MCP arguments/results and auth objects may be secret. +export function isValidWorkerModel(value) { + return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(value); +} + +export function resolveWorkerModel(config, project) { + const model = project?.model ?? config.model; + if (model !== undefined && !isValidWorkerModel(model)) { + throw new Error("model must be a valid model identifier"); + } + return model; +} + +export function buildEngineArgs(engine, args, model) { + if (model !== undefined && !isValidWorkerModel(model)) { + throw new Error("model must be a valid model identifier"); + } + const modelArgs = model === undefined ? [] : ["--model", model]; + if (engine === "claude") + return [...args, "--output-format", "stream-json", "--verbose", ...modelArgs]; + if (engine === "codex") return ["exec", "--json", ...modelArgs, ...args]; + if (engine === "gemini") return [...args, "--output-format", "stream-json", ...modelArgs]; + throw new Error("unsupported worker engine"); +} + +const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value); +const identifier = (value) => + typeof value === "string" && /^[A-Za-z0-9_.:/-]{1,200}$/.test(value) ? value : undefined; +const numericKeys = [ + "input_tokens", + "output_tokens", + "cached_input_tokens", + "cache_read_input_tokens", + "cache_creation_input_tokens", + "total_tokens", + "input", + "output", + "cached", + "thoughts", + "tool", + "total", +]; + +/** A bounded, credential-free event envelope. Unknown schemas retain only their + * type; missing actual model stays missing, even when a requested model is set. */ +export function normalizeProviderEvent(engine, event) { + if (!object(event) || !identifier(event.type)) return null; + const out = { type: "worker.event", engine, event_type: identifier(event.type) }; + for (const field of ["subtype", "status"]) { + if (identifier(event[field])) out[field] = event[field]; + } + const reportedVersion = parseRuntimeVersion(event.claude_code_version); + if (reportedVersion) out.cli_version = reportedVersion; + const models = new Set(); + // These are provider-emitted values, not defaults or the configured request. + for (const value of [event.model, event.message?.model]) { + if (isValidWorkerModel(value)) models.add(value); + } + for (const source of [event.modelUsage, event.stats?.models]) { + if (object(source)) + for (const model of Object.keys(source).slice(0, 32)) { + if (isValidWorkerModel(model)) models.add(model); + } + } + if (models.size) out.effective_models = [...models]; + const usage = event.usage ?? event.message?.usage ?? event.stats?.tokens; + if (object(usage)) { + const counts = {}; + for (const key of numericKeys) { + if (typeof usage[key] === "number" && Number.isFinite(usage[key]) && usage[key] >= 0) + counts[key] = usage[key]; + } + if (Object.keys(counts).length) out.usage = counts; + } + for (const key of [ + "duration_ms", + "duration_api_ms", + "total_cost_usd", + "num_turns", + "exit_code", + ]) { + if (typeof event[key] === "number" && Number.isFinite(event[key])) out[key] = event[key]; + } + if (typeof event.is_error === "boolean") out.is_error = event.is_error; + if (identifier(event.item?.type)) out.item_type = event.item.type; + if (identifier(event.item?.status)) out.item_status = event.item.status; + if (typeof event.item?.exit_code === "number" && Number.isFinite(event.item.exit_code)) + out.exit_code = event.item.exit_code; + const toolNames = (Array.isArray(event.message?.content) ? event.message.content : []) + .filter((item) => item?.type === "tool_use" && identifier(item.name)) + .slice(0, 32) + .map((item) => item.name); + if (identifier(event.tool_name)) toolNames.push(event.tool_name); + if (toolNames.length) out.tool_names = [...new Set(toolNames)]; + // Do not copy error messages, tool arguments/results, auth, session IDs, or + // arbitrary keys. A provider can echo credentials in all of those fields. + if (identifier(event.error?.code)) out.error_code = event.error.code; + const diagnostic = classifyWorkerDiagnostic( + event.error?.message ?? (event.type === "error" ? event.message : undefined), + ); + if (diagnostic) out.diagnostic = diagnostic; + return out; +} + +export function parseProviderLine(engine, line) { + try { + return normalizeProviderEvent(engine, JSON.parse(line)); + } catch { + return null; + } +} + +/** --version may include a product name. Extract the version, never log the + * whole subprocess output (which can contain diagnostics or environment data). */ +export function parseRuntimeVersion(output) { + return typeof output === "string" + ? (output.match(/\b\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?\b/)?.[0] ?? null) + : null; +} + +/** Classify text without persisting it: provider stderr can contain complete + * authorization headers and echoed request payloads. Messages are static. */ +export function classifyWorkerDiagnostic(text) { + if (typeof text !== "string") return null; + const patterns = [ + [ + /unknown (?:option|argument)|unrecognized (?:option|argument)|unexpected argument|invalid argument/i, + "invalid_arguments", + "Check the installed CLI version and supported worker flags.", + ], + [ + /unauthori[sz]ed|authentication|invalid.{0,20}(?:api.?key|token)|401|403/i, + "authentication", + "Check provider authentication and the credential-injecting proxy.", + ], + [/rate.?limit|quota|429/i, "rate_limit", "Check provider quota and retry policy."], + [ + /ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network|connection|timed? ?out/i, + "connection", + "Check provider connectivity, allowed domains, and the egress proxy.", + ], + [ + /permission denied|EACCES/i, + "permission", + "Check worker executable and workspace permissions.", + ], + [/mcp/i, "mcp", "Check MCP endpoint connectivity and session lease configuration."], + ]; + for (const [pattern, code, message] of patterns) { + if (pattern.test(text)) return { code, message }; + } + return null; +} diff --git a/tests/scripts/agent-worker.test.ts b/tests/scripts/agent-worker.test.ts index b6fb66f7..f86b9d11 100644 --- a/tests/scripts/agent-worker.test.ts +++ b/tests/scripts/agent-worker.test.ts @@ -124,6 +124,9 @@ describe("dispatchAnswer (SYD-74: PATH pinning fallout)", () => { // process actually launched — simulate that ordering here. child.pid = 4242; child.emit("spawn"); + expect(answerState.get(ref)).toBeUndefined(); + child.emit("message", { type: "worker.provider_started" }); + child.emit("message", { type: "worker.provider_started" }); // duplicate ACK is harmless expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("pid 4242")); expect(answerState.get(ref)).toBe(1); @@ -131,6 +134,20 @@ describe("dispatchAnswer (SYD-74: PATH pinning fallout)", () => { logSpy.mockRestore(); }); + it("does not consume the answer cap when the wrapper starts but the provider is missing", () => { + const child = new FakeChildProcess(); + spawnMock.mockReturnValue(child); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + dispatchAnswer(ref, config, "token", { dryRun: false }); + child.pid = 4242; + child.emit("spawn"); + child.emit("message", { type: "worker.launch_error" }); + child.emit("exit", 127); + expect(answerState.get(ref)).toBeUndefined(); + expect(active.has(answerKey(ref))).toBe(false); + logSpy.mockRestore(); + }); + it("does not log 'pid undefined' or count a failed spawn against the per-issue answer cap", () => { const child = new FakeChildProcess(); spawnMock.mockReturnValue(child); diff --git a/tests/scripts/worker-telemetry.test.ts b/tests/scripts/worker-telemetry.test.ts new file mode 100644 index 00000000..ded1acbd --- /dev/null +++ b/tests/scripts/worker-telemetry.test.ts @@ -0,0 +1,354 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { execFile, spawn } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + buildEngineArgs, + classifyWorkerDiagnostic, + isValidWorkerModel, + normalizeProviderEvent, + parseProviderLine, + parseRuntimeVersion, + resolveWorkerModel, +} from "../../scripts/worker-telemetry.mjs"; +import { containerImageMetadata } from "../../scripts/worker-image-metadata.js"; +import { validateWorkerConfig } from "../../scripts/init-worker-lib.js"; +import { buildDockerArgs, type WorkerConfig } from "../../scripts/worker-select.js"; + +const exec = promisify(execFile); +const tempDirs: string[] = []; +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); +const config: WorkerConfig = { + url: "http://localhost:3300", + label: "auto", + intervalSeconds: 60, + maxConcurrent: 1, + projects: { SYD: { repo: "/repo" } }, +}; + +describe("worker model configuration", () => { + it("preserves provider defaults and gives project requests precedence", () => { + expect(resolveWorkerModel(config, {})).toBeUndefined(); + expect(resolveWorkerModel({ model: "worker-model" }, {})).toBe("worker-model"); + expect(resolveWorkerModel({ model: "worker-model" }, { model: "project-model" })).toBe( + "project-model", + ); + expect(validateWorkerConfig(config)).toEqual([]); + expect(validateWorkerConfig({ ...config, model: "provider/models/example-1" })).toEqual([]); + }); + it.each([ + "", + "--help", + "a b", + "a\nb", + "$(touch /tmp/no)", + "x;env", + "x'", + "x".repeat(201), + null, + 2, + ])("rejects invalid model %j", (model) => { + expect(isValidWorkerModel(model)).toBe(false); + expect(validateWorkerConfig({ ...config, model })).toContainEqual( + expect.stringContaining("model"), + ); + expect( + validateWorkerConfig({ ...config, projects: { SYD: { repo: "/repo", model } } }), + ).toContainEqual(expect.stringContaining("projects.SYD.model")); + }); + it.each(["claude", "codex", "gemini"])( + "passes %s model as one argument without a shell", + (engine) => { + const model = "provider/model-v1:stable"; + const args = buildEngineArgs(engine, ["a prompt; $(env)"], model); + expect(args.slice(args.indexOf("--model"), args.indexOf("--model") + 2)).toEqual([ + "--model", + model, + ]); + expect(args).toContain("a prompt; $(env)"); + expect(buildEngineArgs(engine, [], undefined)).not.toContain("--model"); + expect(() => buildEngineArgs(engine, [], "--help")).toThrow(); + }, + ); + it("passes the resolved model to a container with no effect on old configs", () => { + const issue = { ref: "SYD-1", labels: [], assigneeId: null, needsInput: false, updatedAt: 0 }; + const env = { CLAUDE_CODE_OAUTH_TOKEN: "do-not-log" }; + expect( + buildDockerArgs( + issue, + { repo: "/repo", model: "project-model" }, + { ...config, model: "worker-model" }, + env, + ), + ).toContain("WORKER_MODEL=project-model"); + expect(buildDockerArgs(issue, { repo: "/repo" }, config, env).join(" ")).not.toContain( + "WORKER_MODEL", + ); + }); +}); + +describe("structured provider metadata", () => { + it("records actual Claude models, usage and tool names without auth or tool input", () => { + const event = normalizeProviderEvent("claude", { + type: "assistant", + model: "claude-configured", + message: { + model: "claude-actual", + usage: { input_tokens: 10, output_tokens: 2, auth_token: "secret" }, + content: [{ type: "tool_use", name: "Bash", input: { command: "echo secret" } }], + }, + authorization: "Bearer secret", + }); + expect(event).toMatchObject({ + effective_models: ["claude-configured", "claude-actual"], + usage: { input_tokens: 10, output_tokens: 2 }, + tool_names: ["Bash"], + }); + expect(JSON.stringify(event)).not.toContain("secret"); + }); + it("handles Gemini init and results, preserving numeric per-event usage", () => { + expect( + parseProviderLine("gemini", '{"type":"init","model":"gemini-actual","session_id":"secret"}'), + ).toEqual({ + type: "worker.event", + engine: "gemini", + event_type: "init", + effective_models: ["gemini-actual"], + }); + expect( + normalizeProviderEvent("gemini", { + type: "result", + status: "success", + stats: { + models: { "gemini-actual": { tokens: { input: 4 } } }, + tokens: { input: 4, total: 8 }, + }, + }), + ).toMatchObject({ + status: "success", + effective_models: ["gemini-actual"], + usage: { input: 4, total: 8 }, + }); + }); + it("does not invent an effective model for Codex events that omit it", () => { + expect( + normalizeProviderEvent("codex", { + type: "turn.completed", + usage: { input_tokens: 30, cached_input_tokens: 20, output_tokens: 5 }, + }), + ).toEqual({ + type: "worker.event", + engine: "codex", + event_type: "turn.completed", + usage: { input_tokens: 30, output_tokens: 5, cached_input_tokens: 20 }, + }); + expect( + normalizeProviderEvent("codex", { + type: "item.completed", + item: { + type: "command_execution", + status: "completed", + exit_code: 1, + command: "echo secret", + aggregated_output: "secret", + }, + }), + ).toMatchObject({ item_type: "command_execution", exit_code: 1 }); + }); + it("ignores malformed data and unknown payload fields", () => { + for (const line of ["not json", "null", "[]", '{"token":"secret"}']) + expect(parseProviderLine("claude", line)).toBeNull(); + expect( + normalizeProviderEvent("claude", { + type: "future.event", + data: { token: "secret" }, + usage: { input_tokens: Infinity, output_tokens: -1 }, + }), + ).toEqual({ type: "worker.event", engine: "claude", event_type: "future.event" }); + expect(parseRuntimeVersion("claude 2.1.283 (Claude Code)\nsecret")).toBe("2.1.283"); + expect(parseRuntimeVersion("auth failed")).toBeNull(); + }); +}); + +describe("runtime capture without provider calls", () => { + it("runs a fake CLI, retains split JSON events, omits credentials and preserves exit status", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "syd-telemetry-")); + tempDirs.push(dir); + const argsFile = path.join(dir, "args.json"); + writeFileSync( + path.join(dir, "claude"), + `#!/usr/bin/env node\nconst fs = require('node:fs');\nif (process.argv.includes('--version')) { console.log('2.1.283 (Claude Code)'); process.exit(0); }\nfs.writeFileSync(process.env.ARGS_FILE, JSON.stringify(process.argv.slice(2)));\nprocess.stdout.write('{"type":"system",');\nsetTimeout(() => { console.log('"subtype":"init","model":"actual-model","token":"secret"}'); console.error('Authorization: Bearer secret'); process.exitCode = 7; }, 10);\n`, + { mode: 0o700 }, + ); + let failure: { stdout: string; stderr: string; code: number } | undefined; + try { + await exec( + process.execPath, + [path.resolve("scripts/worker-engine-runner.mjs"), "claude", "-p", "prompt; $(env)"], + { + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + WORKER_MODEL: "requested-model", + ARGS_FILE: argsFile, + }, + }, + ); + } catch (error) { + failure = error as typeof failure; + } + expect(failure?.code).toBe(7); + if (!failure) throw new Error("fake provider must exit unsuccessfully"); + const lines = failure.stdout + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(lines).toContainEqual( + expect.objectContaining({ + type: "worker.runtime", + requested_model: "requested-model", + cli_version: "2.1.283", + }), + ); + expect(lines).toContainEqual( + expect.objectContaining({ type: "worker.event", effective_models: ["actual-model"] }), + ); + expect(lines).toContainEqual({ type: "worker.output_omitted", stream: "stderr", lines: 1 }); + expect(failure.stdout + failure.stderr).not.toContain("secret"); + expect(JSON.parse(readFileSync(argsFile, "utf8"))).toEqual([ + "-p", + "prompt; $(env)", + "--output-format", + "stream-json", + "--verbose", + "--model", + "requested-model", + ]); + }); + it("keeps missing CLI exit code 127 and actionable diagnostics", async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "syd-missing-cli-")); + tempDirs.push(dir); + try { + await exec( + process.execPath, + [path.resolve("scripts/worker-engine-runner.mjs"), "claude", "-p", "test"], + { env: { ...process.env, PATH: dir } }, + ); + expect.fail("missing CLI must fail"); + } catch (error) { + const failure = error as { code: number; stdout: string }; + expect(failure.code).toBe(127); + expect(failure.stdout).toContain('"code":"ENOENT"'); + expect(failure.stdout).toContain("installed and executable"); + expect(failure.stdout).toContain('"code":127'); + } + }); + it("classifies configuration and authentication failures without echoing secrets", () => { + expect(classifyWorkerDiagnostic("unknown option --token=secret")).toEqual({ + code: "invalid_arguments", + message: "Check the installed CLI version and supported worker flags.", + }); + expect(classifyWorkerDiagnostic("Authentication failed for token secret")).toEqual({ + code: "authentication", + message: "Check provider authentication and the credential-injecting proxy.", + }); + expect(classifyWorkerDiagnostic("arbitrary secret text")).toBeNull(); + }); + it.each([false, true])( + "stops process-group tool descendants (ignores SIGTERM: %s)", + async (ignoreTerm) => { + const dir = mkdtempSync(path.join(os.tmpdir(), "syd-cancel-cli-")); + tempDirs.push(dir); + const marker = path.join(dir, "terminated"); + const heartbeat = path.join(dir, "heartbeat"); + const pidFile = path.join(dir, "descendant.pid"); + writeFileSync( + path.join(dir, "tool.cjs"), + `const fs = require('node:fs'); fs.writeFileSync(process.env.PID_FILE, String(process.pid)); process.on('SIGTERM', () => { fs.writeFileSync(process.env.MARKER, 'terminated'); if (!process.env.IGNORE_TERM) process.exit(0); }); setInterval(() => fs.writeFileSync(process.env.HEARTBEAT, String(Date.now())), 30);`, + ); + writeFileSync( + path.join(dir, "claude"), + `#!/usr/bin/env node +if (process.argv.includes('--version')) { console.log('2.1.283'); process.exit(0); } +require('node:child_process').spawn(process.execPath, [process.env.TOOL], { stdio: 'inherit' }); setInterval(() => {}, 1000); +`, + { mode: 0o700 }, + ); + const wrapper = spawn( + process.execPath, + [path.resolve("scripts/worker-engine-runner.mjs"), "claude", "-p", "test"], + { + detached: true, + stdio: "ignore", + env: { + ...process.env, + PATH: `${dir}:${process.env.PATH}`, + TOOL: path.join(dir, "tool.cjs"), + PID_FILE: pidFile, + MARKER: marker, + HEARTBEAT: heartbeat, + IGNORE_TERM: ignoreTerm ? "1" : "", + }, + }, + ); + try { + await vi.waitFor(() => expect(readFileSync(pidFile, "utf8")).toMatch(/^\d+$/), { + timeout: 3000, + }); + const closed = new Promise((resolve) => wrapper.once("close", () => resolve())); + wrapper.kill("SIGTERM"); + await closed; + expect(readFileSync(marker, "utf8")).toBe("terminated"); + if (ignoreTerm) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const lastBeat = readFileSync(heartbeat, "utf8"); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(readFileSync(heartbeat, "utf8")).toBe(lastBeat); + } + } finally { + if (wrapper.pid) { + try { + process.kill(-wrapper.pid, "SIGKILL"); + } catch { + /* group exited */ + } + } + } + }, + ); + it("inspects immutable image identity without reading container env", async () => { + const id = `sha256:${"a".repeat(64)}`; + const digest = `registry/worker@sha256:${"b".repeat(64)}`; + const run = vi + .fn() + .mockResolvedValueOnce(id) + .mockResolvedValueOnce(JSON.stringify([digest, "secret"])); + expect(await containerImageMetadata("syd-SYD-1", run)).toEqual({ + image_id: id, + image_digests: [digest], + }); + expect(run.mock.calls[0][0]).toEqual([ + "container", + "inspect", + "--format", + "{{.Image}}", + "syd-SYD-1", + ]); + expect(run.mock.calls[1][0]).toEqual([ + "image", + "inspect", + "--format", + "{{json .RepoDigests}}", + id, + ]); + }); + it("reports unknown identity if Docker is unavailable", async () => { + expect( + await containerImageMetadata("missing", vi.fn().mockRejectedValue({ code: "ENOENT" })), + ).toEqual({ image_id: null, image_digests: [] }); + }); +}); diff --git a/worker-sdk/sdk-runner.test.ts b/worker-sdk/sdk-runner.test.ts index 07ab87fe..4cbb621b 100644 --- a/worker-sdk/sdk-runner.test.ts +++ b/worker-sdk/sdk-runner.test.ts @@ -87,6 +87,46 @@ describe("runSdkSession", () => { ); }); + it("records SDK version, requested model and separately observed model metadata", async () => { + options.model = "requested-model"; + queryMock.mockImplementation(() => + asQuery( + (async function* () { + yield { + type: "system", + subtype: "init", + model: "actual-model", + claude_code_version: "2.1.283", + authorization: "secret-auth", + } as unknown as SDKMessage; + yield result(); + })(), + ), + ); + expect(await runSdkSession(options)).toBe(0); + const log = readFileSync(options.logPath, "utf8"); + const events = log + .split("\n") + .filter((line) => line.startsWith("{")) + .map((line) => JSON.parse(line)); + expect(events).toContainEqual( + expect.objectContaining({ + type: "worker.runtime", + sdk_version: expect.stringMatching(/^\d+\.\d+\.\d+/), + requested_model: "requested-model", + cli_version: null, + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: "worker.event", + effective_models: ["actual-model"], + cli_version: "2.1.283", + }), + ); + expect(log).not.toContain("secret-auth"); + }); + it("does not start a provider session after the host cancels its lease", async () => { const host = new AbortController(); host.abort(); diff --git a/worker-sdk/sdk-runner.ts b/worker-sdk/sdk-runner.ts index 4d5adc13..527dacf4 100644 --- a/worker-sdk/sdk-runner.ts +++ b/worker-sdk/sdk-runner.ts @@ -13,7 +13,10 @@ // SDK typecheck is part of `npm run typecheck`. Auth is the same // CLAUDE_CODE_OAUTH_TOKEN (or ANTHROPIC_API_KEY) from the environment. -import { appendFileSync } from "node:fs"; +import { appendFileSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { normalizeProviderEvent, parseRuntimeVersion } from "../scripts/worker-telemetry.mjs"; import { query } from "@anthropic-ai/claude-agent-sdk"; import { formatSdkEvent, type SdkEventLike } from "./sdk-format.js"; @@ -29,6 +32,8 @@ export type SdkSessionOpts = { allowedTools: string[]; /** Optional provider model; omitted to retain the provider's default. */ model?: string; + /** Correlates runtime and provider metadata across repeated issue sessions. */ + sessionKey?: string; logPath: string; /** Watchdog (SYD-115): abort the query if it runs longer than this. No timeout when omitted. */ timeoutMs?: number; @@ -56,6 +61,30 @@ export async function runSdkSession(o: SdkSessionOpts): Promise { /* log dir gone or disk full — the session matters more than the log */ } }; + let sdkVersion: string | null = null; + try { + const require = createRequire(import.meta.url); + const packagePath = join( + dirname(require.resolve("@anthropic-ai/claude-agent-sdk")), + "package.json", + ); + sdkVersion = parseRuntimeVersion(JSON.parse(readFileSync(packagePath, "utf8")).version); + } catch { + /* unknown installed version stays unknown */ + } + log( + JSON.stringify({ + type: "worker.runtime", + session_key: o.sessionKey, + engine: "claude", + runner: "sdk", + requested_model: o.model ?? null, + sdk_version: sdkVersion, + cli_version: null, + image_id: null, + started_at: new Date().toISOString(), + }), + ); let exit = 1; // Watchdog (SYD-115): an SDK query that never yields a `result` message // (a stuck tool call, a wedged CLI subprocess under the hood) would @@ -103,6 +132,8 @@ export async function runSdkSession(o: SdkSessionOpts): Promise { }, }); for await (const message of stream) { + const event = normalizeProviderEvent("claude", message); + if (event) log(JSON.stringify({ session_key: o.sessionKey, ...event })); const line = formatSdkEvent(message as SdkEventLike); if (line) log(line); if (message.type === "result") { From a2a0ee6650f0a1c341c3984e4cedc1a384d3573b Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:14:01 -0400 Subject: [PATCH 7/8] fix: keep worker wrapper with its compiled generation (#271) --- scripts/agent-worker.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/agent-worker.ts b/scripts/agent-worker.ts index 082ca02a..70f5cee5 100644 --- a/scripts/agent-worker.ts +++ b/scripts/agent-worker.ts @@ -994,7 +994,11 @@ export function dispatch( } child = spawn( process.execPath, - [path.join(repoRoot(), "scripts", "worker-engine-runner.mjs"), "claude", ...cliArgs], + [ + fileURLToPath(new URL("./worker-engine-runner.mjs", import.meta.url)), + "claude", + ...cliArgs, + ], { env: { ...process.env, @@ -1305,7 +1309,7 @@ export function dispatchAnswer( child = spawn( process.execPath, [ - path.join(repoRoot(), "scripts", "worker-engine-runner.mjs"), + fileURLToPath(new URL("./worker-engine-runner.mjs", import.meta.url)), "claude", "-p", buildAnswerPrompt(ref), From 00d17fbf02adf40a29c665f32408efa18071aa9f Mon Sep 17 00:00:00 2001 From: Sean Perkins <1733750+seanperkins@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:20:45 -0400 Subject: [PATCH 8/8] test: preserve adversarial Host headers in proxy contracts (#269) --- tests/scripts/egress-proxy-contract.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/scripts/egress-proxy-contract.py b/tests/scripts/egress-proxy-contract.py index e683dd0d..aaefd16f 100644 --- a/tests/scripts/egress-proxy-contract.py +++ b/tests/scripts/egress-proxy-contract.py @@ -23,7 +23,11 @@ def flow(url, headers=None): connection.Client(peername=("127.0.0.1", 1234), sockname=("127.0.0.1", 8888)), connection.Server(address=("api.anthropic.com", 443)), ) - result.request = http.Request.make("POST", url, b'{"stream":true}', headers or {}) + result.request = http.Request.make("POST", url, b'{"stream":true}') + # Request.make assigns the URL after constructing headers, which rewrites + # Host to match it. Apply adversarial headers afterward so mismatch cases + # actually reach the policy with different target and Host values. + result.request.headers.update(headers or {}) return result @@ -69,6 +73,8 @@ def test_rejects_forbidden_cleartext_and_mismatched_destinations(self): ]: with self.subTest(url=url, host=host): f = flow(url, {"Host": host}) + if url.startswith(("https://registry.npmjs.org/", "https://api.openai.com/")): + self.assertNotEqual(f.request.host, f.request.pretty_host) addon.request(f) self.assertEqual(f.response.status_code, 403) self.assertNotIn("authorization", f.request.headers)