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/.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/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/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..493b9aba 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 @@ -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 68d0d72e..9a0cf571 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 @@ -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 6052270d..b59274ae 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 @@ -32,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 70b49716..af139ea8 100644 --- a/README.md +++ b/README.md @@ -236,12 +236,46 @@ 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. + +`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: -Registering non-Claude runners (Codex, Antigravity, Cursor) is being -researched in SYD-46. +```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 @@ -264,14 +298,16 @@ 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 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 @@ -287,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: @@ -296,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 @@ -330,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 @@ -345,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). @@ -451,6 +515,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 ``` @@ -458,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/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/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/scripts/agent-worker.ts b/scripts/agent-worker.ts index 227e8d20..70f5cee5 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, @@ -61,7 +62,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, @@ -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, @@ -117,6 +120,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 +730,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 +766,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 +799,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 +828,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 })), ); @@ -839,15 +862,17 @@ 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`); + 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; } @@ -877,6 +902,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( @@ -886,18 +912,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, @@ -917,6 +954,7 @@ export function dispatch( env: { ...process.env, SWITCHYARD_TOKEN: token, + WORKER_SESSION_KEY: sessionKey, ...(opts.leaseToken ? { SWITCHYARD_LEASE: opts.leaseToken } : {}), }, }); @@ -954,11 +992,24 @@ 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, + [ + fileURLToPath(new URL("./worker-engine-runner.mjs", import.meta.url)), + "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}`); @@ -976,6 +1027,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. @@ -1016,6 +1081,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, @@ -1083,7 +1162,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__*", @@ -1134,6 +1213,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, @@ -1191,6 +1272,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) { @@ -1213,10 +1295,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; } @@ -1224,9 +1307,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, + [ + fileURLToPath(new URL("./worker-engine-runner.mjs", import.meta.url)), + "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}`); @@ -1250,13 +1349,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}`); }); @@ -1283,6 +1388,7 @@ function dispatchAnswerSdk( config: WorkerConfig, token: string, logPath: string, + sessionKey: string, ): void { const key = answerKey(ref); const safeAppend = (text: string) => { @@ -1301,6 +1407,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, @@ -1373,6 +1481,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 +1878,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/container-entry.codex.sh b/scripts/container-entry.codex.sh index b04ef9cb..5e8f0a30 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. @@ -138,10 +142,10 @@ 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 +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 8f0e6cc7..3f1a21a4 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. @@ -115,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 32583dd4..c62e5038 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 @@ -149,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/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/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 8d4b7c8d..1e0db536 100644 --- a/scripts/init-worker-lib.ts +++ b/scripts/init-worker-lib.ts @@ -2,8 +2,56 @@ // 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. */ +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 +91,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 +101,11 @@ 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) { + if (c.model !== undefined && !isValidWorkerModel(c.model)) { problems.push( - '`runner: "sdk"` sessions run in-process on the host — remove `containerized: true` (container SDK image is not built yet)', + "`model` must be a non-empty model identifier (letters, digits, ., _, :, /, -; max 200 chars)", ); } - 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)", @@ -108,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)); } @@ -198,7 +234,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) { @@ -813,8 +849,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/scripts/init-worker.ts b/scripts/init-worker.ts index 207d0756..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 { @@ -93,6 +93,7 @@ import { suggestStackCli, summarizeRoleStatus, validateWorkerConfig, + assertWorkerExecution, workerLaunchdLabel, DELIVER_LAUNCHD_LABEL, POLL_LAUNCHD_LABEL, @@ -175,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", @@ -198,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", }); @@ -237,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. @@ -477,7 +538,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({ @@ -556,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", @@ -601,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(/\/$/, ""); @@ -1395,10 +1446,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 +1501,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-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-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-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-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 4bf19d34..0b77de4e 100644 --- a/scripts/worker-select.ts +++ b/scripts/worker-select.ts @@ -1,6 +1,8 @@ // 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 { resolveWorkerModel } from "./worker-telemetry.mjs"; import { DEFAULT_CODEX_IMAGE } from "./engines/codex.js"; import { DEFAULT_GEMINI_IMAGE, @@ -9,10 +11,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 +41,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 +56,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. */ @@ -89,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; @@ -121,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). */ @@ -198,6 +196,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; @@ -379,9 +389,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 +425,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) { @@ -1241,13 +1228,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, @@ -1312,7 +1293,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", @@ -1334,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/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/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 27238788..5f179cbd 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,23 +209,53 @@ 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([]); expect(validateWorkerConfig({ ...good, token: 123 })).toHaveLength(1); @@ -1065,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", () => { @@ -1077,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" }, ]); }); @@ -1097,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/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 ff6538d4..f86b9d11 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) => { @@ -109,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); @@ -116,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); @@ -636,6 +668,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/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/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/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..aaefd16f --- /dev/null +++ b/tests/scripts/egress-proxy-contract.py @@ -0,0 +1,108 @@ +"""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}') + # 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 + + +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}) + 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) + + 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", 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(() => { 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 20ef9c5d..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, @@ -82,6 +83,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 = { @@ -1026,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", () => { 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/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); + }, + ); +}); 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..4cbb621b --- /dev/null +++ b/worker-sdk/sdk-runner.test.ts @@ -0,0 +1,230 @@ +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("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(); + 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..527dacf4 100644 --- a/worker-sdk/sdk-runner.ts +++ b/worker-sdk/sdk-runner.ts @@ -9,10 +9,14 @@ // 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"; +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"; @@ -26,6 +30,10 @@ 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; + /** 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; @@ -43,11 +51,40 @@ 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 */ } }; + 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 @@ -57,9 +94,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 +113,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, @@ -88,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") { @@ -95,10 +141,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"] +}