From 4e6b85e5b3db9b147e051376294839e5a5fc7028 Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Fri, 10 Jul 2026 22:33:22 +0530 Subject: [PATCH 1/3] terra-prod-hardening: sandbox proxy, redaction, and CI security Co-authored-by: Cursor --- .github/dependabot.yml | 12 + .github/workflows/ci.yml | 21 +- .github/workflows/codeql.yml | 31 + .github/workflows/dependency-review.yml | 17 + .gitignore | 4 +- Dockerfile.sandbox | 35 +- package.json | 2 +- packages/admin-cli/src/configure-providers.ts | 35 +- packages/admin-cli/src/provider-validation.ts | 13 +- .../test/configure-providers.test.mjs | 54 +- .../test/provider-validation.test.mjs | 27 +- packages/cli/package.json | 2 +- packages/cli/src/models.ts | 2 +- packages/cli/test/models.test.mjs | 13 +- packages/sandbox-image/package.json | 5 +- packages/sandbox-image/src/entrypoint.ts | 58 +- packages/sandbox-image/src/git-driver.ts | 91 ++- packages/sandbox-image/src/pi-cost.ts | 2 +- packages/sandbox-image/src/pi-driver.ts | 117 ++- packages/sandbox-image/src/preview-manager.ts | 33 +- packages/sandbox-image/src/runtime-helpers.ts | 14 - packages/sandbox-image/src/runtime-rpc.ts | 41 -- packages/sandbox-image/src/runtime-types.ts | 30 +- packages/sandbox-image/src/runtime.ts | 54 +- .../sandbox-image/test/dockerfile.test.mjs | 35 + .../sandbox-image/test/git-driver.test.mjs | 108 ++- .../sandbox-image/test/pi-driver.test.mjs | 215 +++++- packages/sandbox-image/test/runtime.test.mjs | 161 +++- packages/shared/src/agent-models.ts | 11 +- packages/shared/src/entrypoint-env.ts | 33 +- packages/shared/src/github.ts | 30 + packages/shared/src/index.ts | 27 +- packages/shared/src/messages-sandbox.ts | 35 +- packages/shared/src/observability.ts | 130 +++- packages/shared/src/providers.ts | 274 +++++-- packages/shared/test/entrypoint-env.test.mjs | 25 + packages/shared/test/github.test.mjs | 27 + packages/shared/test/observability.test.mjs | 39 + packages/shared/test/provider-models.test.mjs | 5 +- packages/shared/test/providers.test.mjs | 285 +++++-- .../src/lib/__tests__/provider-models.test.ts | 9 +- packages/web/src/lib/provider-models.ts | 5 + packages/web/src/routes/index.tsx | 79 +- packages/worker/.dev.vars.example | 24 + packages/worker/.env.example | 3 +- packages/worker/src/github.ts | 28 +- packages/worker/src/health.ts | 10 +- packages/worker/src/http-handlers.ts | 48 +- packages/worker/src/http-router.ts | 7 +- packages/worker/src/index.ts | 56 +- packages/worker/src/logging.ts | 66 +- packages/worker/src/memberships.ts | 11 +- packages/worker/src/orchestrator.ts | 159 +++- .../orchestrator/directory-update-queue.ts | 81 ++ .../src/orchestrator/event-log-limits.ts | 73 +- packages/worker/src/orchestrator/event-log.ts | 58 +- .../worker/src/orchestrator/provisioning.ts | 21 +- .../src/orchestrator/sandbox-handlers.ts | 114 +-- packages/worker/src/orchestrator/types.ts | 10 +- packages/worker/src/provider-credentials.ts | 54 +- packages/worker/src/redaction.ts | 127 +++- packages/worker/src/sandbox-proxy.ts | 220 ++++++ packages/worker/src/sandbox-ws-token.ts | 77 ++ packages/worker/src/sandbox.ts | 87 ++- packages/worker/src/session-directory.ts | 40 +- packages/worker/src/worker-env.ts | 50 +- .../worker/test/deployment-config.test.mjs | 36 + .../test/directory-update-queue.test.mjs | 73 ++ .../worker/test/event-log-limits.test.mjs | 69 ++ .../test/event-log-persistence.test.mjs | 87 +++ packages/worker/test/github.test.mjs | 21 - packages/worker/test/http-edge.test.mjs | 33 +- packages/worker/test/http-router.test.mjs | 44 ++ .../worker/test/logging-redaction.test.mjs | 166 +++++ packages/worker/test/memberships.test.mjs | 31 +- .../worker/test/provider-credentials.test.mjs | 105 ++- .../test/provisioning-redaction.test.mjs | 41 ++ packages/worker/test/redaction.test.mjs | 57 ++ .../worker/test/sandbox-handlers.test.mjs | 69 ++ packages/worker/test/sandbox-proxy.test.mjs | 186 +++++ .../worker/test/sandbox-ws-token.test.mjs | 55 ++ packages/worker/test/sandbox.test.mjs | 138 +++- .../worker/test/session-directory.test.mjs | 65 ++ .../worker/test/tracer-redaction.test.mjs | 29 + .../worker/wrangler.operator.example.toml | 67 ++ packages/worker/wrangler.toml | 11 +- pnpm-lock.yaml | 695 +++++++++++++++--- 87 files changed, 4929 insertions(+), 819 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 packages/sandbox-image/test/dockerfile.test.mjs create mode 100644 packages/shared/src/github.ts create mode 100644 packages/shared/test/github.test.mjs create mode 100644 packages/worker/.dev.vars.example create mode 100644 packages/worker/src/orchestrator/directory-update-queue.ts create mode 100644 packages/worker/src/sandbox-proxy.ts create mode 100644 packages/worker/src/sandbox-ws-token.ts create mode 100644 packages/worker/test/deployment-config.test.mjs create mode 100644 packages/worker/test/directory-update-queue.test.mjs create mode 100644 packages/worker/test/event-log-persistence.test.mjs create mode 100644 packages/worker/test/logging-redaction.test.mjs create mode 100644 packages/worker/test/sandbox-proxy.test.mjs create mode 100644 packages/worker/test/sandbox-ws-token.test.mjs create mode 100644 packages/worker/test/tracer-redaction.test.mjs create mode 100644 packages/worker/wrangler.operator.example.toml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..39240b5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7be1c4..14b1080 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,26 +10,37 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read + jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + with: + version: 10.28.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: - node-version: 22 + node-version: 22.19.0 cache: pnpm - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm verify sandbox-image: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - run: docker build -f Dockerfile.sandbox -t codevil-sandbox:ci . diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a3a5db1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,31 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: "17 3 * * 1" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + name: Analyze JavaScript and TypeScript + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 + with: + languages: javascript-typescript + build-mode: none + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..a9ca184 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,17 @@ +name: Dependency review + +on: + pull_request: + paths: + - "package.json" + - "pnpm-lock.yaml" + - "packages/**/package.json" + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/.gitignore b/.gitignore index 8c3ffa9..cb21b93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ node_modules/ +.pnpm-store/ dist/ *.tsbuildinfo .wrangler/ .dev.vars +wrangler.operator.toml .env.production **/bin .superpowers/ @@ -11,4 +13,4 @@ dist/ redesign docs/ references/* -tmp/* \ No newline at end of file +tmp/* diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index de69556..61a1c33 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -1,6 +1,6 @@ ARG CODEVIL_SANDBOX_PLATFORM=linux/amd64 -FROM --platform=$CODEVIL_SANDBOX_PLATFORM node:20-slim AS builder +FROM --platform=$CODEVIL_SANDBOX_PLATFORM node:22-slim AS builder RUN npm install -g pnpm@10.28.1 WORKDIR /app @@ -16,16 +16,41 @@ RUN pnpm install --filter @codevil/sandbox-image... --prod=false \ RUN rm -rf node_modules \ && pnpm install --filter @codevil/sandbox-image... --prod -FROM --platform=$CODEVIL_SANDBOX_PLATFORM docker.io/cloudflare/sandbox:0.12.1 +FROM --platform=$CODEVIL_SANDBOX_PLATFORM docker.io/cloudflare/sandbox:0.12.1 AS sandbox-runtime + +FROM --platform=$CODEVIL_SANDBOX_PLATFORM node:22-slim + +COPY --from=sandbox-runtime /container-server/sandbox /sandbox +ENTRYPOINT ["/sandbox"] -USER root RUN apt-get update \ - && apt-get install -y --no-install-recommends gh ca-certificates \ + && apt-get install -y --no-install-recommends \ + gh \ + ca-certificates \ + git \ + curl \ + wget \ + jq \ + zip \ + unzip \ + file \ + procps \ && rm -rf /var/lib/apt/lists/* \ - && npm install -g pnpm@10.28.1 + && npm install -g bun@1 pnpm@10.28.1 \ + && node --version \ + && git --version \ + && bun --version COPY --from=builder /app /app ENV CODEVIL_PI_AGENT_DIR=/opt/codevil/pi-agent COPY packages/sandbox-image/skills /opt/codevil/pi-agent/skills +RUN groupadd --gid 10001 codevil \ + && useradd --uid 10001 --gid codevil --create-home codevil \ + && mkdir -p /workspace /run/secrets \ + && chown -R codevil:codevil /app /workspace /run/secrets /opt/codevil + +USER codevil +WORKDIR /workspace + EXPOSE 5173 8000 8080 3001 diff --git a/package.json b/package.json index a211549..43e0d8c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "engines": { - "node": ">=20" + "node": ">=22.19" }, "packageManager": "pnpm@10.28.1", "scripts": { diff --git a/packages/admin-cli/src/configure-providers.ts b/packages/admin-cli/src/configure-providers.ts index cec7092..3079094 100644 --- a/packages/admin-cli/src/configure-providers.ts +++ b/packages/admin-cli/src/configure-providers.ts @@ -43,14 +43,28 @@ export async function configureProviders(options: { const selectedIndices = await promptForProviderSelection(prompt, output); const secretsToUpload: Record = {}; + const credentialsBySecret = new Map(); + const validationBySecret = new Map(); + const configuredValues = new Set(); const summary: ProviderSummary[] = []; for (const selectedIndex of selectedIndices) { const provider = LLM_PROVIDERS[selectedIndex - 1]; - const key = await promptForSecret(prompt, output, provider); - const status = await validateSelection(prompt, output, validator, provider, key); + let key = credentialsBySecret.get(provider.secretName); + let status = validationBySecret.get(provider.secretName); + if (!key || !status) { + key = await promptForSecret(prompt, output, provider); + status = await validateSelection(prompt, output, validator, provider, key); + credentialsBySecret.set(provider.secretName, key); + validationBySecret.set(provider.secretName, status); + } secretsToUpload[provider.secretName] = key; + for (const configKey of provider.configKeys) { + if (configuredValues.has(configKey)) continue; + secretsToUpload[configKey] = await promptForPublicConfig(prompt, output, configKey, provider); + configuredValues.add(configKey); + } summary.push({ displayName: provider.displayName, status, @@ -65,6 +79,23 @@ export async function configureProviders(options: { } } +async function promptForPublicConfig( + prompt: Prompt, + output: Output, + configKey: string, + provider: LLMProviderDefinition, +): Promise { + while (true) { + const value = await prompt.text(`Enter ${formatConfigKey(configKey)} for ${provider.displayName}: `); + if (value.trim().length > 0) return value.trim(); + output.error(`${formatConfigKey(configKey)} cannot be blank.`); + } +} + +function formatConfigKey(configKey: string): string { + return configKey.toLowerCase().split("_").map((part) => part[0].toUpperCase() + part.slice(1)).join(" "); +} + async function promptForProviderSelection(prompt: Prompt, output: Output): Promise { while (true) { const response = await prompt.text("Select providers by number (comma-separated): "); diff --git a/packages/admin-cli/src/provider-validation.ts b/packages/admin-cli/src/provider-validation.ts index d48d8fb..cdb46a2 100644 --- a/packages/admin-cli/src/provider-validation.ts +++ b/packages/admin-cli/src/provider-validation.ts @@ -5,7 +5,7 @@ export type CredentialValidation = | { status: "invalid"; message: string } | { status: "unavailable"; message: string }; -type ProviderValidationDefinition = Pick; +type ProviderValidationDefinition = Pick; export type ProviderValidationFetcher = ( input: string | URL | Request, @@ -28,6 +28,13 @@ export async function validateProviderCredential( fetcher: ProviderValidationFetcher = fetch, options: ProviderValidationOptions = {}, ): Promise { + if (!definition.validation) { + return { + status: "unavailable", + message: `Live credential validation is not available for ${definition.displayName}.`, + }; + } + const controller = new AbortController(); const setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs)); const clearTimer = options.clearTimer ?? ((handle) => { @@ -43,11 +50,11 @@ export async function validateProviderCredential( }, options.timeoutMs ?? DEFAULT_VALIDATION_TIMEOUT_MS); }); const response = await Promise.race([ - fetcher(definition.validationUrl, { + fetcher(definition.validation.url, { method: "GET", headers: { Accept: "application/json", - Authorization: `Bearer ${key}`, + [definition.validation.header]: `${definition.validation.prefix}${key}`, }, signal: controller.signal, }), diff --git a/packages/admin-cli/test/configure-providers.test.mjs b/packages/admin-cli/test/configure-providers.test.mjs index 04c11d8..9efe328 100644 --- a/packages/admin-cli/test/configure-providers.test.mjs +++ b/packages/admin-cli/test/configure-providers.test.mjs @@ -47,7 +47,7 @@ test("configureProviders marks configured providers, supports multiple selection await configureProviders({ prompt: createPrompt({ - textAnswers: ["2,3"], + textAnswers: ["10,1"], hiddenAnswers: ["openrouter-secret", "openai-secret"], }), wrangler: { @@ -68,7 +68,7 @@ test("configureProviders marks configured providers, supports multiple selection assert.deepEqual(validations, [ { provider: "OpenRouter", key: "openrouter-secret" }, - { provider: "OpenAI Platform", key: "openai-secret" }, + { provider: "OpenAI", key: "openai-secret" }, ]); assert.deepEqual(uploads, [ { @@ -78,11 +78,10 @@ test("configureProviders marks configured providers, supports multiple selection ]); const transcript = output.entries.map((entry) => entry.message).join("\n"); - assert.match(transcript, /1\. OpenCode Go/); - assert.match(transcript, /2\. OpenRouter/); - assert.match(transcript, /3\. OpenAI Platform \(configured\)/); + assert.match(transcript, /1\. OpenAI \(configured\)/); + assert.match(transcript, /10\. OpenRouter/); assert.match(transcript, /OpenRouter: validated/); - assert.match(transcript, /OpenAI Platform: validated/); + assert.match(transcript, /OpenAI: validated/); assert.doesNotMatch(transcript, /openrouter-secret|openai-secret/); }); @@ -113,7 +112,7 @@ test("configureProviders rejects blank secrets and re-prompts before validating }); assert.deepEqual(validations, ["retry-secret"]); - assert.deepEqual(uploads, [{ OPENCODE_API_KEY: "retry-secret" }]); + assert.deepEqual(uploads, [{ OPENAI_API_KEY: "retry-secret" }]); assert.match(output.entries.map((entry) => entry.message).join("\n"), /cannot be blank/i); }); @@ -182,8 +181,8 @@ test("configureProviders retries unavailable validation when requested", async ( }); assert.equal(attempts, 2); - assert.deepEqual(uploads, [{ OPENCODE_API_KEY: "retry-me" }]); - assert.match(output.entries.map((entry) => entry.message).join("\n"), /OpenCode Go: validated/); + assert.deepEqual(uploads, [{ OPENAI_API_KEY: "retry-me" }]); + assert.match(output.entries.map((entry) => entry.message).join("\n"), /OpenAI: validated/); }); test("configureProviders allows skipping unavailable validation but cancels upload on negative response", async () => { @@ -192,7 +191,7 @@ test("configureProviders allows skipping unavailable validation but cancels uplo await configureProviders({ prompt: createPrompt({ - textAnswers: ["2", "skip"], + textAnswers: ["10", "skip"], hiddenAnswers: ["skip-secret"], }), wrangler: { @@ -223,7 +222,7 @@ test("configureProviders allows skipping unavailable validation but cancels uplo () => configureProviders({ prompt: createPrompt({ - textAnswers: ["2", "no"], + textAnswers: ["10", "no"], hiddenAnswers: ["cancel-secret"], }), wrangler: { @@ -276,10 +275,39 @@ test("configureProviders re-prompts when unavailable validation decision is blan }); assert.equal(attempts, 2); - assert.deepEqual(uploads, [{ OPENCODE_API_KEY: "retry-secret" }]); + assert.deepEqual(uploads, [{ OPENAI_API_KEY: "retry-secret" }]); const transcript = output.entries.map((entry) => entry.message).join("\n"); assert.match(transcript, /Enter retry, skip, or no\/cancel/i); - assert.match(transcript, /OpenCode Go: skipped validation/); + assert.match(transcript, /OpenAI: skipped validation/); +}); + +test("configureProviders collects required public provider configuration once without printing it", async () => { + const uploads = []; + const output = createOutput(); + + await configureProviders({ + prompt: createPrompt({ + textAnswers: ["29", "skip", "account-id"], + hiddenAnswers: ["cloudflare-key"], + }), + wrangler: { + async whoami() {}, + async configuredSecrets() { return new Set(); }, + async uploadSecrets(secrets) { uploads.push(secrets); }, + }, + validator: async () => ({ + status: "unavailable", + message: "Live credential validation is not available for Cloudflare Workers AI.", + }), + output, + }); + + assert.deepEqual(uploads, [{ + CLOUDFLARE_API_KEY: "cloudflare-key", + CLOUDFLARE_ACCOUNT_ID: "account-id", + }]); + const transcript = output.entries.map((entry) => entry.message).join("\n"); + assert.doesNotMatch(transcript, /cloudflare-key|account-id/); }); test("runCli handles help, provider setup, non-tty provider usage, unknown commands, and safe error messages", async () => { diff --git a/packages/admin-cli/test/provider-validation.test.mjs b/packages/admin-cli/test/provider-validation.test.mjs index 81dfaa3..00a2836 100644 --- a/packages/admin-cli/test/provider-validation.test.mjs +++ b/packages/admin-cli/test/provider-validation.test.mjs @@ -8,8 +8,11 @@ const definition = { aliases: [], displayName: "OpenAI Platform", secretName: "OPENAI_API_KEY", - validationUrl: "https://api.openai.com/v1/models", - keyHelpUrl: "https://platform.openai.com/api-keys", + validation: { + url: "https://api.openai.com/v1/models", + header: "authorization", + prefix: "Bearer ", + }, }; function createFetchResponse(status) { @@ -38,7 +41,7 @@ test("sends the expected GET request with bearer authorization", async () => { assert.deepEqual(result, { status: "valid" }); assert.equal(calls.length, 1); - assert.equal(calls[0].url, definition.validationUrl); + assert.equal(calls[0].url, definition.validation.url); assert.equal(calls[0].init?.method, "GET"); assert.equal(calls[0].init?.signal instanceof AbortSignal, true); assert.deepEqual(Object.fromEntries(new Headers(calls[0].init?.headers).entries()), { @@ -47,6 +50,24 @@ test("sends the expected GET request with bearer authorization", async () => { }); }); +test("does not make a network request when the provider has no exact validation strategy", async () => { + let called = false; + const result = await validateProviderCredential( + { displayName: "Anthropic" }, + "fake-key", + async () => { + called = true; + return createFetchResponse(200); + }, + ); + + assert.equal(called, false); + assert.deepEqual(result, { + status: "unavailable", + message: "Live credential validation is not available for Anthropic.", + }); +}); + test("clears the validation deadline after a completed request", async () => { const timeoutHandle = Symbol("timeout"); let clearedHandle; diff --git a/packages/cli/package.json b/packages/cli/package.json index ae38ee6..22cac1f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@codevil/shared": "workspace:*", - "@mariozechner/pi-ai": "^0.73.1", + "@earendil-works/pi-ai": "^0.80.3", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/cli/src/models.ts b/packages/cli/src/models.ts index 1d45539..45e490b 100644 --- a/packages/cli/src/models.ts +++ b/packages/cli/src/models.ts @@ -1,4 +1,4 @@ -import { getModels, type KnownProvider } from "@mariozechner/pi-ai"; +import { getModels, type KnownProvider } from "@earendil-works/pi-ai/compat"; import { getProviderDefinition } from "@codevil/shared"; export interface AgentModelInfo { diff --git a/packages/cli/test/models.test.mjs b/packages/cli/test/models.test.mjs index 9dd1b55..33fb11a 100644 --- a/packages/cli/test/models.test.mjs +++ b/packages/cli/test/models.test.mjs @@ -3,6 +3,8 @@ import test from "node:test"; import { parseCommand } from "../dist/args.js"; import { checkAgentRunnableModel, listAgentRunnableModels } from "../dist/models.js"; +import { getModels } from "@earendil-works/pi-ai/compat"; +import { agentRunnableModelIds } from "@codevil/shared"; test("parseCommand parses models list", () => { assert.deepEqual(parseCommand(["models", "list"]), { @@ -28,9 +30,16 @@ test("listAgentRunnableModels includes kimi-k2.6 for opencode-go", () => { assert.equal(models.some((model) => model.id === "kimi-k2.6"), true); }); -test("checkAgentRunnableModel rejects unsupported opencode-go models", () => { +test("checkAgentRunnableModel follows the maintained OpenCode Go catalog", () => { assert.equal(checkAgentRunnableModel("opencode-go", "kimi-k2.6"), true); assert.equal(checkAgentRunnableModel("opencode-go", "deepseek-v4-flash"), true); + assert.equal(checkAgentRunnableModel("opencode-go", "glm-5.2"), true); assert.equal(checkAgentRunnableModel("opencode-go", "mimo-v2-omni"), false); - assert.equal(checkAgentRunnableModel("opencode-go", "glm-5.2"), false); +}); + +test("shared OpenCode Go filter matches Pi's maintained catalog", () => { + const maintainedIds = getModels("opencode-go").map((model) => model.id).sort(); + const sharedIds = [...(agentRunnableModelIds("opencode-go") ?? [])].sort(); + + assert.deepEqual(sharedIds, maintainedIds); }); diff --git a/packages/sandbox-image/package.json b/packages/sandbox-image/package.json index d1d16d9..458f70a 100644 --- a/packages/sandbox-image/package.json +++ b/packages/sandbox-image/package.json @@ -10,9 +10,8 @@ }, "dependencies": { "@codevil/shared": "workspace:*", - "@mariozechner/pi-ai": "^0.73.1", - "@mariozechner/pi-coding-agent": "^0.73.1", - "@sinclair/typebox": "^0.34.49", + "@earendil-works/pi-ai": "^0.80.3", + "@earendil-works/pi-coding-agent": "^0.80.3", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/sandbox-image/src/entrypoint.ts b/packages/sandbox-image/src/entrypoint.ts index 572b3ca..3e710b6 100644 --- a/packages/sandbox-image/src/entrypoint.ts +++ b/packages/sandbox-image/src/entrypoint.ts @@ -7,6 +7,7 @@ import { DOToSandboxMessageSchema, type EntrypointEnv, parseEntrypointEnv, + parseProviderPublicConfig, parseInbound, } from "@codevil/shared"; @@ -20,11 +21,15 @@ import { } from "./logging.js"; import { PiAgentDriver } from "./pi-driver.js"; import { SandboxRuntime } from "./runtime.js"; -import { readAndUnlinkSecret } from "./secrets.js"; import { ReconnectingWebSocketClient } from "./socket-client.js"; export type { EntrypointEnv } from "@codevil/shared"; +// Capabilities last 15 minutes. Refreshing over the existing authenticated DO +// socket before that window closes lets long-lived keepalive sandboxes continue +// without ever receiving a real provider credential. +const PROXY_CAPABILITY_REFRESH_MS = 10 * 60 * 1_000; + function isNodeENOENT(error: unknown): boolean { return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"; } @@ -70,7 +75,6 @@ export function createSandboxMessageDispatcher(runtime: SandboxMessageRuntime): } if ( - message.type === "credential_response" || message.type === "create_pr_response" || message.type === "ask_question_response" || message.type === "ask_question_cancelled" @@ -98,36 +102,53 @@ export async function startEntrypoint( const env = loadEnv(rawEnv); if (!env.CODEVIL_DO_WS_URL) throw new Error("CODEVIL_DO_WS_URL is required"); + if (!env.CODEVIL_PROXY_BASE || !env.CODEVIL_PROXY_TOKENS) { + throw new Error("Sandbox outbound proxy configuration is required"); + } - const wsUrl = env.CODEVIL_DO_WS_URL; + let wsUrl = withSandboxWebSocketToken(env.CODEVIL_DO_WS_URL, env.CODEVIL_SANDBOX_WS_TOKEN); const sessionId = sessionIdFromWsUrl(wsUrl); if (sessionId) setSandboxTraceFromSession(sessionId); await configureDefaultGitIdentity(); - const llmKey = await readAndUnlinkSecret(env.CODEVIL_LLM_KEY_FILE ?? "/run/secrets/llm_key"); let connection: ReconnectingWebSocketClient; + let proxyCapabilityRefreshTimer: NodeJS.Timeout | undefined; + const proxyTokens = parseProxyTokens(env.CODEVIL_PROXY_TOKENS); + const providerConfig = parseProviderPublicConfig(env.CODEVIL_PROVIDER_CONFIG); const runtime = new SandboxRuntime({ workspace: env.CODEVIL_WORKSPACE ?? "/workspace", provider: env.CODEVIL_PROVIDER ?? "anthropic", - llmKey, + providerConfig, + proxyTokens, + proxySessionId: sessionId, + proxyBase: env.CODEVIL_PROXY_BASE, send: (message: SandboxToDOMessage) => connection.send(JSON.stringify(message)), agentFactory: () => new PiAgentDriver(), - git: new ShellGitDriver(), + git: new ShellGitDriver({ + proxyBase: env.CODEVIL_PROXY_BASE, + proxySessionId: sessionId, + gitProxyCapability: proxyTokens?.git, + }), }); const dispatch = createSandboxMessageDispatcher(runtime); connection = new ReconnectingWebSocketClient({ createSocket: () => { sandboxLogger().log("INFO", "sandbox.ws.connecting", { target: wsUrlForLog(wsUrl) }); - return new WebSocket(wsUrl, { - headers: env.CODEVIL_API_KEY ? { Authorization: `Bearer ${env.CODEVIL_API_KEY}` } : undefined, - }); + return new WebSocket(wsUrl); }, onOpen: () => { sandboxLogger().log("INFO", "sandbox.ws.connected"); connection.send(JSON.stringify({ type: "status", message: "Sandbox connected." } satisfies SandboxToDOMessage)); + connection.send(JSON.stringify({ type: "proxy_capabilities_refresh_request" } satisfies SandboxToDOMessage)); + if (!proxyCapabilityRefreshTimer) { + proxyCapabilityRefreshTimer = setInterval(() => { + connection.send(JSON.stringify({ type: "proxy_capabilities_refresh_request" } satisfies SandboxToDOMessage)); + }, PROXY_CAPABILITY_REFRESH_MS); + proxyCapabilityRefreshTimer.unref(); + } }, onError: (error) => { sandboxLogException("sandbox.ws.error", error); @@ -145,9 +166,28 @@ export async function startEntrypoint( } const message = parseInbound(DOToSandboxMessageSchema, raw, "do_to_sandbox"); if (!message) return; + if (message.type === "proxy_capabilities" && message.sandbox_ws_token) { + wsUrl = withSandboxWebSocketToken(wsUrl, message.sandbox_ws_token); + } sandboxLogger().log("DEBUG", "sandbox.message.received", { message_type: message.type }); dispatch(message); }, }); connection.start(); } + +function withSandboxWebSocketToken(wsUrl: string, token: string | undefined): string { + if (!token) return wsUrl; + const url = new URL(wsUrl); + url.searchParams.set("sandbox_ws_token", token); + return url.toString(); +} + +function parseProxyTokens(value: string | undefined): Record | undefined { + if (!value) return undefined; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + return Object.fromEntries(Object.entries(parsed).filter((entry): entry is [string, string] => typeof entry[1] === "string")); + } catch { return undefined; } +} diff --git a/packages/sandbox-image/src/git-driver.ts b/packages/sandbox-image/src/git-driver.ts index ecc5d73..05d0bfd 100644 --- a/packages/sandbox-image/src/git-driver.ts +++ b/packages/sandbox-image/src/git-driver.ts @@ -2,7 +2,9 @@ import { mkdir } from "node:fs/promises"; import { spawn } from "node:child_process"; import { dirname } from "node:path"; -import type { GitCredential, GitDriver, PushBranchOptions } from "./runtime.js"; +import { normalizeGitHubRepoName } from "@codevil/shared"; + +import type { GitDriver, PushBranchOptions } from "./runtime.js"; export const DEFAULT_GIT_AUTHOR_NAME = "Codevil Coder"; export const DEFAULT_GIT_AUTHOR_EMAIL = "coder@codevil.com"; @@ -16,43 +18,48 @@ export async function configureDefaultGitIdentity(): Promise { } export class ShellGitDriver implements GitDriver { + private readonly proxyBase?: string; + private readonly proxySessionId?: string; + private gitProxyCapability?: string; + + constructor(options: { proxyBase?: string; proxySessionId?: string; gitProxyCapability?: string } = {}) { + this.proxyBase = options.proxyBase; + this.proxySessionId = options.proxySessionId; + this.gitProxyCapability = options.gitProxyCapability; + } + + refreshGitProxyCapability(capability: string | undefined): void { this.gitProxyCapability = capability; } + async clone( repo: string, destination: string, onProgress: (line: string) => void, - credential?: GitCredential, ): Promise { await mkdir(dirname(destination), { recursive: true }); - await run("git", shallowCloneArgs(credential ? withCredential(repo, credential) : repo, destination), { + const target = this.proxyUrl(repo); + await run("git", this.withProxyHeader(shallowCloneArgs(target, destination)), { onStderr: onProgress, }); - if (credential) { - await run("git", ["remote", "set-url", "origin", repo], { cwd: destination }); - } } async refresh( repo: string, cwd: string, onProgress: (line: string) => void, - credential?: GitCredential, cleanExcludes: string[] = [], ): Promise { - const fetchUrl = credential ? withCredential(repo, credential) : repo; + const fetchUrl = this.proxyUrl(repo); await run("git", ["remote", "set-url", "origin", fetchUrl], { cwd }); - try { - await run("git", ["fetch", "--progress", "--depth", "1", "--prune", "--no-tags", "origin"], { - cwd, - onStderr: onProgress, - }); - await run("git", ["remote", "set-head", "origin", "--auto"], { cwd, onStderr: onProgress }); - await run("git", ["reset", "--hard", "refs/remotes/origin/HEAD"], { cwd, onStdout: onProgress }); - await run("git", gitCleanArgs(cleanExcludes), { cwd, onStdout: onProgress }); - } finally { - if (credential) { - await run("git", ["remote", "set-url", "origin", repo], { cwd }); - } - } + await run("git", this.withProxyHeader(["fetch", "--progress", "--depth", "1", "--prune", "--no-tags", "origin"]), { + cwd, + onStderr: onProgress, + }); + await run("git", this.withProxyHeader(["remote", "set-head", "origin", "--auto"]), { + cwd, + onStderr: onProgress, + }); + await run("git", ["reset", "--hard", "refs/remotes/origin/HEAD"], { cwd, onStdout: onProgress }); + await run("git", gitCleanArgs(cleanExcludes), { cwd, onStdout: onProgress }); } async defaultBranch(cwd: string): Promise { @@ -64,13 +71,38 @@ export class ShellGitDriver implements GitDriver { await run("git", ["checkout", "-b", options.branch], { cwd: options.cwd }); await run("git", ["add", "-A"], { cwd: options.cwd }); await run("git", ["commit", "-m", options.commitMessage], { cwd: options.cwd }); - if (options.credential) { - const origin = (await run("git", ["remote", "get-url", "origin"], { cwd: options.cwd })).stdout.trim(); - await run("git", ["push", "-u", withCredential(origin, options.credential), options.branch], { cwd: options.cwd }); - return; + await run("git", this.withProxyHeader(["push", "-u", "origin", options.branch]), { cwd: options.cwd }); + } + + private proxyUrl(repo: string): string { + if (!this.hasProxyConfiguration()) return repo; + this.assertCompleteProxyConfiguration(); + + const normalized = normalizeGitHubRepoName(repo); + if (!normalized) { + throw new Error("Git proxy only permits canonical GitHub repository URLs"); } + const [owner, name] = normalized.split("/"); + return new URL( + `/sandbox-proxy/sessions/${encodeURIComponent(this.proxySessionId!)}/github/${encodeURIComponent(owner)}/${encodeURIComponent(name)}.git`, + this.proxyBase!, + ).toString(); + } + + private withProxyHeader(args: string[]): string[] { + if (!this.hasProxyConfiguration()) return args; + this.assertCompleteProxyConfiguration(); + return ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`x-access-token:${this.gitProxyCapability!}`).toString("base64")}`, ...args]; + } - await run("git", ["push", "-u", "origin", options.branch], { cwd: options.cwd }); + private hasProxyConfiguration(): boolean { + return Boolean(this.proxyBase || this.proxySessionId || this.gitProxyCapability); + } + + private assertCompleteProxyConfiguration(): void { + if (!this.proxyBase || !this.proxySessionId || !this.gitProxyCapability) { + throw new Error("Git proxy configuration is incomplete"); + } } } @@ -86,13 +118,6 @@ export function gitCleanArgs(excludes: string[] = []): string[] { ]; } -function withCredential(repo: string, credential: GitCredential): string { - const url = new URL(repo); - url.username = credential.username; - url.password = credential.password; - return url.toString(); -} - interface RunOptions { cwd?: string; onStdout?(line: string): void; diff --git a/packages/sandbox-image/src/pi-cost.ts b/packages/sandbox-image/src/pi-cost.ts index a2b704b..79d4a8e 100644 --- a/packages/sandbox-image/src/pi-cost.ts +++ b/packages/sandbox-image/src/pi-cost.ts @@ -1,4 +1,4 @@ -import type { AgentSession } from "@mariozechner/pi-coding-agent"; +import type { AgentSession } from "@earendil-works/pi-coding-agent"; import { type CostInfo, zeroCost } from "@codevil/shared"; interface SessionCostSnapshot { diff --git a/packages/sandbox-image/src/pi-driver.ts b/packages/sandbox-image/src/pi-driver.ts index 0a69e85..4066adc 100644 --- a/packages/sandbox-image/src/pi-driver.ts +++ b/packages/sandbox-image/src/pi-driver.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; -import { getModels, type KnownProvider, type Model } from "@mariozechner/pi-ai"; +import { Type } from "@earendil-works/pi-ai"; +import { getModels, type KnownProvider, type Model } from "@earendil-works/pi-ai/compat"; import { AuthStorage, createAgentSession, @@ -11,12 +12,13 @@ import { type AgentSession, type ResourceLoader, type ToolDefinition, -} from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; +} from "@earendil-works/pi-coding-agent"; import { type CostInfo, isRecord, + type ProviderApi, + type ProviderPublicConfig, } from "@codevil/shared"; import { @@ -39,19 +41,70 @@ import type { const DEFAULT_CODEVIL_PI_AGENT_DIR = "/opt/codevil/pi-agent"; +/** + * Pi model objects are immutable catalog entries in practice. Copying keeps the + * shared registry untouched while directing only this sandbox's requests through + * the Worker credential boundary. + */ +export function resolveProviderModel(model: Model, providerConfig: ProviderPublicConfig = {}): Model { + let baseUrl = model.baseUrl; + for (const [key, value] of Object.entries(providerConfig)) { + baseUrl = baseUrl.replaceAll(`{${key}}`, value); + } + if (/{CLOUDFLARE_(?:ACCOUNT|GATEWAY)_ID}/.test(baseUrl)) { + throw new Error(`Missing provider configuration for ${model.provider}`); + } + return { ...model, baseUrl }; +} + +function withProxyModel(model: Model, provider: string, proxyBase: string, sessionId: string): Model { + const original = new URL(model.baseUrl); + const proxy = new URL(`/sandbox-proxy/sessions/${encodeURIComponent(sessionId)}/llm/${encodeURIComponent(provider)}/${encodeURIComponent(model.api)}/`, proxyBase); + return { + ...model, + baseUrl: proxy.toString(), + headers: { + ...model.headers, + "x-codevil-proxy-target": original.toString(), + }, + }; +} + +/** + * Pi needs these IDs in its provider-scoped API-key credential to resolve + * Cloudflare model URLs. The key is always the short-lived proxy capability, + * never the Worker-held provider key, and in-memory storage avoids persistence. + */ +function setSandboxCredential( + authStorage: AuthStorage, + provider: string, + key: string | undefined, + providerConfig: ProviderPublicConfig | undefined, +): void { + if (key) { + authStorage.set(provider, { + type: "api_key", + key, + ...(providerConfig && Object.keys(providerConfig).length > 0 ? { env: providerConfig } : {}), + }); + authStorage.setRuntimeApiKey(provider, key); + } +} + export class PiAgentDriver implements AgentDriver { private session: AgentSession | undefined; private authStorage: AuthStorage | undefined; private modelRegistry: ModelRegistry | undefined; + private proxyBase: string | undefined; + private proxySessionId: string | undefined; + private proxyTokens: Partial> | undefined; + private provider: string | undefined; + private providerConfig: ProviderPublicConfig | undefined; private latestAssistantText = ""; private streamedAssistantText = ""; async start(options: AgentStartOptions): Promise { - const authStorage = AuthStorage.create(); - if (options.llmKey) { - authStorage.setRuntimeApiKey(options.provider, options.llmKey); - } - + const authStorage = AuthStorage.inMemory(); const modelRegistry = ModelRegistry.inMemory(authStorage); const provider = options.provider as KnownProvider; const model = modelRegistry.find(provider, options.model) @@ -61,6 +114,11 @@ export class PiAgentDriver implements AgentDriver { throw new Error(`Model not found: ${options.provider}/${options.model}`); } + const proxyToken = options.proxyTokens?.[model.api as ProviderApi]; + if (options.proxyBase && (!proxyToken || !options.proxySessionId)) throw new Error("Missing sandbox proxy capability for model API"); + setSandboxCredential(authStorage, options.provider, proxyToken ?? options.llmKey, options.providerConfig); + const configuredModel = resolveProviderModel(model, options.providerConfig); + const proxiedModel = options.proxyBase ? withProxyModel(configuredModel, options.provider, options.proxyBase, options.proxySessionId!) : configuredModel; const customTools: ReturnType[] = [ createPullRequestTool(options.createPullRequest), ]; @@ -77,7 +135,7 @@ export class PiAgentDriver implements AgentDriver { const { session } = await createAgentSession({ cwd: options.cwd, agentDir, - model, + model: proxiedModel, authStorage, modelRegistry, customTools, @@ -111,6 +169,11 @@ export class PiAgentDriver implements AgentDriver { this.session = session; this.authStorage = authStorage; this.modelRegistry = modelRegistry; + this.proxyBase = options.proxyBase; + this.proxySessionId = options.proxySessionId; + this.proxyTokens = options.proxyTokens; + this.provider = options.provider; + this.providerConfig = options.providerConfig && { ...options.providerConfig }; } async turn(prompt: string): Promise { @@ -144,11 +207,7 @@ export class PiAgentDriver implements AgentDriver { } async consolidateAnnotations(input: ConsolidationInput): Promise { - const authStorage = AuthStorage.create(); - if (input.llmKey) { - authStorage.setRuntimeApiKey(input.provider, input.llmKey); - } - + const authStorage = AuthStorage.inMemory(); const modelRegistry = ModelRegistry.inMemory(authStorage); const provider = input.provider as KnownProvider; const model = modelRegistry.find(provider, input.model) @@ -159,6 +218,11 @@ export class PiAgentDriver implements AgentDriver { } // Build the custom tools list: read-only file tools + ask_question when available. + const proxyToken = input.proxyTokens?.[model.api as ProviderApi]; + if (input.proxyBase && (!proxyToken || !input.proxySessionId)) throw new Error("Missing sandbox proxy capability for model API"); + setSandboxCredential(authStorage, input.provider, proxyToken ?? input.llmKey, input.providerConfig); + const configuredModel = resolveProviderModel(model, input.providerConfig); + const proxiedModel = input.proxyBase ? withProxyModel(configuredModel, input.provider, input.proxyBase, input.proxySessionId!) : configuredModel; const customTools: ReturnType[] = []; if (input.askQuestion) { customTools.push(askQuestionTool(input.askQuestion)); @@ -173,7 +237,7 @@ export class PiAgentDriver implements AgentDriver { const { session } = await createAgentSession({ cwd: input.cwd, agentDir, - model, + model: proxiedModel, authStorage, modelRegistry, customTools, @@ -199,13 +263,34 @@ export class PiAgentDriver implements AgentDriver { const session = this.requireSession(); const modelRegistry = this.modelRegistry; if (!modelRegistry) throw new Error("Model registry has not been initialized"); + const authStorage = this.authStorage; + if (!authStorage) throw new Error("Auth storage has not been initialized"); const knownProvider = provider as KnownProvider; const model = modelRegistry.find(knownProvider, modelId) ?? findKnownModel(knownProvider, modelId); if (!model) throw new Error(`Model not found: ${provider}/${modelId}`); + const proxyToken = this.proxyTokens?.[model.api as ProviderApi]; + if (this.proxyBase && (!proxyToken || !this.proxySessionId)) { + throw new Error("Missing sandbox proxy capability for model API"); + } + if (proxyToken) { + setSandboxCredential(authStorage, provider, proxyToken, this.providerConfig); + } + const proxiedModel = this.proxyBase + ? withProxyModel(resolveProviderModel(model, this.providerConfig), provider, this.proxyBase, this.proxySessionId!) + : resolveProviderModel(model, this.providerConfig); + this.provider = provider; + session.setActiveToolsByName(["read", "bash", "edit", "write"]); - await session.setModel(model); + await session.setModel(proxiedModel); + } + + refreshProxyCapabilities(tokens: Partial>): void { + this.proxyTokens = tokens; + if (!this.authStorage || !this.provider) return; + const token = this.session?.model?.api && tokens[this.session.model.api as ProviderApi]; + if (token) this.authStorage.setRuntimeApiKey(this.provider, token); } async execute(plan: string): Promise { diff --git a/packages/sandbox-image/src/preview-manager.ts b/packages/sandbox-image/src/preview-manager.ts index b055ce4..abd0f27 100644 --- a/packages/sandbox-image/src/preview-manager.ts +++ b/packages/sandbox-image/src/preview-manager.ts @@ -75,8 +75,10 @@ export class PreviewManager { } if (this.state.state === "starting") { - sandboxLogger().log("WARN", "preview_start_ignored", { reason: "already_starting" }); - return; + // A newer user request owns this lifecycle generation. Reap the old + // startup before launching the replacement so it cannot become ready + // after the new command has been selected. + await this.terminateChild(); } if (aborted()) return; @@ -146,7 +148,13 @@ export class PreviewManager { const crashedCommand = this.state.command; this.state = { state: "idle" }; - if (!this.stopping && wasRunning && this.autoRestartCount < MAX_PREVIEW_AUTO_RESTARTS) { + if (!wasRunning) { + // The start() await owns startup failure cleanup and emits its single + // terminal notification through stop(). + return; + } + + if (!this.stopping && this.autoRestartCount < MAX_PREVIEW_AUTO_RESTARTS) { this.autoRestartCount++; const restartGeneration = this.stopGeneration; sandboxLogger().log("INFO", "preview_auto_restart", { @@ -183,7 +191,15 @@ export class PreviewManager { this.autoRestartCount = 0; this.options.onReady(command); } catch (error) { + // A stop or replacement can make a readiness/child failure stale while + // the await above is in flight. Never let that older operation stop or + // report an error for the preview that replaced it. + const current = this.state as PreviewState; + if (aborted() || (current.state !== "idle" && current.child !== child)) return; + + const failureGeneration = this.stopGeneration; await this.stop(); + if (this.stopGeneration !== failureGeneration + 1) return; this.options.onError(withRecentLogs( error instanceof Error ? error.message : String(error), recentLogs, @@ -193,16 +209,21 @@ export class PreviewManager { async stop(): Promise { this.stopping = true; - this.stopGeneration++; + const stopGeneration = ++this.stopGeneration; this.autoRestartCount = 0; if (this.state.state === "idle") { - this.options.onStopped(); + if (stopGeneration === this.stopGeneration) this.options.onStopped(); return; } await this.terminateChild(); - this.options.onStopped(); + // A new user start may have begun while the old child was being reaped. + // In that case its generation owns lifecycle notifications. + const current = this.state as PreviewState; + if (stopGeneration === this.stopGeneration && current.state === "idle") { + this.options.onStopped(); + } } private async terminateChild(): Promise { diff --git a/packages/sandbox-image/src/runtime-helpers.ts b/packages/sandbox-image/src/runtime-helpers.ts index f84a240..cfb9696 100644 --- a/packages/sandbox-image/src/runtime-helpers.ts +++ b/packages/sandbox-image/src/runtime-helpers.ts @@ -9,20 +9,6 @@ export { addCost, zeroCost }; export const AGENT_PREVIEW_KEY = "agent"; -export function credentialRequestFromRepo(repo: string): { protocol: "https"; host: string; path: string } | null { - try { - const url = new URL(repo); - if (url.protocol !== "https:") return null; - return { - protocol: "https", - host: url.hostname, - path: url.pathname.replace(/^\/+/, ""), - }; - } catch { - return null; - } -} - export function detectLibc(): "gnu" | "musl" | undefined { if (process.platform !== "linux") return undefined; if ( diff --git a/packages/sandbox-image/src/runtime-rpc.ts b/packages/sandbox-image/src/runtime-rpc.ts index 7edd103..92adf18 100644 --- a/packages/sandbox-image/src/runtime-rpc.ts +++ b/packages/sandbox-image/src/runtime-rpc.ts @@ -1,8 +1,6 @@ import type { DOToSandboxMessage, SandboxToDOMessage } from "@codevil/shared"; -import type { GitCredential } from "./runtime-types.js"; import type { AskQuestionOutcome, AskQuestionParams, CreatePullRequestToolOptions } from "./runtime-types.js"; -type CredentialRequestMessage = Extract; type CreatePRRequestMessage = Extract; type AskQuestionRequestMessage = Extract; @@ -10,12 +8,7 @@ const DEFAULT_ASK_QUESTION_TIMEOUT_MS = 600_000; export class SandboxRpcCoordinator { private readonly send: (message: SandboxToDOMessage) => void; - private readonly credentialTimeoutMs: number; private readonly askQuestionTimeoutMs: number; - private credentialRequests = new Map; - }>(); private pullRequestRequests = new Map void; - credentialTimeoutMs: number; askQuestionTimeoutMs?: number; }) { this.send = options.send; - this.credentialTimeoutMs = options.credentialTimeoutMs; this.askQuestionTimeoutMs = options.askQuestionTimeoutMs ?? DEFAULT_ASK_QUESTION_TIMEOUT_MS; } - async requestCredential(request: Omit): Promise { - if (this.credentialTimeoutMs <= 0) return undefined; - - const requestId = `cred_${Date.now()}_${Math.random().toString(36).slice(2)}`; - this.send({ type: "credential_request", request_id: requestId, ...request }); - - return new Promise((resolve) => { - const timeout = setTimeout(() => { - this.credentialRequests.delete(requestId); - resolve(undefined); - }, this.credentialTimeoutMs); - - this.credentialRequests.set(requestId, { resolve, timeout }); - }); - } - - handleCredentialResponse(message: Extract): void { - const pending = this.credentialRequests.get(message.request_id); - if (!pending) return; - - clearTimeout(pending.timeout); - this.credentialRequests.delete(message.request_id); - - if (message.error || !message.username || !message.password) { - this.send({ type: "status", message: `Credential request denied: ${message.error ?? "missing credential"}` }); - pending.resolve(undefined); - return; - } - - pending.resolve({ username: message.username, password: message.password }); - } - async createPullRequest(options: { runId: string; branch: string; diff --git a/packages/sandbox-image/src/runtime-types.ts b/packages/sandbox-image/src/runtime-types.ts index a8974ec..78e22c5 100644 --- a/packages/sandbox-image/src/runtime-types.ts +++ b/packages/sandbox-image/src/runtime-types.ts @@ -7,14 +7,22 @@ import type { AnswerableBy, ParticipantIdentity, } from "@codevil/shared"; +import type { ProviderApi } from "@codevil/shared"; +import type { ProviderPublicConfig } from "@codevil/shared"; import type { CommandRunner, Verifier } from "./verification.js"; +export type ProxyCapabilities = Partial> & { git?: string }; + export interface AgentStartOptions { cwd: string; mode: "coding"; model: string; provider: string; + providerConfig?: ProviderPublicConfig; llmKey?: string; + proxyBase?: string; + proxyTokens?: ProxyCapabilities; + proxySessionId?: string; onEvent(event: unknown): void; createPullRequest(options: CreatePullRequestToolOptions): Promise<{ url: string }>; askQuestion?: (params: AskQuestionParams) => Promise; @@ -58,7 +66,11 @@ export interface ConsolidationInput { round: number; model: string; provider: string; + providerConfig?: ProviderPublicConfig; llmKey?: string; + proxyBase?: string; + proxyTokens?: ProxyCapabilities; + proxySessionId?: string; plan: string; annotations: ConsolidationAnnotation[]; askQuestion?: (params: AskQuestionParams) => Promise; @@ -76,6 +88,8 @@ export interface AgentDriver { plan(prompt: string): Promise; refine(feedback: string): Promise; consolidateAnnotations?(input: ConsolidationInput): Promise; + /** Replace expiring sandbox-only proxy capabilities without exposing provider keys. */ + refreshProxyCapabilities?(tokens: Partial>): Promise | void; switchToExecution(model: string, provider?: string): Promise; execute(plan: string): Promise; dispose?(): Promise | void; @@ -86,28 +100,23 @@ export interface AgentDriverFactory { } export interface GitDriver { - clone(repo: string, destination: string, onProgress: (line: string) => void, credential?: GitCredential): Promise; + clone(repo: string, destination: string, onProgress: (line: string) => void): Promise; refresh( repo: string, cwd: string, onProgress: (line: string) => void, - credential?: GitCredential, cleanExcludes?: string[], ): Promise; defaultBranch(cwd: string): Promise; pushBranch(options: PushBranchOptions): Promise; -} - -export interface GitCredential { - username: string; - password: string; + /** Replace the short-lived Git proxy capability; never a GitHub PAT. */ + refreshGitProxyCapability?(capability: string | undefined): void; } export interface PushBranchOptions { cwd: string; branch: string; commitMessage: string; - credential?: GitCredential; } export type RepoState = @@ -123,12 +132,15 @@ export type RepoState = export interface SandboxRuntimeOptions { workspace: string; provider?: string; + providerConfig?: ProviderPublicConfig; llmKey?: string; + proxyBase?: string; + proxyTokens?: ProxyCapabilities; + proxySessionId?: string; send(message: SandboxToDOMessage): void; agentFactory: AgentDriverFactory; git: GitDriver; verifier?: Verifier; commandRunner?: CommandRunner; - credentialTimeoutMs?: number; askQuestionTimeoutMs?: number; } diff --git a/packages/sandbox-image/src/runtime.ts b/packages/sandbox-image/src/runtime.ts index 1a52900..63354b1 100644 --- a/packages/sandbox-image/src/runtime.ts +++ b/packages/sandbox-image/src/runtime.ts @@ -61,7 +61,7 @@ export type { AgentDriver, AgentDriverFactory, GitDriver, - GitCredential, + ProxyCapabilities, PushBranchOptions, SandboxRuntimeOptions, } from "./runtime-types.js"; @@ -74,13 +74,13 @@ import type { AskQuestionParams, CreatePullRequestToolOptions, GitDriver, + ProxyCapabilities, RepoState, SandboxRuntimeOptions, } from "./runtime-types.js"; import { AGENT_PREVIEW_KEY, addCost, - credentialRequestFromRepo, detectLibc, fallbackConsolidation, inferFrameworkFromCommand, @@ -92,13 +92,16 @@ import { export class SandboxRuntime { private readonly workspace: string; private readonly provider: string; + private readonly providerConfig: import("@codevil/shared").ProviderPublicConfig | undefined; private readonly llmKey: string | undefined; + private readonly proxyBase: string | undefined; + private proxyTokens: ProxyCapabilities | undefined; + private readonly proxySessionId: string | undefined; private readonly send: (message: SandboxToDOMessage) => void; private readonly agentFactory: AgentDriverFactory; private readonly git: GitDriver; private readonly verifier: Verifier; private readonly commandRunner: CommandRunner; - private readonly credentialTimeoutMs: number; private repo: RepoState = { state: "uninit" }; private agent: AgentDriver | undefined; private activeRunId: string | undefined; @@ -109,22 +112,30 @@ export class SandboxRuntime { constructor(options: SandboxRuntimeOptions) { this.workspace = options.workspace; this.provider = options.provider ?? "anthropic"; + this.providerConfig = options.providerConfig; this.llmKey = options.llmKey; + this.proxyBase = options.proxyBase; + this.proxyTokens = options.proxyTokens; + this.proxySessionId = options.proxySessionId; this.send = options.send; this.agentFactory = options.agentFactory; this.git = options.git; this.commandRunner = options.commandRunner ?? new ShellCommandRunner(); this.verifier = options.verifier ?? new RepositoryVerifier(this.commandRunner); - this.credentialTimeoutMs = options.credentialTimeoutMs ?? 10_000; this.rpc = new SandboxRpcCoordinator({ send: this.send, - credentialTimeoutMs: this.credentialTimeoutMs, askQuestionTimeoutMs: options.askQuestionTimeoutMs, }); } async handleMessage(message: DOToSandboxMessage): Promise { try { + if (message.type === "proxy_capabilities") { + this.proxyTokens = message.tokens as ProxyCapabilities; + this.git.refreshGitProxyCapability?.(message.tokens.git); + await this.agent?.refreshProxyCapabilities?.(this.proxyTokens); + return; + } // Bootstrap or refresh the tracer from any trace_id present on the wire. // Init carries it for clone/setup spans; phase-starting messages carry // both trace_id and parent_span_id so sandbox spans nest under the DO's @@ -159,9 +170,6 @@ export class SandboxRuntime { case "create_pr": await this.handleCreatePullRequest(message, parent); return; - case "credential_response": - this.rpc.handleCredentialResponse(message); - return; case "create_pr_response": this.rpc.handleCreatePRResponse(message); return; @@ -218,22 +226,18 @@ export class SandboxRuntime { : undefined; this.send({ type: "clone_started" }); - const credentialRequest = credentialRequestFromRepo(repo); - const credential = credentialRequest - ? await this.rpc.requestCredential(credentialRequest) - : undefined; if (restoredFromCache && existsSync(join(repoDir, ".git"))) { await this.maybeSpan("sandbox.repo_refresh", { attributes: { repo } }, async () => { this.send({ type: "clone_progress", line: `Refreshing cached repository in ${repoDir}` }); await this.git.refresh(repo, repoDir, (line) => { this.send({ type: "clone_progress", line }); - }, credential, dependencyCleanExcludesForMarker(restoredDependencyMarker)); + }, dependencyCleanExcludesForMarker(restoredDependencyMarker)); }); } else { await this.maybeSpan("sandbox.clone", { attributes: { repo } }, () => this.git.clone(repo, repoDir, (line) => { this.send({ type: "clone_progress", line }); - }, credential), + }), ); } @@ -291,7 +295,11 @@ export class SandboxRuntime { mode: "coding", model, provider: provider ?? this.provider, + providerConfig: this.providerConfig, llmKey: this.llmKey, + proxyBase: this.proxyBase, + proxyTokens: this.proxyTokens, + proxySessionId: this.proxySessionId, onEvent: (event) => { // Validate Pi events at the SDK boundary. Known shapes narrow; unknown // event types pass through opaquely so a Pi version bump can't kill @@ -333,7 +341,11 @@ export class SandboxRuntime { mode: "coding", model, provider: provider ?? this.provider, + providerConfig: this.providerConfig, llmKey: this.llmKey, + proxyBase: this.proxyBase, + proxyTokens: this.proxyTokens, + proxySessionId: this.proxySessionId, onEvent: (event) => { const validated = parseInbound(PiAgentEventSchema, event, "pi_agent_event"); if (validated) this.send({ type: "agent_event", event: validated }); @@ -371,15 +383,10 @@ export class SandboxRuntime { body: options.body, draft: options.draft ?? true, push: async () => { - const credentialRequest = credentialRequestFromRepo(repo.url); - const credential = credentialRequest - ? await this.rpc.requestCredential(credentialRequest) - : undefined; await this.git.pushBranch({ cwd: repo.dir, branch, commitMessage: options.commit_message?.trim() || options.title, - credential, }); }, }); @@ -413,7 +420,11 @@ export class SandboxRuntime { round: message.round, model: message.model, provider: message.provider ?? this.provider, + providerConfig: this.providerConfig, llmKey: this.llmKey, + proxyBase: this.proxyBase, + proxyTokens: this.proxyTokens, + proxySessionId: this.proxySessionId, plan: message.plan, annotations: message.annotations, askQuestion, @@ -492,10 +503,6 @@ export class SandboxRuntime { parent: SpanContext | undefined, ): Promise { const repo = this.requireRepo(); - const credentialRequest = credentialRequestFromRepo(repo.url); - const credential = credentialRequest - ? await this.rpc.requestCredential(credentialRequest) - : undefined; await this.maybeSpan( "sandbox.push_branch", { parent, attributes: { branch: message.branch } }, @@ -504,7 +511,6 @@ export class SandboxRuntime { cwd: repo.dir, branch: message.branch, commitMessage: message.commit_message, - credential, }), ); diff --git a/packages/sandbox-image/test/dockerfile.test.mjs b/packages/sandbox-image/test/dockerfile.test.mjs new file mode 100644 index 0000000..9fe11f4 --- /dev/null +++ b/packages/sandbox-image/test/dockerfile.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const dockerfilePath = resolve(dirname(fileURLToPath(import.meta.url)), "../../../Dockerfile.sandbox"); + +test("sandbox runtime preserves Node 22 development tools", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + const runtime = dockerfile.split("AS sandbox-runtime")[1]; + + assert.ok(runtime, "Dockerfile must define a separate Sandbox entrypoint stage"); + assert.match(runtime, /FROM --platform=\$CODEVIL_SANDBOX_PLATFORM node:22-slim/); + assert.match(runtime, /COPY --from=sandbox-runtime \/container-server\/sandbox \/sandbox/); + assert.match(runtime, /ENTRYPOINT \["\/sandbox"\]/); + for (const tool of ["git", "curl", "wget", "jq", "zip", "unzip", "file", "procps"]) { + assert.match(runtime, new RegExp(`\\b${tool}\\b`)); + } + assert.match(runtime, /npm install -g bun@/); + for (const command of ["node --version", "git --version", "bun --version"]) { + assert.match(runtime, new RegExp(command)); + } +}); + +test("sandbox entrypoint and agent processes run as an unprivileged user", async () => { + const dockerfile = await readFile(dockerfilePath, "utf8"); + const runtime = dockerfile.split("AS sandbox-runtime")[1]; + + assert.match(runtime, /groupadd --gid 10001 codevil/); + assert.match(runtime, /useradd --uid 10001 --gid codevil --create-home codevil/); + assert.match(runtime, /chown -R codevil:codevil \/app \/workspace \/run\/secrets \/opt\/codevil/); + assert.match(runtime, /USER codevil\s+WORKDIR \/workspace/); + assert.doesNotMatch(runtime.slice(runtime.lastIndexOf("USER codevil")), /USER root/); +}); diff --git a/packages/sandbox-image/test/git-driver.test.mjs b/packages/sandbox-image/test/git-driver.test.mjs index bf960dc..4e9dd83 100644 --- a/packages/sandbox-image/test/git-driver.test.mjs +++ b/packages/sandbox-image/test/git-driver.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -82,7 +82,7 @@ test("refresh preserves excluded dependency artifacts while cleaning other ignor await writeFile(join(checkout, "ignored.log"), "remove me\n"); const driver = new ShellGitDriver(); - await driver.refresh(origin, checkout, () => {}, undefined, [ + await driver.refresh(origin, checkout, () => {}, [ "node_modules/", "**/node_modules/", ]); @@ -101,6 +101,110 @@ test("refresh preserves excluded dependency artifacts while cleaning other ignor } }); +test("refresh sends the proxy capability for every remote request without persisting it", async () => { + const root = await mkdtemp(join(tmpdir(), "codevil-git-proxy-refresh-")); + const checkout = join(root, "checkout"); + const bin = join(root, "bin"); + const commandLog = join(root, "git-commands.jsonl"); + const originalPath = process.env.PATH; + const capability = "git-capability-that-must-not-persist"; + + try { + await mkdir(join(checkout, ".git"), { recursive: true }); + await mkdir(bin, { recursive: true }); + await writeFile(join(checkout, ".git", "config"), "[core]\n\trepositoryformatversion = 0\n"); + await writeFile(join(bin, "git"), `#!/usr/bin/env node +const { appendFileSync } = require("node:fs"); +appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv.slice(2)) + "\\n"); +`); + await chmod(join(bin, "git"), 0o755); + process.env.PATH = `${bin}:${originalPath ?? ""}`; + process.env.CODEVIL_GIT_COMMAND_LOG = commandLog; + + const driver = new ShellGitDriver({ + proxyBase: "https://worker.example", + proxySessionId: "session-1", + gitProxyCapability: capability, + }); + await driver.refresh("https://github.com/example/app.git", checkout, () => {}); + + const commands = (await readFile(commandLog, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + const header = `http.extraHeader=Authorization: Basic ${Buffer.from(`x-access-token:${capability}`).toString("base64")}`; + const fetch = commands.find((args) => args.includes("fetch")); + const setHead = commands.find((args) => args.includes("set-head")); + const setUrl = commands.find((args) => args.includes("set-url")); + + assert.ok(fetch); + assert.ok(setHead); + assert.ok(setUrl); + assert.ok(fetch.includes(header)); + assert.ok(setHead.includes(header)); + assert.ok(!setUrl.includes(header)); + assert.ok(!commands.flat().some((argument) => argument.includes(capability) && argument !== header)); + assert.doesNotMatch(await readFile(join(checkout, ".git", "config"), "utf8"), new RegExp(capability)); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + delete process.env.CODEVIL_GIT_COMMAND_LOG; + await rm(root, { recursive: true, force: true }); + } +}); + +test("GitHub clone and refresh normalize HTTPS and bare remotes through the capability proxy", async () => { + const root = await mkdtemp(join(tmpdir(), "codevil-git-proxy-normalization-")); + const checkout = join(root, "checkout"); + const bin = join(root, "bin"); + const commandLog = join(root, "git-commands.jsonl"); + const originalPath = process.env.PATH; + + try { + await mkdir(join(checkout, ".git"), { recursive: true }); + await mkdir(bin, { recursive: true }); + await writeFile(join(bin, "git"), `#!/usr/bin/env node +const { appendFileSync } = require("node:fs"); +appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv.slice(2)) + "\\n"); +`); + await chmod(join(bin, "git"), 0o755); + process.env.PATH = `${bin}:${originalPath ?? ""}`; + process.env.CODEVIL_GIT_COMMAND_LOG = commandLog; + + const driver = new ShellGitDriver({ + proxyBase: "https://worker.example/base/", + proxySessionId: "session-1", + gitProxyCapability: "capability", + }); + await driver.clone("https://github.com/example/app", join(root, "clone"), () => {}); + await driver.refresh("github.com/example/app", checkout, () => {}); + + const commands = (await readFile(commandLog, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); + const proxyUrl = "https://worker.example/sandbox-proxy/sessions/session-1/github/example/app.git"; + assert.ok(commands.some((args) => args.includes(proxyUrl)), "clone uses proxy URL"); + assert.ok(commands.some((args) => args.includes("set-url") && args.includes(proxyUrl)), "refresh uses proxy URL"); + assert.ok(!commands.flat().some((argument) => argument === "https://github.com/example/app" || argument === "github.com/example/app")); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + delete process.env.CODEVIL_GIT_COMMAND_LOG; + await rm(root, { recursive: true, force: true }); + } +}); + +test("configured Git proxy fails closed instead of falling back to unsafe or non-GitHub remotes", async () => { + const driver = new ShellGitDriver({ + proxyBase: "https://worker.example", + proxySessionId: "session-1", + gitProxyCapability: "capability", + }); + + for (const repo of [ + "https://github.com/example/app.git/extra", + "https://github.com@example.evil/example/app.git", + "https://gitlab.com/example/app.git", + ]) { + await assert.rejects(() => driver.clone(repo, "/tmp/never-clone", () => {}), /Git proxy only permits canonical GitHub repository URLs/); + } +}); + test("runGitCommand rejects when a command exceeds its timeout", async () => { await assert.rejects( () => runGitCommand("node", ["-e", "setTimeout(() => {}, 60_000)"], { timeoutMs: 100 }), diff --git a/packages/sandbox-image/test/pi-driver.test.mjs b/packages/sandbox-image/test/pi-driver.test.mjs index 0287db7..8e1cc14 100644 --- a/packages/sandbox-image/test/pi-driver.test.mjs +++ b/packages/sandbox-image/test/pi-driver.test.mjs @@ -3,8 +3,15 @@ import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { getModels } from "@earendil-works/pi-ai/compat"; -import { PiAgentDriver, extractAssistantDeltaFromEvent, extractAssistantTextFromEvent, consolidationPrompt } from "../dist/pi-driver.js"; +import { + PiAgentDriver, + extractAssistantDeltaFromEvent, + extractAssistantTextFromEvent, + consolidationPrompt, + resolveProviderModel, +} from "../dist/pi-driver.js"; test("extracts plan text from Pi agent_end messages", () => { const text = extractAssistantTextFromEvent({ @@ -46,7 +53,7 @@ test("starts with coding tools, create_pull_request, and ask_question active", a cwd, mode: "coding", provider: "anthropic", - model: "claude-3-5-haiku-20241022", + model: "claude-haiku-4-5", llmKey: "test-key", onEvent: () => {}, createPullRequest: async (options) => { @@ -80,6 +87,208 @@ test("starts with coding tools, create_pull_request, and ask_question active", a } }); +test("starts an OpenCode Go session with Pi's maintained Kimi K2.7 Code catalog model", async () => { + const cwd = await mkdtemp(join(tmpdir(), "codevil-pi-opencode-go-")); + const driver = new PiAgentDriver(); + + try { + await driver.start({ + cwd, + mode: "coding", + provider: "opencode-go", + model: "kimi-k2.7-code", + llmKey: "test-key", + onEvent: () => {}, + createPullRequest: async () => ({ url: "https://github.com/example/app/pull/1" }), + }); + + assert.equal(driver.session.model?.id, "kimi-k2.7-code"); + assert.equal(driver.session.model?.provider, "opencode-go"); + } finally { + driver.dispose(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("Cloudflare Pi models resolve declared account and gateway identifiers before proxy routing", () => { + const workersAi = getModels("cloudflare-workers-ai")[0]; + const aiGateway = getModels("cloudflare-ai-gateway")[0]; + + assert.equal( + resolveProviderModel(workersAi, { CLOUDFLARE_ACCOUNT_ID: "account_123" }).baseUrl, + "https://api.cloudflare.com/client/v4/accounts/account_123/ai/v1", + ); + assert.equal( + resolveProviderModel(aiGateway, { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + }).baseUrl, + "https://gateway.ai.cloudflare.com/v1/account_123/gateway_456/anthropic", + ); + assert.throws( + () => resolveProviderModel(aiGateway, { CLOUDFLARE_ACCOUNT_ID: "account_123" }), + /Missing provider configuration for cloudflare-ai-gateway/, + ); +}); + +test("Cloudflare Workers AI uses a proxy capability and resolved upstream target instead of an operator credential", async () => { + const cwd = await mkdtemp(join(tmpdir(), "codevil-pi-cloudflare-workers-ai-")); + const driver = new PiAgentDriver(); + const model = getModels("cloudflare-workers-ai")[0]; + + try { + await driver.start({ + cwd, + mode: "coding", + provider: "cloudflare-workers-ai", + model: model.id, + proxyBase: "https://worker.example", + proxySessionId: "ses_cloudflare", + proxyTokens: { "openai-completions": "short-lived-proxy-capability" }, + providerConfig: { CLOUDFLARE_ACCOUNT_ID: "account_123" }, + onEvent: () => {}, + createPullRequest: async () => ({ url: "https://github.com/example/app/pull/1" }), + }); + + assert.equal( + driver.session.model.baseUrl, + "https://worker.example/sandbox-proxy/sessions/ses_cloudflare/llm/cloudflare-workers-ai/openai-completions/", + ); + assert.equal( + driver.session.model.headers["x-codevil-proxy-target"], + "https://api.cloudflare.com/client/v4/accounts/account_123/ai/v1", + ); + assert.equal(await driver.authStorage.getApiKey("cloudflare-workers-ai"), "short-lived-proxy-capability"); + assert.deepEqual(await driver.authStorage.getProviderEnv("cloudflare-workers-ai"), { + CLOUDFLARE_ACCOUNT_ID: "account_123", + }); + } finally { + driver.dispose(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("Cloudflare AI Gateway uses a proxy capability and its resolved account/gateway target", async () => { + const cwd = await mkdtemp(join(tmpdir(), "codevil-pi-cloudflare-gateway-")); + const driver = new PiAgentDriver(); + const model = getModels("cloudflare-ai-gateway")[0]; + + try { + await driver.start({ + cwd, + mode: "coding", + provider: "cloudflare-ai-gateway", + model: model.id, + proxyBase: "https://worker.example", + proxySessionId: "ses_cloudflare_gateway", + proxyTokens: { "anthropic-messages": "short-lived-gateway-capability" }, + providerConfig: { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + }, + onEvent: () => {}, + createPullRequest: async () => ({ url: "https://github.com/example/app/pull/1" }), + }); + + assert.equal( + driver.session.model.baseUrl, + "https://worker.example/sandbox-proxy/sessions/ses_cloudflare_gateway/llm/cloudflare-ai-gateway/anthropic-messages/", + ); + assert.equal( + driver.session.model.headers["x-codevil-proxy-target"], + "https://gateway.ai.cloudflare.com/v1/account_123/gateway_456/anthropic", + ); + assert.equal(await driver.authStorage.getApiKey("cloudflare-ai-gateway"), "short-lived-gateway-capability"); + assert.deepEqual(await driver.authStorage.getProviderEnv("cloudflare-ai-gateway"), { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + }); + } finally { + driver.dispose(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("switchToExecution preserves the Pi provider target and refreshes the selected API capability", async () => { + const cwd = await mkdtemp(join(tmpdir(), "codevil-pi-proxy-switch-")); + const driver = new PiAgentDriver(); + try { + await driver.start({ + cwd, + mode: "coding", + provider: "anthropic", + model: "claude-haiku-4-5", + proxyBase: "https://worker.example/base", + proxySessionId: "ses_proxy_test", + proxyTokens: { "anthropic-messages": "initial-capability" }, + onEvent: () => {}, + createPullRequest: async () => ({ url: "https://github.com/example/app/pull/1" }), + }); + + driver.refreshProxyCapabilities({ "anthropic-messages": "renewed-capability" }); + await driver.switchToExecution("claude-haiku-4-5", "anthropic"); + + const model = driver.session.model; + assert.equal(model.baseUrl, "https://worker.example/sandbox-proxy/sessions/ses_proxy_test/llm/anthropic/anthropic-messages/"); + assert.equal(model.headers["x-codevil-proxy-target"], "https://api.anthropic.com/"); + assert.equal(driver.authStorage.runtimeOverrides.get("anthropic"), "renewed-capability"); + } finally { + driver.dispose(); + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("switchToExecution retains Cloudflare provider configuration while rebuilding its proxy target", async () => { + const cases = [ + { + provider: "cloudflare-workers-ai", + api: "openai-completions", + config: { CLOUDFLARE_ACCOUNT_ID: "account_123" }, + target: "https://api.cloudflare.com/client/v4/accounts/account_123/ai/v1", + }, + { + provider: "cloudflare-ai-gateway", + api: "anthropic-messages", + config: { CLOUDFLARE_ACCOUNT_ID: "account_123", CLOUDFLARE_GATEWAY_ID: "gateway_456" }, + target: "https://gateway.ai.cloudflare.com/v1/account_123/gateway_456/anthropic", + }, + ]; + + for (const scenario of cases) { + const cwd = await mkdtemp(join(tmpdir(), `codevil-pi-${scenario.provider}-switch-`)); + const driver = new PiAgentDriver(); + const model = getModels(scenario.provider)[0]; + try { + await driver.start({ + cwd, + mode: "coding", + provider: scenario.provider, + model: model.id, + proxyBase: "https://worker.example", + proxySessionId: "ses_cloudflare_switch", + proxyTokens: { [scenario.api]: "initial-capability" }, + providerConfig: scenario.config, + onEvent: () => {}, + createPullRequest: async () => ({ url: "https://github.com/example/app/pull/1" }), + }); + + driver.refreshProxyCapabilities({ [scenario.api]: "renewed-capability" }); + await driver.switchToExecution(model.id, scenario.provider); + + assert.equal( + driver.session.model.baseUrl, + `https://worker.example/sandbox-proxy/sessions/ses_cloudflare_switch/llm/${scenario.provider}/${scenario.api}/`, + ); + assert.equal(driver.session.model.headers["x-codevil-proxy-target"], scenario.target); + assert.deepEqual(await driver.authStorage.getProviderEnv(scenario.provider), scenario.config); + assert.equal(await driver.authStorage.getApiKey(scenario.provider), "renewed-capability"); + } finally { + driver.dispose(); + await rm(cwd, { recursive: true, force: true }); + } + } +}); + test("starts with Codevil sandbox skills loaded from the agent directory", async () => { const cwd = await mkdtemp(join(tmpdir(), "codevil-pi-driver-cwd-")); const agentDir = await mkdtemp(join(tmpdir(), "codevil-pi-agent-")); @@ -106,7 +315,7 @@ test("starts with Codevil sandbox skills loaded from the agent directory", async cwd, mode: "coding", provider: "anthropic", - model: "claude-3-5-haiku-20241022", + model: "claude-haiku-4-5", llmKey: "test-key", onEvent: () => {}, createPullRequest: async () => ({ url: "https://github.com/example/app/pull/1" }), diff --git a/packages/sandbox-image/test/runtime.test.mjs b/packages/sandbox-image/test/runtime.test.mjs index ba6599d..71e18e3 100644 --- a/packages/sandbox-image/test/runtime.test.mjs +++ b/packages/sandbox-image/test/runtime.test.mjs @@ -97,7 +97,7 @@ test("init runs repository setup after clone", async () => { } }); -test("init uses credential_response for authenticated GitHub clone", async () => { +test("init never requests a GitHub credential over the sandbox socket", async () => { const sent = []; const git = new FakeGitDriver(); const runtime = new SandboxRuntime({ @@ -107,27 +107,14 @@ test("init uses credential_response for authenticated GitHub clone", async () => git, }); - const init = runtime.handleMessage({ type: "init", repo: "https://github.com/example/app.git" }); - await new Promise((resolve) => setImmediate(resolve)); - - const request = sent.find((message) => message.type === "credential_request"); - assert.equal(request.host, "github.com"); - assert.equal(request.path, "example/app.git"); - - await runtime.handleMessage({ - type: "credential_response", - request_id: request.request_id, - username: "x-access-token", - password: "ghp_secret", - }); - await init; + await runtime.handleMessage({ type: "init", repo: "https://github.com/example/app.git" }); assert.deepEqual(git.calls[0], [ "clone", "https://github.com/example/app.git", "/workspace/repo", - { username: "x-access-token", password: "ghp_secret" }, ]); + assert.equal(sent.some((message) => message.type === "credential_request"), false); }); test("init refreshes a restored cached repository instead of cloning", async () => { @@ -754,12 +741,13 @@ test("PreviewManager includes recent process output when startup times out", asy test("PreviewManager reports child exit before the readiness timeout", async () => { const workspace = await mkdtemp(join(tmpdir(), "codevil-preview-exit-")); const errors = []; + const stopped = []; const manager = new PreviewManager({ cwd: workspace, readinessTimeoutMs: 500, onStarting() {}, onReady() {}, - onStopped() {}, + onStopped: () => stopped.push(true), onError: (message) => errors.push(message), }); @@ -771,6 +759,7 @@ test("PreviewManager reports child exit before the readiness timeout", async () assert.match(errors[0], /Preview command exited before becoming healthy \(code 7\)\./); assert.doesNotMatch(errors[0], /Preview server did not become healthy/); + assert.equal(stopped.length, 1, "startup failure must publish exactly one terminal stop"); } finally { await manager.stop(); await rm(workspace, { recursive: true, force: true }); @@ -975,6 +964,108 @@ test("PreviewManager stop terminates a running child within the grace period", a } }); +test("PreviewManager ignores stale startup completion after stop", async () => { + const workspace = await mkdtemp(join(tmpdir(), "codevil-preview-stale-stop-")); + const ready = []; + const errors = []; + const stopped = []; + const manager = new PreviewManager({ + cwd: workspace, + readinessTimeoutMs: 2_000, + onStarting() {}, + onReady: (command) => ready.push(command), + onStopped: () => stopped.push(true), + onError: (message) => errors.push(message), + }); + + try { + const starting = manager.start({ + command: "node -e \"setTimeout(() => require('http').createServer((req, res) => res.end('ok')).listen(59982, '127.0.0.1'), 500)\"", + port: 59982, + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + await manager.stop(); + await starting; + + assert.equal(ready.length, 0); + assert.equal(errors.length, 0, "a cancelled start must not surface a stale error"); + assert.equal(stopped.length, 1, "only the explicit stop should publish stopped"); + } finally { + await manager.stop(); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("PreviewManager replaces a still-starting preview with the newer command", async () => { + const workspace = await mkdtemp(join(tmpdir(), "codevil-preview-starting-replacement-")); + const oldPort = 50_000 + (process.pid % 5_000); + const replacementPort = oldPort + 1; + const starts = []; + const stopped = []; + const manager = new PreviewManager({ + cwd: workspace, + readinessTimeoutMs: 2_000, + onStarting: (command) => starts.push(command), + onReady() {}, + onStopped: () => stopped.push(true), + onError() {}, + }); + + try { + const first = manager.start({ + command: `node -e "setTimeout(() => require('http').createServer((req, res) => res.end('old')).listen(${oldPort}, '127.0.0.1'), 500)"`, + port: oldPort, + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + + await manager.start({ command: previewHttpServerCommand(replacementPort), port: replacementPort }); + await first; + + assert.deepEqual(starts.map((command) => command.port), [oldPort, replacementPort]); + } finally { + await manager.stop(); + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("PreviewManager does not publish stale stopped after a replacement start", async () => { + const workspace = await mkdtemp(join(tmpdir(), "codevil-preview-stale-replacement-")); + const stopped = []; + const manager = new PreviewManager({ + cwd: workspace, + readinessTimeoutMs: 2_000, + onStarting() {}, + onReady() {}, + onStopped: () => stopped.push(true), + onError() {}, + }); + + try { + const first = manager.start({ + command: "node -e \"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\"", + port: 59981, + }); + await new Promise((resolve) => setTimeout(resolve, 30)); + const stopping = manager.stop(); + await new Promise((resolve) => setTimeout(resolve, 30)); + await manager.start({ command: previewHttpServerCommand(59980), port: 59980 }); + const stoppedBeforeOldStopCompletes = stopped.length; + await stopping; + await first; + + assert.equal( + stopped.length, + stoppedBeforeOldStopCompletes, + "the old stop must not overwrite the replacement preview state", + ); + await manager.stop(); + assert.equal(stopped.length, stoppedBeforeOldStopCompletes + 1); + } finally { + await manager.stop(); + await rm(workspace, { recursive: true, force: true }); + } +}); + test("detectPackageManager walks from app cwd up to repo root for lockfiles", async () => { const workspace = await mkdtemp(join(tmpdir(), "codevil-package-manager-walk-")); try { @@ -1365,6 +1456,24 @@ test("execute switches to coding tools and reports execution completion", async }); }); +test("proxy capability refresh updates the live agent without a restart", async () => { + const agent = new FakeAgentDriver({ plan: { plan: "## Plan", cost: zeroCost } }); + const runtime = new SandboxRuntime({ + workspace: "/workspace", + proxyTokens: { "anthropic-messages": "old-capability" }, + send: () => {}, + agentFactory: () => agent, + git: new FakeGitDriver(), + credentialTimeoutMs: 0, + }); + + await runtime.handleMessage({ type: "init", repo: "https://github.com/example/app" }); + await runtime.handleMessage({ type: "plan", run_id: "run_1", prompt: "plan", model: "planner" }); + await runtime.handleMessage({ type: "proxy_capabilities", tokens: { "anthropic-messages": "renewed-capability" } }); + + assert.deepEqual(agent.calls.at(-1), ["refreshProxyCapabilities", { "anthropic-messages": "renewed-capability" }]); +}); + test("execute runs verification and retries fixes before reporting completion", async () => { const sent = []; const agent = new FakeAgentDriver({ @@ -1523,8 +1632,8 @@ class FakeGitDriver { this.options = options; } - async clone(repo, destination, onProgress, credential) { - this.calls.push(credential ? ["clone", repo, destination, credential] : ["clone", repo, destination]); + async clone(repo, destination, onProgress) { + this.calls.push(["clone", repo, destination]); await mkdir(destination, { recursive: true }).catch(() => {}); if (this.options.createNpmRepo) { await createNpmRepo(destination); @@ -1536,13 +1645,11 @@ class FakeGitDriver { onProgress(`Cloning ${repo} into ${destination}`); } - async refresh(repo, destination, onProgress, credential, cleanExcludes = []) { + async refresh(repo, destination, onProgress, cleanExcludes = []) { this.calls.push( - credential - ? ["refresh", repo, destination, credential, cleanExcludes] - : cleanExcludes.length > 0 - ? ["refresh", repo, destination, cleanExcludes] - : ["refresh", repo, destination], + cleanExcludes.length > 0 + ? ["refresh", repo, destination, cleanExcludes] + : ["refresh", repo, destination], ); if (this.options.createCodevilSetup) { await mkdir(join(destination, ".codevil"), { recursive: true }); @@ -1627,6 +1734,10 @@ class FakeAgentDriver { return this.responses.consolidation; } + async refreshProxyCapabilities(tokens) { + this.calls.push(["refreshProxyCapabilities", tokens]); + } + async switchToExecution(model) { this.calls.push(["switchToExecution", model]); } diff --git a/packages/shared/src/agent-models.ts b/packages/shared/src/agent-models.ts index ca7d829..14e08fa 100644 --- a/packages/shared/src/agent-models.ts +++ b/packages/shared/src/agent-models.ts @@ -1,23 +1,24 @@ import type { LLMProviderId } from "./providers.js"; /** - * Model IDs the sandbox pi-driver can resolve via @mariozechner/pi-ai. + * Model IDs the sandbox Pi driver can resolve via @earendil-works/pi-ai. * Keep in sync with `codevil models list --provider opencode-go`. */ export const AGENT_RUNNABLE_MODEL_IDS: Partial> = { "opencode-go": [ "deepseek-v4-flash", "deepseek-v4-pro", - "glm-5", "glm-5.1", - "kimi-k2.5", + "glm-5.2", "kimi-k2.6", + "kimi-k2.7-code", "mimo-v2.5", "mimo-v2.5-pro", - "minimax-m2.5", "minimax-m2.7", - "qwen3.5-plus", + "minimax-m3", "qwen3.6-plus", + "qwen3.7-max", + "qwen3.7-plus", ], }; diff --git a/packages/shared/src/entrypoint-env.ts b/packages/shared/src/entrypoint-env.ts index 6b38205..93fc92a 100644 --- a/packages/shared/src/entrypoint-env.ts +++ b/packages/shared/src/entrypoint-env.ts @@ -1,21 +1,32 @@ import { z } from "zod"; +import type { ProviderPublicConfig } from "./providers.js"; + +const ProviderPublicConfigSchema = z.object({ + CLOUDFLARE_ACCOUNT_ID: z.string().min(1).optional(), + CLOUDFLARE_GATEWAY_ID: z.string().min(1).optional(), +}).strict(); + export const EntrypointEnvSchema = z.object({ CODEVIL_DO_WS_URL: z.string().min(1).optional(), - CODEVIL_API_KEY: z.string().optional(), + CODEVIL_SANDBOX_WS_TOKEN: z.string().min(1).optional(), CODEVIL_WORKSPACE: z.string().optional(), CODEVIL_PROVIDER: z.string().optional(), - CODEVIL_LLM_KEY_FILE: z.string().optional(), + CODEVIL_PROVIDER_CONFIG: z.string().min(1).optional(), + CODEVIL_PROXY_BASE: z.string().url().optional(), + CODEVIL_PROXY_TOKENS: z.string().min(1).optional(), }); export type EntrypointEnv = z.infer; const ENTRYPOINT_ENV_KEYS = [ "CODEVIL_DO_WS_URL", - "CODEVIL_API_KEY", + "CODEVIL_SANDBOX_WS_TOKEN", "CODEVIL_WORKSPACE", "CODEVIL_PROVIDER", - "CODEVIL_LLM_KEY_FILE", + "CODEVIL_PROVIDER_CONFIG", + "CODEVIL_PROXY_BASE", + "CODEVIL_PROXY_TOKENS", ] as const satisfies readonly (keyof EntrypointEnv)[]; export function pickEntrypointEnvFields( @@ -28,6 +39,20 @@ export function pickEntrypointEnvFields( return picked; } +/** Parse the small, allowlisted config payload passed to Pi; never arbitrary env. */ +export function parseProviderPublicConfig(raw: string | undefined): ProviderPublicConfig { + if (!raw) return {}; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("Invalid provider configuration JSON"); + } + const result = ProviderPublicConfigSchema.safeParse(parsed); + if (!result.success) throw new Error("Invalid provider configuration"); + return result.data; +} + export function parseEntrypointEnv( input: Record, ): EntrypointEnv { diff --git a/packages/shared/src/github.ts b/packages/shared/src/github.ts new file mode 100644 index 0000000..61ff956 --- /dev/null +++ b/packages/shared/src/github.ts @@ -0,0 +1,30 @@ +const GITHUB_REPO_PART = /^[A-Za-z0-9_.-]+$/; + +/** + * Converts an accepted GitHub repository input into its canonical owner/repo + * form. This is intentionally strict because it is used to scope credentials. + */ +export function normalizeGitHubRepoName(repo: string): string | undefined { + const bare = repo.match(/^github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/); + if (bare) return githubRepoNameFromParts(bare[1], bare[2]); + + try { + const url = new URL(repo); + if ( + url.protocol !== "https:" || url.hostname !== "github.com" || + url.username || url.password || url.port || url.search || url.hash + ) return undefined; + const match = url.pathname.match(/^\/([^/]+)\/([^/]+?)(?:\.git)?$/); + return match ? githubRepoNameFromParts(match[1], match[2]) : undefined; + } catch { + return undefined; + } +} + +function githubRepoNameFromParts(owner: string, repo: string): string | undefined { + return isGitHubRepoPart(owner) && isGitHubRepoPart(repo) ? `${owner}/${repo}` : undefined; +} + +function isGitHubRepoPart(value: string): boolean { + return GITHUB_REPO_PART.test(value) && value !== "." && value !== ".."; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1b5dcd0..83e9b59 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,6 +13,8 @@ export { export { isRecord } from "./records.js"; +export { normalizeGitHubRepoName } from "./github.js"; + export type { CostInfo } from "./cost.js"; export { CostInfoSchema, @@ -46,11 +48,24 @@ export { export type { LLMProviderId, + DeferredProviderId, + LLMProviderCapability, LLMProviderDefinition, + ProviderApi, + ProviderAuthPolicy, + ProviderPublicConfig, + ProviderPublicConfigKey, + WorkerProviderSecretName, } from "./providers.js"; + export { + DEFERRED_PROVIDER_IDS, + LLM_PROVIDER_CAPABILITIES, LLM_PROVIDERS, + LLM_PROVIDER_IDS, + PROVIDER_APIS, getProviderDefinition, + getProviderOutboundAuthPolicy, LLMProviderIdSchema, KnownProviderSchema, } from "./providers.js"; @@ -200,7 +215,6 @@ export type { ExecuteMessage, RefinePlanSandboxMessage, CreatePRMessage, - CredentialResponseMessage, CreatePRResponseMessage, PreviewStartSandboxMessage, PreviewStopSandboxMessage, @@ -209,11 +223,13 @@ export type { AskQuestionResponse, AskQuestionCancelled, ProtocolErrorMessage, + ProxyCapabilitiesMessage, DOToSandboxMessage, SandboxAgentEvent, SandboxCloneStarted, SandboxCloneComplete, SandboxStatus, + ProxyCapabilitiesRefreshRequest, SandboxCloneProgress, SandboxPlanReady, AgentTurnComplete, @@ -222,7 +238,6 @@ export type { SandboxVerificationStarted, SandboxVerificationRetrying, SandboxVerificationFailed, - CredentialRequest, BranchPushed, PRCreated, SandboxError, @@ -243,7 +258,6 @@ export { ExecuteMessageSchema, RefinePlanSandboxMessageSchema, CreatePRMessageSchema, - CredentialResponseMessageSchema, CreatePRResponseMessageSchema, PreviewStartSandboxMessageSchema, PreviewStopSandboxMessageSchema, @@ -252,11 +266,13 @@ export { AskQuestionResponseSchema, AskQuestionCancelledSchema, ProtocolErrorMessageSchema, + ProxyCapabilitiesMessageSchema, DOToSandboxMessageSchema, SandboxAgentEventSchema, SandboxCloneStartedSchema, SandboxCloneCompleteSchema, SandboxStatusSchema, + ProxyCapabilitiesRefreshRequestSchema, SandboxCloneProgressSchema, SandboxPlanReadySchema, AgentTurnCompleteSchema, @@ -265,7 +281,6 @@ export { SandboxVerificationStartedSchema, SandboxVerificationRetryingSchema, SandboxVerificationFailedSchema, - CredentialRequestSchema, BranchPushedSchema, PRCreatedSchema, SandboxErrorSchema, @@ -457,6 +472,7 @@ export type { EntrypointEnv } from "./entrypoint-env.js"; export { EntrypointEnvSchema, parseEntrypointEnv, + parseProviderPublicConfig, pickEntrypointEnvFields, } from "./entrypoint-env.js"; @@ -496,6 +512,9 @@ export { defaultTracerSink, emitLog, logException, + safeExceptionAttributes, + safeOwnDataProperty, + safePrimitiveString, setTracerSink, traceIdFromSessionId, newTraceId, diff --git a/packages/shared/src/messages-sandbox.ts b/packages/shared/src/messages-sandbox.ts index a257e7b..7d8c254 100644 --- a/packages/shared/src/messages-sandbox.ts +++ b/packages/shared/src/messages-sandbox.ts @@ -76,13 +76,6 @@ export const CreatePRMessageSchema = z.object({ ...TraceContextFields, }); -export const CredentialResponseMessageSchema = z.object({ - type: z.literal("credential_response"), - request_id: z.string(), - username: z.string().optional(), - password: z.string().optional(), - error: z.string().optional(), -}); export const CreatePRResponseMessageSchema = z.object({ type: z.literal("create_pr_response"), @@ -135,6 +128,14 @@ export const ProtocolErrorMessageSchema = z.object({ message: z.string(), }); +/** Fresh short-lived outbound-proxy capabilities for the current sandbox. */ +export const ProxyCapabilitiesMessageSchema = z.object({ + type: z.literal("proxy_capabilities"), + tokens: z.record(z.string(), z.string().min(1)), + /** Replacement WebSocket upgrade capability for a future reconnect. */ + sandbox_ws_token: z.string().min(1).optional(), +}); + export const DOToSandboxMessageSchema = z.discriminatedUnion("type", [ InitMessageSchema, AgentTurnMessageSchema, @@ -142,7 +143,6 @@ export const DOToSandboxMessageSchema = z.discriminatedUnion("type", [ ExecuteMessageSchema, RefinePlanSandboxMessageSchema, CreatePRMessageSchema, - CredentialResponseMessageSchema, CreatePRResponseMessageSchema, PreviewStartSandboxMessageSchema, PreviewStopSandboxMessageSchema, @@ -150,6 +150,7 @@ export const DOToSandboxMessageSchema = z.discriminatedUnion("type", [ AskQuestionResponseSchema, AskQuestionCancelledSchema, ProtocolErrorMessageSchema, + ProxyCapabilitiesMessageSchema, ]); export type InitMessage = z.infer; @@ -158,13 +159,13 @@ export type PlanMessage = z.infer; export type ExecuteMessage = z.infer; export type RefinePlanSandboxMessage = z.infer; export type CreatePRMessage = z.infer; -export type CredentialResponseMessage = z.infer; export type CreatePRResponseMessage = z.infer; export type PreviewStartSandboxMessage = z.infer; export type PreviewStopSandboxMessage = z.infer; export type ConsolidationAnnotation = z.infer; export type ConsolidateAnnotationsMessage = z.infer; export type ProtocolErrorMessage = z.infer; +export type ProxyCapabilitiesMessage = z.infer; export type DOToSandboxMessage = z.infer; // --- Sandbox → DO messages --- @@ -189,6 +190,11 @@ export const SandboxStatusSchema = z.object({ message: z.string(), }); +/** Sent only on the already-authenticated sandbox WebSocket. */ +export const ProxyCapabilitiesRefreshRequestSchema = z.object({ + type: z.literal("proxy_capabilities_refresh_request"), +}); + export const SandboxCloneProgressSchema = z.object({ type: z.literal("clone_progress"), line: z.string(), @@ -242,13 +248,6 @@ export const SandboxVerificationFailedSchema = z.object({ last_error: z.string(), }); -export const CredentialRequestSchema = z.object({ - type: z.literal("credential_request"), - request_id: z.string(), - protocol: z.literal("https"), - host: z.string(), - path: z.string().optional(), -}); export const BranchPushedSchema = z.object({ type: z.literal("branch_pushed"), @@ -328,6 +327,7 @@ export const SandboxToDOMessageSchema = z.discriminatedUnion("type", [ SandboxCloneStartedSchema, SandboxCloneCompleteSchema, SandboxStatusSchema, + ProxyCapabilitiesRefreshRequestSchema, SandboxCloneProgressSchema, SandboxPlanReadySchema, AgentTurnCompleteSchema, @@ -336,7 +336,6 @@ export const SandboxToDOMessageSchema = z.discriminatedUnion("type", [ SandboxVerificationStartedSchema, SandboxVerificationRetryingSchema, SandboxVerificationFailedSchema, - CredentialRequestSchema, BranchPushedSchema, PRCreatedSchema, SandboxErrorSchema, @@ -354,6 +353,7 @@ export type SandboxAgentEvent = z.infer; export type SandboxCloneStarted = z.infer; export type SandboxCloneComplete = z.infer; export type SandboxStatus = z.infer; +export type ProxyCapabilitiesRefreshRequest = z.infer; export type SandboxCloneProgress = z.infer; export type SandboxPlanReady = z.infer; export type AgentTurnComplete = z.infer; @@ -362,7 +362,6 @@ export type ExecutionComplete = z.infer; export type SandboxVerificationStarted = z.infer; export type SandboxVerificationRetrying = z.infer; export type SandboxVerificationFailed = z.infer; -export type CredentialRequest = z.infer; export type BranchPushed = z.infer; export type PRCreated = z.infer; export type SandboxError = z.infer; diff --git a/packages/shared/src/observability.ts b/packages/shared/src/observability.ts index 58f0b00..ca938d8 100644 --- a/packages/shared/src/observability.ts +++ b/packages/shared/src/observability.ts @@ -74,6 +74,8 @@ export interface CreateTracerOptions { trace_id: string; session_id?: string; sink?: TracerSink; + /** Last-chance transformation before any trace record leaves this process. */ + transform?: (line: EmittedWideEvent) => EmittedWideEvent; } export interface Tracer { @@ -235,7 +237,8 @@ class LiveTracer implements Tracer { this.trace_id = opts.trace_id; this.component = opts.component; this.session_id = opts.session_id; - this.sink = opts.sink ?? ((line) => sharedSink(line)); + const sink = opts.sink ?? ((line) => sharedSink(line)); + this.sink = opts.transform ? (line) => sink(opts.transform!(line)) : sink; } startSpan(name: string, options: SpanOptions = {}): Span { @@ -262,27 +265,20 @@ class LiveTracer implements Tracer { span.setStatus("OK"); return result; } catch (error) { - span.setStatus( - "ERROR", - error instanceof Error ? error.message : String(error), - ); - span.addEvent("exception", { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, + const exception = safeExceptionAttributes(error); + const message = typeof exception.error === "string" + ? exception.error + : UNAVAILABLE_EXCEPTION_FIELD; + span.setStatus("ERROR", message); + span.addEvent("exception", exception); + span.setGroup("error", { + type: exception.name, + message, + ...(typeof exception.stack === "string" ? { stack: exception.stack } : {}), + ...Object.fromEntries(Object.entries(exception).filter(([key]) => + key !== "name" && key !== "error" && key !== "stack" + )), }); - if (error instanceof Error) { - const errorGroup: Record = { - type: error.name, - message: error.message, - stack: error.stack, - }; - for (const [key, value] of Object.entries(error)) { - if (key !== "name" && key !== "message" && key !== "stack") { - errorGroup[key] = value; - } - } - span.setGroup("error", errorGroup); - } throw error; } finally { span.end(); @@ -412,11 +408,99 @@ export function logException( ): void { logger.log("ERROR", operation, { ...attributes, - error: error instanceof Error ? error.message : String(error), - ...(error instanceof Error && error.stack ? { stack: error.stack } : {}), + ...safeExceptionAttributes(error), }); } +const UNAVAILABLE_EXCEPTION_FIELD = "[UNAVAILABLE]"; + +/** + * Extract diagnostics from an untrusted thrown value without invoking any of + * its accessors or coercion hooks. Error reporting must never turn a failed + * operation into a second failure (or expose an accessor's side effects). + */ +export function safeExceptionAttributes(error: unknown): Record { + if (error === null) return { name: "Error", error: "null" }; + if (typeof error !== "object" && typeof error !== "function") { + return { name: "Error", error: safePrimitiveString(error) }; + } + + const descriptors = safePropertyDescriptors(error); + if (!descriptors) { + return { name: UNAVAILABLE_EXCEPTION_FIELD, error: UNAVAILABLE_EXCEPTION_FIELD }; + } + + const result: Record = { + name: safeExceptionText(error, descriptors, "name"), + error: safeExceptionText(error, descriptors, "message"), + }; + const stack = safeExceptionText(error, descriptors, "stack"); + if (stack !== UNAVAILABLE_EXCEPTION_FIELD) result.stack = stack; + + for (const [key, descriptor] of Object.entries(descriptors)) { + if (!descriptor.enumerable || key === "name" || key === "message" || key === "stack") continue; + result[key] = "value" in descriptor ? descriptor.value : UNAVAILABLE_EXCEPTION_FIELD; + } + return result; +} + +/** Read an untrusted object's own data property without invoking accessors. */ +export function safeOwnDataProperty(value: unknown, key: string): unknown { + if ((typeof value !== "object" && typeof value !== "function") || value === null) { + return undefined; + } + const descriptor = safePropertyDescriptors(value)?.[key]; + return descriptor && "value" in descriptor ? descriptor.value : undefined; +} + +/** Render only primitive values; never invoke untrusted object coercion hooks. */ +export function safePrimitiveString(value: unknown): string { + switch (typeof value) { + case "string": + return value; + case "number": + case "bigint": + case "boolean": + case "undefined": + case "symbol": + return String(value); + default: + return UNAVAILABLE_EXCEPTION_FIELD; + } +} + +function safePropertyDescriptors(value: object): Record | undefined { + try { + return Object.getOwnPropertyDescriptors(value); + } catch { + return undefined; + } +} + +function safeExceptionText( + value: object, + descriptors: Record, + key: "name" | "message" | "stack", +): string { + const descriptor = descriptors[key] ?? safeInheritedDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return UNAVAILABLE_EXCEPTION_FIELD; + return typeof descriptor.value === "string" ? descriptor.value : safePrimitiveString(descriptor.value); +} + +function safeInheritedDescriptor(value: object, key: string): PropertyDescriptor | undefined { + try { + let prototype: object | null = Object.getPrototypeOf(value); + while (prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, key); + if (descriptor) return descriptor; + prototype = Object.getPrototypeOf(prototype); + } + } catch { + return undefined; + } + return undefined; +} + export function createTracer(opts: CreateTracerOptions): Tracer { return new LiveTracer(opts); } diff --git a/packages/shared/src/providers.ts b/packages/shared/src/providers.ts index 57b224f..d2a5215 100644 --- a/packages/shared/src/providers.ts +++ b/packages/shared/src/providers.ts @@ -1,60 +1,207 @@ import { z } from "zod"; -export type LLMProviderId = "opencode-go" | "openrouter" | "openai"; +/** API protocols emitted by Pi providers that Codevil can credential through an outbound proxy. */ +export const PROVIDER_APIS = [ + "anthropic-messages", + "google-generative-ai", + "mistral-conversations", + "openai-completions", + "openai-responses", +] as const; -type ProviderSecretNameById = { - readonly "opencode-go": "OPENCODE_API_KEY"; - readonly openrouter: "OPENROUTER_API_KEY"; - readonly openai: "OPENAI_API_KEY"; +export type ProviderApi = (typeof PROVIDER_APIS)[number]; + +export type ProviderAuthPolicy = { + readonly api: ProviderApi; + readonly header: "authorization" | "cf-aig-authorization" | "x-api-key" | "x-goog-api-key"; + readonly prefix: "" | "Bearer "; }; -type ProviderAliasesById = { - readonly "opencode-go": readonly ["opencode"]; - readonly openrouter: readonly []; - readonly openai: readonly []; +/** Secrets that an operator may configure for an API-key backed Pi provider. */ +export type WorkerProviderSecretName = + | "OPENAI_API_KEY" + | "ANTHROPIC_API_KEY" + | "GEMINI_API_KEY" + | "DEEPSEEK_API_KEY" + | "NVIDIA_API_KEY" + | "MISTRAL_API_KEY" + | "GROQ_API_KEY" + | "CEREBRAS_API_KEY" + | "XAI_API_KEY" + | "OPENROUTER_API_KEY" + | "TOGETHER_API_KEY" + | "HF_TOKEN" + | "MOONSHOT_API_KEY" + | "ZAI_API_KEY" + | "ZAI_CODING_CN_API_KEY" + | "MINIMAX_API_KEY" + | "MINIMAX_CN_API_KEY" + | "XIAOMI_API_KEY" + | "XIAOMI_TOKEN_PLAN_CN_API_KEY" + | "XIAOMI_TOKEN_PLAN_AMS_API_KEY" + | "XIAOMI_TOKEN_PLAN_SGP_API_KEY" + | "ANT_LING_API_KEY" + | "KIMI_API_KEY" + | "AI_GATEWAY_API_KEY" + | "FIREWORKS_API_KEY" + | "OPENCODE_API_KEY" + | "CLOUDFLARE_API_KEY"; + +/** Non-secret values needed by the outbound provider proxy. */ +export type ProviderPublicConfigKey = "CLOUDFLARE_ACCOUNT_ID" | "CLOUDFLARE_GATEWAY_ID"; + +/** + * Non-secret provider values that may cross the Worker-to-sandbox boundary. + * This deliberately cannot represent arbitrary process environment variables. + */ +export type ProviderPublicConfig = Partial>; + +/** Pi providers deliberately deferred until their non-API-key credential flow is implemented. */ +export const DEFERRED_PROVIDER_IDS = [ + "azure-openai-responses", + "google-vertex", + "amazon-bedrock", + "openai-codex", + "github-copilot", + "anthropic-oauth", + "custom", +] as const; + +export type DeferredProviderId = (typeof DEFERRED_PROVIDER_IDS)[number]; + +type ProviderCapabilityShape = { + readonly id: string; + readonly aliases: readonly string[]; + readonly displayName: string; + /** Worker secret used by the outbound proxy. */ + readonly secretName: WorkerProviderSecretName; + /** Exact network authorities Pi 0.80.3 models send requests to. */ + readonly hosts: readonly string[]; + /** Header replacement rules indexed by Pi's model API protocol. */ + readonly authPolicies: readonly ProviderAuthPolicy[]; + /** Required provider configuration that is not itself secret material. */ + readonly configKeys: readonly ProviderPublicConfigKey[]; }; -export type LLMProviderDefinition = { - [K in LLMProviderId]: { - readonly id: K; - readonly aliases: ProviderAliasesById[K]; - readonly displayName: string; - readonly secretName: ProviderSecretNameById[K]; - readonly validationUrl: string; - readonly keyHelpUrl: string; +const openAICompletions = auth("openai-completions", "authorization", "Bearer "); +const openAIResponses = auth("openai-responses", "authorization", "Bearer "); +const anthropicMessages = auth("anthropic-messages", "x-api-key", ""); +const googleGenerativeAI = auth("google-generative-ai", "x-goog-api-key", ""); +const mistralConversations = auth("mistral-conversations", "authorization", "Bearer "); +const cloudflareAIGatewayAnthropic = auth("anthropic-messages", "cf-aig-authorization", "Bearer "); +const cloudflareAIGatewayOpenAICompletions = auth("openai-completions", "cf-aig-authorization", "Bearer "); +const cloudflareAIGatewayOpenAIResponses = auth("openai-responses", "cf-aig-authorization", "Bearer "); + +/** + * This registry is the security boundary for API-key backed Pi 0.80.3 providers. + * OAuth, cloud identity, and arbitrary endpoints are intentionally absent. + */ +export const LLM_PROVIDER_CAPABILITIES = [ + capability("openai", "OpenAI", "OPENAI_API_KEY", ["api.openai.com"], [openAIResponses]), + capability("anthropic", "Anthropic", "ANTHROPIC_API_KEY", ["api.anthropic.com"], [anthropicMessages]), + capability("google", "Google", "GEMINI_API_KEY", ["generativelanguage.googleapis.com"], [googleGenerativeAI]), + capability("deepseek", "DeepSeek", "DEEPSEEK_API_KEY", ["api.deepseek.com"], [openAICompletions]), + capability("nvidia", "NVIDIA", "NVIDIA_API_KEY", ["integrate.api.nvidia.com"], [openAICompletions]), + capability("mistral", "Mistral", "MISTRAL_API_KEY", ["api.mistral.ai"], [mistralConversations]), + capability("groq", "Groq", "GROQ_API_KEY", ["api.groq.com"], [openAICompletions]), + capability("cerebras", "Cerebras", "CEREBRAS_API_KEY", ["api.cerebras.ai"], [openAICompletions]), + capability("xai", "xAI", "XAI_API_KEY", ["api.x.ai"], [openAICompletions]), + capability("openrouter", "OpenRouter", "OPENROUTER_API_KEY", ["openrouter.ai"], [openAICompletions]), + capability("together", "Together", "TOGETHER_API_KEY", ["api.together.ai"], [openAICompletions]), + capability("huggingface", "Hugging Face", "HF_TOKEN", ["router.huggingface.co"], [openAICompletions]), + capability("moonshotai", "Moonshot AI", "MOONSHOT_API_KEY", ["api.moonshot.ai"], [openAICompletions]), + capability("moonshotai-cn", "Moonshot AI CN", "MOONSHOT_API_KEY", ["api.moonshot.cn"], [openAICompletions]), + capability("zai", "Z.AI", "ZAI_API_KEY", ["api.z.ai"], [openAICompletions]), + capability("zai-coding-cn", "Z.AI Coding CN", "ZAI_CODING_CN_API_KEY", ["open.bigmodel.cn"], [openAICompletions]), + capability("minimax", "MiniMax", "MINIMAX_API_KEY", ["api.minimax.io"], [anthropicMessages]), + capability("minimax-cn", "MiniMax CN", "MINIMAX_CN_API_KEY", ["api.minimaxi.com"], [anthropicMessages]), + capability("xiaomi", "Xiaomi", "XIAOMI_API_KEY", ["api.xiaomimimo.com"], [openAICompletions]), + capability("xiaomi-token-plan-cn", "Xiaomi Token Plan CN", "XIAOMI_TOKEN_PLAN_CN_API_KEY", ["token-plan-cn.xiaomimimo.com"], [openAICompletions]), + capability("xiaomi-token-plan-ams", "Xiaomi Token Plan AMS", "XIAOMI_TOKEN_PLAN_AMS_API_KEY", ["token-plan-ams.xiaomimimo.com"], [openAICompletions]), + capability("xiaomi-token-plan-sgp", "Xiaomi Token Plan SGP", "XIAOMI_TOKEN_PLAN_SGP_API_KEY", ["token-plan-sgp.xiaomimimo.com"], [openAICompletions]), + capability("ant-ling", "Ant Ling", "ANT_LING_API_KEY", ["api.ant-ling.com"], [openAICompletions]), + capability("kimi-coding", "Kimi For Coding", "KIMI_API_KEY", ["api.kimi.com"], [anthropicMessages]), + capability("vercel-ai-gateway", "Vercel AI Gateway", "AI_GATEWAY_API_KEY", ["ai-gateway.vercel.sh"], [anthropicMessages]), + capability("fireworks", "Fireworks", "FIREWORKS_API_KEY", ["api.fireworks.ai"], [anthropicMessages, openAICompletions]), + capability("opencode", "OpenCode Zen", "OPENCODE_API_KEY", ["opencode.ai"], [anthropicMessages, googleGenerativeAI, openAICompletions, openAIResponses]), + capability("opencode-go", "OpenCode Zen Go", "OPENCODE_API_KEY", ["opencode.ai"], [anthropicMessages, openAICompletions]), + capability("cloudflare-workers-ai", "Cloudflare Workers AI", "CLOUDFLARE_API_KEY", ["api.cloudflare.com"], [openAICompletions], ["CLOUDFLARE_ACCOUNT_ID"]), + capability("cloudflare-ai-gateway", "Cloudflare AI Gateway", "CLOUDFLARE_API_KEY", ["gateway.ai.cloudflare.com"], [cloudflareAIGatewayAnthropic, cloudflareAIGatewayOpenAICompletions, cloudflareAIGatewayOpenAIResponses], ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"]), +] as const satisfies readonly ProviderCapabilityShape[]; + +/** Canonical provider ids are derived from the outbound capability security boundary. */ +export type LLMProviderId = (typeof LLM_PROVIDER_CAPABILITIES)[number]["id"]; +export type LLMProviderCapability = (typeof LLM_PROVIDER_CAPABILITIES)[number]; + +/** Backwards-compatible runtime export, derived rather than maintained separately. */ +export const LLM_PROVIDER_IDS = LLM_PROVIDER_CAPABILITIES.map( + (provider) => provider.id, +) as unknown as readonly [LLMProviderId, ...LLMProviderId[]]; + +function auth( + api: ProviderApi, + header: ProviderAuthPolicy["header"], + prefix: ProviderAuthPolicy["prefix"], +): ProviderAuthPolicy { + return { api, header, prefix }; +} + +function capability< + const Id extends string, + const Hosts extends readonly string[], + const Policies extends readonly ProviderAuthPolicy[], + const ConfigKeys extends readonly ProviderPublicConfigKey[], + const Aliases extends readonly string[], +>( + id: Id, + displayName: string, + secretName: WorkerProviderSecretName, + hosts: Hosts, + authPolicies: Policies, + configKeys: ConfigKeys = [] as unknown as ConfigKeys, + aliases: Aliases = [] as unknown as Aliases, +): ProviderCapabilityShape & { + readonly id: Id; + readonly hosts: Hosts; + readonly authPolicies: Policies; + readonly configKeys: ConfigKeys; + readonly aliases: Aliases; +} { + return { id, aliases, displayName, secretName, hosts, authPolicies, configKeys }; +} + +export type LLMProviderDefinition = Pick< + LLMProviderCapability, + "id" | "aliases" | "displayName" | "secretName" | "configKeys" +> & { + /** A safe, provider-specific request for checking an API key. Omitted means save without live validation. */ + readonly validation?: { + readonly url: string; + readonly header: "authorization" | "x-api-key" | "x-goog-api-key"; + readonly prefix: "" | "Bearer "; }; -}[LLMProviderId]; - -export const LLM_PROVIDERS = [ - { - id: "opencode-go", - aliases: ["opencode"], - displayName: "OpenCode Go", - secretName: "OPENCODE_API_KEY", - validationUrl: "https://opencode.ai/zen/go/v1/models", - keyHelpUrl: "https://opencode.ai/docs/go/", - }, - { - id: "openrouter", - aliases: [], - displayName: "OpenRouter", - secretName: "OPENROUTER_API_KEY", - validationUrl: "https://openrouter.ai/api/v1/key", - keyHelpUrl: "https://openrouter.ai/settings/keys", - }, - { - id: "openai", - aliases: [], - displayName: "OpenAI Platform", - secretName: "OPENAI_API_KEY", - validationUrl: "https://api.openai.com/v1/models", - keyHelpUrl: "https://platform.openai.com/api-keys", - }, -] as const satisfies readonly LLMProviderDefinition[]; - -export const LLMProviderIdSchema = z.enum(["opencode-go", "openrouter", "openai"]); - -/** Accepts canonical provider ids and documented aliases (e.g. `opencode` → `opencode-go`). */ +}; + +const PROVIDER_VALIDATION = { + openai: { url: "https://api.openai.com/v1/models", header: "authorization", prefix: "Bearer " }, + openrouter: { url: "https://openrouter.ai/api/v1/key", header: "authorization", prefix: "Bearer " }, + opencode: { url: "https://opencode.ai/zen/v1/models", header: "authorization", prefix: "Bearer " }, + "opencode-go": { url: "https://opencode.ai/zen/go/v1/models", header: "authorization", prefix: "Bearer " }, +} as const; + +/** Operator setup is derived from the same allowlisted capabilities used by the outbound proxy. */ +export const LLM_PROVIDERS = LLM_PROVIDER_CAPABILITIES.map((provider) => ({ + id: provider.id, + aliases: provider.aliases, + displayName: provider.displayName, + secretName: provider.secretName, + configKeys: provider.configKeys, + validation: PROVIDER_VALIDATION[provider.id as keyof typeof PROVIDER_VALIDATION], +})) as readonly LLMProviderDefinition[]; + +export const LLMProviderIdSchema = z.enum(LLM_PROVIDER_IDS); + +/** Accepts supported canonical Pi provider ids. */ export const KnownProviderSchema = z .string() .trim() @@ -63,17 +210,22 @@ export const KnownProviderSchema = z message: "Unknown LLM provider", }); -export function getProviderDefinition( +export function getProviderDefinition(provider: string): LLMProviderCapability | undefined { + return LLM_PROVIDER_CAPABILITIES.find( + (definition) => definition.id === provider || definition.aliases.includes(provider), + ); +} + +/** + * Returns credentials only for an explicitly declared provider/host/Pi API tuple. + * Callers must use this single lookup rather than composing host and auth checks. + */ +export function getProviderOutboundAuthPolicy( provider: string, -): LLMProviderDefinition | undefined { - return LLM_PROVIDERS.find((definition) => { - switch (definition.id) { - case "opencode-go": - return provider === "opencode-go" || provider === "opencode"; - case "openrouter": - return provider === "openrouter"; - case "openai": - return provider === "openai"; - } - }); + hostname: string, + api: ProviderApi, +): ProviderAuthPolicy | undefined { + const definition = getProviderDefinition(provider); + if (!definition?.hosts.includes(hostname)) return undefined; + return definition.authPolicies.find((policy) => policy.api === api); } diff --git a/packages/shared/test/entrypoint-env.test.mjs b/packages/shared/test/entrypoint-env.test.mjs index 635927c..01c2f2d 100644 --- a/packages/shared/test/entrypoint-env.test.mjs +++ b/packages/shared/test/entrypoint-env.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { EntrypointEnvSchema, parseEntrypointEnv, + parseProviderPublicConfig, pickEntrypointEnvFields, } from "../dist/index.js"; @@ -34,3 +35,27 @@ test("pickEntrypointEnvFields: only keeps entrypoint keys", () => { assert.deepEqual(picked, { CODEVIL_PROVIDER: "opencode-go" }); assert.equal(EntrypointEnvSchema.safeParse(picked).success, true); }); + +test("parseProviderPublicConfig accepts only the declared Cloudflare identifiers", () => { + assert.deepEqual( + parseProviderPublicConfig(JSON.stringify({ + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + })), + { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + }, + ); +}); + +test("parseProviderPublicConfig fails closed on malformed or undeclared sandbox configuration", () => { + assert.throws(() => parseProviderPublicConfig("{"), /Invalid provider configuration JSON/); + assert.throws( + () => parseProviderPublicConfig(JSON.stringify({ + CLOUDFLARE_ACCOUNT_ID: "account_123", + NODE_OPTIONS: "--require arbitrary-module", + })), + /Invalid provider configuration/, + ); +}); diff --git a/packages/shared/test/github.test.mjs b/packages/shared/test/github.test.mjs new file mode 100644 index 0000000..a43f7a3 --- /dev/null +++ b/packages/shared/test/github.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeGitHubRepoName } from "../dist/index.js"; + +test("normalizeGitHubRepoName accepts only canonical GitHub repository inputs", () => { + for (const input of [ + "github.com/acme/app", + "github.com/acme/app.git", + "https://github.com/acme/app", + "https://github.com/acme/app.git", + ]) { + assert.equal(normalizeGitHubRepoName(input), "acme/app"); + } +}); + +test("normalizeGitHubRepoName rejects unsafe or non-repository inputs", () => { + for (const input of [ + "https://github.com/acme/app.git/extra", + "https://github.com/acme/app?ref=main", + "https://github.com/acme/..", + "https://github.com@evil.example/acme/app.git", + "https://gitlab.com/acme/app.git", + ]) { + assert.equal(normalizeGitHubRepoName(input), undefined, input); + } +}); diff --git a/packages/shared/test/observability.test.mjs b/packages/shared/test/observability.test.mjs index 43d1bef..f2abd37 100644 --- a/packages/shared/test/observability.test.mjs +++ b/packages/shared/test/observability.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { createTracer, createComponentLogger, + logException, newTraceId, newSpanId, setTracerSink, @@ -93,6 +94,24 @@ test("span() wraps fn, sets OK on success and ERROR on throw", async () => { assert.equal(errLines[0].events[0].name, "exception"); }); +test("span failure diagnostics never invoke hostile exception fields", async () => { + const hostile = {}; + for (const key of ["name", "message", "stack", "detail"]) { + Object.defineProperty(hostile, key, { + enumerable: true, + get() { throw new Error(`hostile ${key} getter`); }, + }); + } + + const lines = await withSinkAsync(async () => { + const tracer = createTracer({ component: "sandbox", trace_id: newTraceId() }); + await assert.rejects(tracer.span("sandbox.hostile", {}, async () => { throw hostile; })); + }); + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, /hostile .* getter/); + assert.match(serialized, /\[UNAVAILABLE\]/); +}); + test("parent_span_id propagates from options.parent", () => { const lines = withSink(() => { const t = createTracer({ component: "orchestrator", trace_id: newTraceId() }); @@ -131,6 +150,26 @@ test("createComponentLogger attaches session_id to every log", () => { assert.equal(lines[0].session_id, "ses_deadbeef"); }); +test("logException never invokes hostile exception accessors", () => { + const error = {}; + for (const key of ["name", "message", "stack", "detail"]) { + Object.defineProperty(error, key, { + enumerable: true, + get() { + throw new Error(`hostile ${key} getter`); + }, + }); + } + + const lines = withSink(() => { + assert.doesNotThrow(() => logException(createComponentLogger("worker"), "request.failed", error)); + }); + assert.equal(lines[0].error, "[UNAVAILABLE]"); + assert.equal(lines[0].name, "[UNAVAILABLE]"); + assert.equal(lines[0].detail, "[UNAVAILABLE]"); + assert.doesNotMatch(JSON.stringify(lines), /hostile .* getter/); +}); + test("traceIdFromSessionId strips ses_ prefix", () => { assert.equal( traceIdFromSessionId("ses_0123456789abcdef0123456789abcdef"), diff --git a/packages/shared/test/provider-models.test.mjs b/packages/shared/test/provider-models.test.mjs index 844cbf5..5b54232 100644 --- a/packages/shared/test/provider-models.test.mjs +++ b/packages/shared/test/provider-models.test.mjs @@ -56,10 +56,11 @@ test("formatModelId title-cases hyphenated model ids", () => { assert.equal(formatModelId("gpt-5.4-mini"), "Gpt 5 4 Mini"); }); -test("agentRunnableModelIds excludes unsupported sandbox models", () => { +test("agentRunnableModelIds follows the maintained OpenCode Go catalog", () => { const runnable = agentRunnableModelIds("opencode-go"); assert.equal(runnable?.has("kimi-k2.6"), true); + assert.equal(runnable?.has("kimi-k2.7-code"), true); assert.equal(runnable?.has("deepseek-v4-flash"), true); assert.equal(runnable?.has("mimo-v2-omni"), false); - assert.equal(runnable?.has("glm-5.2"), false); + assert.equal(runnable?.has("glm-5.2"), true); }); diff --git a/packages/shared/test/providers.test.mjs b/packages/shared/test/providers.test.mjs index 0970fce..a893d7b 100644 --- a/packages/shared/test/providers.test.mjs +++ b/packages/shared/test/providers.test.mjs @@ -1,53 +1,256 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { PROVIDER_APIS } from "../dist/index.js"; import * as shared from "../dist/index.js"; -test("provider catalog preserves canonical order", () => { +const INCLUDED_PROVIDER_IDS = [ + "openai", + "anthropic", + "google", + "deepseek", + "nvidia", + "mistral", + "groq", + "cerebras", + "xai", + "openrouter", + "together", + "huggingface", + "moonshotai", + "moonshotai-cn", + "zai", + "zai-coding-cn", + "minimax", + "minimax-cn", + "xiaomi", + "xiaomi-token-plan-cn", + "xiaomi-token-plan-ams", + "xiaomi-token-plan-sgp", + "ant-ling", + "kimi-coding", + "vercel-ai-gateway", + "fireworks", + "opencode", + "opencode-go", + "cloudflare-workers-ai", + "cloudflare-ai-gateway", +]; + +const DEFERRED_PROVIDER_IDS = [ + "azure-openai-responses", + "google-vertex", + "amazon-bedrock", + "openai-codex", + "github-copilot", + "anthropic-oauth", + "custom", +]; + +test("capability registry contains exactly the supported API-key Pi providers", () => { + assert.deepEqual( + shared.LLM_PROVIDER_CAPABILITIES.map((provider) => provider.id), + INCLUDED_PROVIDER_IDS, + ); + assert.deepEqual(shared.DEFERRED_PROVIDER_IDS, DEFERRED_PROVIDER_IDS); +}); + +test("KnownProviderSchema accepts included providers but rejects deferred provider ids", () => { + assert.equal(shared.KnownProviderSchema.parse("opencode"), "opencode"); + assert.equal(shared.KnownProviderSchema.parse("opencode-go"), "opencode-go"); + + for (const providerId of DEFERRED_PROVIDER_IDS) { + assert.equal(shared.KnownProviderSchema.safeParse(providerId).success, false, providerId); + } +}); + +test("capability definitions have unique ids and distinct configuration requirements", () => { + const ids = shared.LLM_PROVIDER_CAPABILITIES.map((provider) => provider.id); + + assert.equal(new Set(ids).size, ids.length); + const providersWithConfig = shared.LLM_PROVIDER_CAPABILITIES.filter( + (provider) => provider.configKeys.length > 0, + ); + assert.deepEqual( + providersWithConfig.map((provider) => [provider.id, provider.configKeys]), + [ + ["cloudflare-workers-ai", ["CLOUDFLARE_ACCOUNT_ID"]], + ["cloudflare-ai-gateway", ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"]], + ], + ); + for (const provider of shared.LLM_PROVIDER_CAPABILITIES) { + assert.ok(provider.hosts.length > 0, `${provider.id} must have an outbound host allowlist`); + assert.ok(provider.authPolicies.length > 0, `${provider.id} must have an auth policy`); + } +}); + +test("Pi providers share their documented Worker secrets where credentials are interchangeable", () => { + assert.equal(shared.getProviderDefinition("opencode")?.secretName, "OPENCODE_API_KEY"); + assert.equal(shared.getProviderDefinition("opencode-go")?.secretName, "OPENCODE_API_KEY"); + assert.equal(shared.getProviderDefinition("moonshotai")?.secretName, "MOONSHOT_API_KEY"); + assert.equal(shared.getProviderDefinition("moonshotai-cn")?.secretName, "MOONSHOT_API_KEY"); + assert.equal( + shared.getProviderDefinition("cloudflare-workers-ai")?.secretName, + "CLOUDFLARE_API_KEY", + ); + assert.equal( + shared.getProviderDefinition("cloudflare-ai-gateway")?.secretName, + "CLOUDFLARE_API_KEY", + ); +}); + +test("OpenCode Go supports both Pi request protocols only at its declared host", () => { + const provider = shared.getProviderDefinition("opencode-go"); + + assert.deepEqual(provider?.aliases, []); + assert.equal(provider?.id, "opencode-go"); + assert.deepEqual(provider?.hosts, ["opencode.ai"]); + assert.deepEqual( + provider?.authPolicies.map((policy) => policy.api), + ["anthropic-messages", "openai-completions"], + ); + assert.deepEqual( + shared.getProviderOutboundAuthPolicy("opencode-go", "opencode.ai", "anthropic-messages"), + { api: "anthropic-messages", header: "x-api-key", prefix: "" }, + ); + assert.deepEqual( + shared.getProviderOutboundAuthPolicy("opencode-go", "opencode.ai", "openai-completions"), + { api: "openai-completions", header: "authorization", prefix: "Bearer " }, + ); +}); + +test("Fireworks has auth policies for every Pi protocol it serves", () => { + assert.deepEqual( + shared.getProviderDefinition("fireworks")?.authPolicies.map((policy) => policy.api), + ["anthropic-messages", "openai-completions"], + ); + assert.deepEqual( + shared.getProviderOutboundAuthPolicy("fireworks", "api.fireworks.ai", "anthropic-messages"), + { api: "anthropic-messages", header: "x-api-key", prefix: "" }, + ); + assert.deepEqual( + shared.getProviderOutboundAuthPolicy("fireworks", "api.fireworks.ai", "openai-completions"), + { api: "openai-completions", header: "authorization", prefix: "Bearer " }, + ); +}); + +test("OpenCode Zen authenticates every supported Pi protocol with its complete policy", () => { + const host = "opencode.ai"; + const expectedPolicies = [ + { api: "anthropic-messages", header: "x-api-key", prefix: "" }, + { api: "google-generative-ai", header: "x-goog-api-key", prefix: "" }, + { api: "openai-completions", header: "authorization", prefix: "Bearer " }, + { api: "openai-responses", header: "authorization", prefix: "Bearer " }, + ]; + + assert.deepEqual(shared.getProviderDefinition("opencode")?.authPolicies, expectedPolicies); + for (const policy of expectedPolicies) { + assert.deepEqual(shared.getProviderOutboundAuthPolicy("opencode", host, policy.api), policy); + } +}); + +test("Cloudflare AI Gateway authenticates every supported Pi protocol with its complete policy", () => { + const host = "gateway.ai.cloudflare.com"; + const expectedPolicies = [ + { api: "anthropic-messages", header: "cf-aig-authorization", prefix: "Bearer " }, + { api: "openai-completions", header: "cf-aig-authorization", prefix: "Bearer " }, + { api: "openai-responses", header: "cf-aig-authorization", prefix: "Bearer " }, + ]; + + assert.deepEqual(shared.getProviderDefinition("cloudflare-ai-gateway")?.authPolicies, expectedPolicies); + for (const policy of expectedPolicies) { + assert.deepEqual( + shared.getProviderOutboundAuthPolicy("cloudflare-ai-gateway", host, policy.api), + policy, + ); + } +}); + +test("outbound provider policy fails closed for unknown providers, hosts, and APIs", () => { + assert.equal( + shared.getProviderOutboundAuthPolicy("not-a-provider", "api.openai.com", "openai-responses"), + undefined, + ); + assert.equal( + shared.getProviderOutboundAuthPolicy("openai", "api.anthropic.com", "openai-responses"), + undefined, + ); + assert.equal( + shared.getProviderOutboundAuthPolicy("openai", "api.openai.com", "anthropic-messages"), + undefined, + ); +}); + +test("every declared provider host and Pi API has an outbound auth policy", () => { + for (const provider of shared.LLM_PROVIDER_CAPABILITIES) { + for (const hostname of provider.hosts) { + for (const policy of provider.authPolicies) { + assert.deepEqual( + shared.getProviderOutboundAuthPolicy(provider.id, hostname, policy.api), + policy, + `${provider.id}/${hostname}/${policy.api}`, + ); + } + } + } +}); + +test("every declared provider host fails closed for every unsupported Pi API", () => { + for (const provider of shared.LLM_PROVIDER_CAPABILITIES) { + const supportedApis = new Set(provider.authPolicies.map((policy) => policy.api)); + const unsupportedApis = PROVIDER_APIS.filter((api) => !supportedApis.has(api)); + + for (const hostname of provider.hosts) { + for (const api of unsupportedApis) { + assert.equal( + shared.getProviderOutboundAuthPolicy(provider.id, hostname, api), + undefined, + `${provider.id}/${hostname}/${api} must not receive credentials`, + ); + } + } + } +}); + +test("Cloudflare providers declare the account and gateway configuration they need", () => { + assert.deepEqual( + shared.getProviderDefinition("cloudflare-workers-ai")?.configKeys, + ["CLOUDFLARE_ACCOUNT_ID"], + ); + assert.deepEqual( + shared.getProviderDefinition("cloudflare-ai-gateway")?.configKeys, + ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"], + ); + assert.equal( + shared.getProviderOutboundAuthPolicy( + "cloudflare-ai-gateway", + "gateway.ai.cloudflare.com", + "openai-responses", + )?.header, + "cf-aig-authorization", + ); +}); + +test("operator provider catalog is derived from every supported capability", () => { assert.deepEqual( shared.LLM_PROVIDERS.map((provider) => provider.id), - ["opencode-go", "openrouter", "openai"], - ); -}); - -test("provider catalog matches the exact expected entries", () => { - assert.deepEqual(shared.LLM_PROVIDERS, [ - { - id: "opencode-go", - aliases: ["opencode"], - displayName: "OpenCode Go", - secretName: "OPENCODE_API_KEY", - validationUrl: "https://opencode.ai/zen/go/v1/models", - keyHelpUrl: "https://opencode.ai/docs/go/", - }, - { - id: "openrouter", - aliases: [], - displayName: "OpenRouter", - secretName: "OPENROUTER_API_KEY", - validationUrl: "https://openrouter.ai/api/v1/key", - keyHelpUrl: "https://openrouter.ai/settings/keys", - }, - { - id: "openai", - aliases: [], - displayName: "OpenAI Platform", - secretName: "OPENAI_API_KEY", - validationUrl: "https://api.openai.com/v1/models", - keyHelpUrl: "https://platform.openai.com/api-keys", - }, - ]); -}); - -test("opencode alias resolves to opencode-go", () => { - assert.equal(shared.getProviderDefinition("opencode")?.id, "opencode-go"); + INCLUDED_PROVIDER_IDS, + ); }); -test("unknown provider returns undefined", () => { - assert.equal(shared.getProviderDefinition("not-a-provider"), undefined); +test("only providers with an exact validation strategy are validated live", () => { + assert.deepEqual( + shared.LLM_PROVIDERS.filter((provider) => provider.validation).map((provider) => provider.id), + ["openai", "openrouter", "opencode", "opencode-go"], + ); + assert.equal(shared.LLM_PROVIDERS.find((provider) => provider.id === "anthropic")?.validation, undefined); }); -test("secret names are unique across providers", () => { - const secretNames = shared.LLM_PROVIDERS.map((provider) => provider.secretName); - assert.equal(new Set(secretNames).size, secretNames.length); +test("OpenCode Zen and OpenCode Go remain distinct Pi providers", () => { + assert.equal(shared.getProviderDefinition("opencode")?.id, "opencode"); + assert.equal(shared.getProviderDefinition("opencode-go")?.id, "opencode-go"); +}); + +test("unknown provider returns undefined", () => { + assert.equal(shared.getProviderDefinition("not-a-provider"), undefined); }); diff --git a/packages/web/src/lib/__tests__/provider-models.test.ts b/packages/web/src/lib/__tests__/provider-models.test.ts index 4ba3fc5..e3bb14b 100644 --- a/packages/web/src/lib/__tests__/provider-models.test.ts +++ b/packages/web/src/lib/__tests__/provider-models.test.ts @@ -46,7 +46,14 @@ describe("listProviderModels", () => { it("rejects unknown providers", async () => { await expect( - listProviderModels("anthropic", async () => new Response("{}", { status: 200 })), + listProviderModels("not-a-provider", async () => new Response("{}", { status: 200 })), ).rejects.toThrow(/Unknown provider/); }); + + it("returns no catalog options without fetching for providers that require a typed model id", async () => { + const fetcher = vi.fn(); + + await expect(listProviderModels("anthropic", fetcher as typeof fetch)).resolves.toEqual([]); + expect(fetcher).not.toHaveBeenCalled(); + }); }); diff --git a/packages/web/src/lib/provider-models.ts b/packages/web/src/lib/provider-models.ts index 92d4061..deb4c15 100644 --- a/packages/web/src/lib/provider-models.ts +++ b/packages/web/src/lib/provider-models.ts @@ -3,6 +3,7 @@ import { buildProviderModelOptions, getProviderDefinition, MODELS_DEV_CATALOG_URL, + PROVIDERS_WITH_MODEL_CATALOG, type ModelsDevCatalog, type ProviderModelOption, } from "@codevil/shared"; @@ -20,6 +21,10 @@ export async function listProviderModels( throw new Error(`Unknown provider: ${providerId}`); } + if (!PROVIDERS_WITH_MODEL_CATALOG.includes(definition.id)) { + return []; + } + const catalog = await fetchModelsDevCatalog(fetcher); const availableIds = definition.id === "opencode-go" ? await fetchOpenCodeGoModelIds(fetcher) diff --git a/packages/web/src/routes/index.tsx b/packages/web/src/routes/index.tsx index 617f528..46abd5c 100644 --- a/packages/web/src/routes/index.tsx +++ b/packages/web/src/routes/index.tsx @@ -26,9 +26,7 @@ export const Route = createFileRoute("/")({ const MODEL_PREFS_KEY = "codevil_model_prefs"; -const SUPPORTED_PROVIDERS = LLM_PROVIDERS.filter((entry) => - PROVIDERS_WITH_MODEL_CATALOG.includes(entry.id), -); +const SUPPORTED_PROVIDERS = LLM_PROVIDERS; const SUPPORTED_PROVIDER_IDS = new Set(SUPPORTED_PROVIDERS.map((entry) => entry.id)); @@ -125,6 +123,42 @@ function GithubGlyph() { ); } +function ModelField({ + label, + value, + onChange, + options, + loading, + hasCatalog, +}: { + label: string; + value: string; + onChange: (value: string) => void; + options: ProviderModelOption[]; + loading: boolean; + hasCatalog: boolean; +}) { + return ( + + ); +} + function HomePage() { const navigate = useNavigate(); const [repo, setRepo] = useState(""); @@ -144,6 +178,7 @@ function HomePage() { const [modelOptions, setModelOptions] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [modelsError, setModelsError] = useState(null); + const providerHasCatalog = (PROVIDERS_WITH_MODEL_CATALOG as readonly string[]).includes(provider); useEffect(() => { const prefs = loadModelPrefs(); @@ -306,7 +341,7 @@ function HomePage() { const canCreate = !loading && !modelsLoading && - modelOptions.length > 0 && + (!providerHasCatalog || modelOptions.length > 0) && Boolean(repo.trim() && provider.trim() && planModel.trim() && execModel.trim()); const counts = useMemo(() => { @@ -453,40 +488,8 @@ function HomePage() { ))} - - + + {modelsError &&

{modelsError}

} diff --git a/packages/worker/.dev.vars.example b/packages/worker/.dev.vars.example new file mode 100644 index 0000000..b5c39df --- /dev/null +++ b/packages/worker/.dev.vars.example @@ -0,0 +1,24 @@ +# Copy to .dev.vars for local development. Never commit the copied file. +# Generate independent random values for the internal secrets. +CODEVIL_API_KEY= +CODEVIL_SETUP_TOKEN= +CODEVIL_PROXY_SIGNING_SECRET= +BETTER_AUTH_SECRET= + +# OAuth and GitHub credentials. +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_PAT= + +# Public local-development values. Use your deployed worker URL when testing +# OAuth redirects against a remote Worker. +BETTER_AUTH_URL=http://localhost:8787 +CODEVIL_WEB_ORIGIN=http://localhost:5173,http://localhost:8787 + +# Optional email delivery. +# EMAIL_PROVIDER=resend +# RESEND_API_KEY= +# RESEND_FROM= + +# Add only the LLM provider secret(s) configured for local testing. +# ANTHROPIC_API_KEY= diff --git a/packages/worker/.env.example b/packages/worker/.env.example index 4797a65..5d6d724 100644 --- a/packages/worker/.env.example +++ b/packages/worker/.env.example @@ -4,7 +4,8 @@ CODEVIL_API_KEY=REPLACE_ME_WITH_AN_INDEPENDENT_RANDOM_VALUE CODEVIL_SETUP_TOKEN=REPLACE_ME_WITH_A_DIFFERENT_INDEPENDENT_RANDOM_VALUE -BETTER_AUTH_SECRET=REPLACE_ME_WITH_A_THIRD_INDEPENDENT_RANDOM_VALUE +CODEVIL_PROXY_SIGNING_SECRET=REPLACE_ME_WITH_A_THIRD_INDEPENDENT_RANDOM_VALUE +BETTER_AUTH_SECRET=REPLACE_ME_WITH_A_FOURTH_INDEPENDENT_RANDOM_VALUE GITHUB_PAT=REPLACE_ME_WITH_YOUR_FINE_GRAINED_GITHUB_TOKEN GOOGLE_CLIENT_ID=REPLACE_ME_WITH_YOUR_GOOGLE_OAUTH_CLIENT_ID diff --git a/packages/worker/src/github.ts b/packages/worker/src/github.ts index ac2b774..2735f7c 100644 --- a/packages/worker/src/github.ts +++ b/packages/worker/src/github.ts @@ -1,4 +1,4 @@ -import type { CredentialRequest } from "@codevil/shared"; +import { normalizeGitHubRepoName } from "@codevil/shared"; export interface GitHubRepo { host: string; @@ -18,29 +18,13 @@ export interface CreatePullRequestOptions { const TRANSIENT_PR_STATUSES = new Set([429, 500, 502, 503, 504]); const PR_RETRY_DELAYS_MS = [500, 1_500, 3_000]; +export { normalizeGitHubRepoName } from "@codevil/shared"; export function parseGitHubRepo(repoUrl: string): GitHubRepo | null { - try { - const url = new URL(repoUrl); - if (url.protocol !== "https:" || url.hostname !== "github.com") return null; - const [owner, rawRepo] = url.pathname.replace(/^\/+/, "").split("/"); - const repo = rawRepo?.replace(/\.git$/, ""); - if (!owner || !repo) return null; - return { host: url.hostname, owner, repo }; - } catch { - return null; - } -} - -export function credentialRequestAllowed(sessionRepo: string, request: CredentialRequest): boolean { - const parsed = parseGitHubRepo(sessionRepo); - if (!parsed) return false; - if (request.protocol !== "https") return false; - if (request.host !== parsed.host) return false; - - const normalizedPath = normalizeRepoPath(request.path); - if (!normalizedPath) return false; - return normalizedPath === `${parsed.owner}/${parsed.repo}`; + const normalized = normalizeGitHubRepoName(repoUrl); + if (!normalized) return null; + const [owner, repo] = normalized.split("/"); + return { host: "github.com", owner, repo }; } export function buildCreatePullRequestRequest(options: CreatePullRequestOptions): { diff --git a/packages/worker/src/health.ts b/packages/worker/src/health.ts index 7659e77..508cf31 100644 --- a/packages/worker/src/health.ts +++ b/packages/worker/src/health.ts @@ -27,11 +27,17 @@ export function checkApiKeyPresent(env: Env): boolean { return typeof env.CODEVIL_API_KEY === "string" && env.CODEVIL_API_KEY.trim().length > 0; } +export function checkProxySigningSecretPresent(env: Env): boolean { + return typeof env.CODEVIL_PROXY_SIGNING_SECRET === "string" + && env.CODEVIL_PROXY_SIGNING_SECRET.trim().length > 0; +} + export async function handleReady(env: Env): Promise { const d1 = await checkD1Reachable(env.DB); const auth_config = checkAuthConfigPresent(env); const api_key = checkApiKeyPresent(env); - const checks = { d1, auth_config, api_key }; - const ok = d1 && auth_config && api_key; + const proxy_signing_secret = checkProxySigningSecretPresent(env); + const checks = { d1, auth_config, api_key, proxy_signing_secret }; + const ok = d1 && auth_config && api_key && proxy_signing_secret; return json({ ok, checks }, ok ? 200 : 503); } diff --git a/packages/worker/src/http-handlers.ts b/packages/worker/src/http-handlers.ts index b96b4e9..a261286 100644 --- a/packages/worker/src/http-handlers.ts +++ b/packages/worker/src/http-handlers.ts @@ -14,7 +14,7 @@ import { type SessionDirectoryRow, } from "./session-directory.js"; import { createCodevilAuth } from "./auth.js"; -import { workerLogSessionException } from "./logging.js"; +import { workerLogSessionExceptionForEnv } from "./logging.js"; import { activeMembershipByUserSelect, createOwnerMembershipInsert, @@ -53,6 +53,8 @@ import { getCodevilSandbox, readSandboxDiagnostics, } from "./sandbox.js"; +import { redactEvent } from "./redaction.js"; +import { collectWorkerSecretValues } from "./worker-env.js"; import type { Env } from "./worker-env.js"; import type { SocketAuthContext } from "./ws-authorization.js"; import { createSocketAuthToken } from "./ws-token.js"; @@ -154,7 +156,13 @@ export async function handleSetupClaim(request: Request, env: Env): Promise sessionId, ); const logs = await sandbox.getProcessLogs("codevil-agent"); - return json(logs, 200); - } catch (error) { - return json({ - error: error instanceof Error ? error.message : String(error), - }, 500); + return json(redactSandboxDiagnosticResponse(logs, env), 200); + } catch { + return json({ error: "Failed to read sandbox logs" }, 500); } } export async function handleDiagnostics(env: Env, sessionId: string): Promise { try { - return json(await readSandboxDiagnostics(env.Sandbox, sessionId, "codevil-agent"), 200); - } catch (error) { - return json({ - error: error instanceof Error ? error.message : String(error), - }, 500); + return json(redactSandboxDiagnosticResponse( + await readSandboxDiagnostics( + env.Sandbox, + sessionId, + "codevil-agent", + collectWorkerSecretValues(env), + ), + env, + ), 200); + } catch { + return json({ error: "Failed to read sandbox diagnostics" }, 500); } } +/** Final HTTP boundary for sandbox-controlled diagnostic text. */ +export function redactSandboxDiagnosticResponse(data: unknown, env: Env): unknown { + return redactEvent(data, collectWorkerSecretValues(env)); +} + export function json(data: unknown, status: number): Response { return Response.json(data, { status }); } diff --git a/packages/worker/src/http-router.ts b/packages/worker/src/http-router.ts index 298263c..4e8d594 100644 --- a/packages/worker/src/http-router.ts +++ b/packages/worker/src/http-router.ts @@ -150,9 +150,10 @@ export async function dispatchHttpRequest( const sandboxWsMatch = path.match(/^\/sessions\/([^/]+)\/sandbox\/ws$/); if (sandboxWsMatch && request.method === "GET") { - if (!authenticate(request, env.CODEVIL_API_KEY)) { - return json({ error: "Unauthorized" }, 401); - } + // Sandbox workers authenticate with the short-lived, session-bound + // sandbox_ws_token in the Orchestrator. The deployment API key must never + // be accepted here: it is neither available to the sandbox nor a valid + // replacement capability. return handleSandboxWebSocketUpgrade(request, env, sandboxWsMatch[1]); } diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 5dc82bf..7a1cd73 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -4,6 +4,9 @@ import { recordSandboxLifecycleEvent, SANDBOX_KEEPALIVE_STATE_KEY, SANDBOX_LIFECYCLE_EVENT_KEY, + lifecycleErrorEvent, + redactSandboxKeepAliveState, + stopDiagnostics, shouldDeferSandboxActivityExpiry, type SandboxKeepAliveState, type SandboxLifecycleEvent, @@ -17,24 +20,34 @@ import { sandboxLifecycleLogger, withRequestId, } from "./logging.js"; +import { collectWorkerSecretValues } from "./worker-env.js"; +import { redactEvent } from "./redaction.js"; import type { Env } from "./worker-env.js"; +import { handleSandboxProxy } from "./sandbox-proxy.js"; export type { Env } from "./worker-env.js"; // Subclass the Cloudflare Sandbox so Codevil can keep active agent sessions // alive and persist stop diagnostics across abnormal socket closures. -export class Sandbox extends BaseSandbox { +export class Sandbox extends BaseSandbox { override sleepAfter = "10m"; + private readonly redactionSecrets: readonly string[]; + + constructor(ctx: DurableObjectState<{}>, env: RuntimeEnv) { + super(ctx, env); + // Sandbox lifecycle callbacks can run without an HTTP request first. + this.redactionSecrets = collectWorkerSecretValues(env); + } async setCodevilKeepAlive(active: boolean, reason = "unspecified"): Promise { - const state: SandboxKeepAliveState = { + const state = redactSandboxKeepAliveState({ active, reason, updated_at: new Date().toISOString(), - }; + }, this.redactionSecrets); await this.ctx.storage.put(SANDBOX_KEEPALIVE_STATE_KEY, state); if (active) this.renewActivityTimeout(); - sandboxLifecycleLogger(this.sessionId()).log("INFO", "sandbox.keepalive", { + sandboxLifecycleLogger(this.redactionSecrets, this.sessionId()).log("INFO", "sandbox.keepalive", { sandbox: { ...state }, }); } @@ -68,12 +81,8 @@ export class Sandbox extends BaseSandbox { } override async onError(error: unknown): Promise { - await this.recordLifecycle({ - type: "error", - at: new Date().toISOString(), - error: error instanceof Error ? error.message : String(error), - }); - await Promise.resolve(super.onError(error)); + await this.recordLifecycle(lifecycleErrorEvent(error, this.redactionSecrets)); + await Promise.resolve(super.onError(redactEvent(error, this.redactionSecrets))); } override async onActivityExpired(): Promise { @@ -85,7 +94,7 @@ export class Sandbox extends BaseSandbox { reason: keepAlive.reason, }); this.renewActivityTimeout(); - sandboxLifecycleLogger(this.sessionId()).log("INFO", "sandbox.activity_expired_deferred", { + sandboxLifecycleLogger(this.redactionSecrets, this.sessionId()).log("INFO", "sandbox.activity_expired_deferred", { sandbox: { ...keepAlive }, }); return; @@ -99,10 +108,11 @@ export class Sandbox extends BaseSandbox { } private async recordLifecycle(event: SandboxLifecycleEvent): Promise { - sandboxLifecycleLogger(this.sessionId()).log("INFO", "sandbox.lifecycle", { - sandbox: { ...event }, + const sanitized = redactEvent(event, this.redactionSecrets); + sandboxLifecycleLogger(this.redactionSecrets, this.sessionId()).log("INFO", "sandbox.lifecycle", { + sandbox: { ...sanitized }, }); - await recordSandboxLifecycleEvent(this.ctx.storage, event); + await recordSandboxLifecycleEvent(this.ctx.storage, sanitized, this.redactionSecrets); } private sessionId(): string | undefined { @@ -127,19 +137,6 @@ export class Sandbox extends BaseSandbox { } } -function stopDiagnostics(params: unknown): Pick { - if (!params || typeof params !== "object") return {}; - - const record = params as Record; - const exitCode = record.exitCode; - const reason = record.reason; - - return { - ...(typeof exitCode === "number" ? { exit_code: exitCode } : {}), - ...(typeof reason === "string" ? { reason } : {}), - }; -} - export { Orchestrator }; const CORS_BASE_HEADERS: Record = { @@ -181,6 +178,7 @@ function withCors(request: Request, env: Env, response: Response): Response { export default { async fetch(request: Request, env: Env): Promise { + const redactionSecrets = collectWorkerSecretValues(env); if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeadersFor(request, env) }); } @@ -191,6 +189,8 @@ export default { const applyCors = (response: Response) => withCors(request, env, response); try { + const proxyResponse = await handleSandboxProxy(request, env); + if (proxyResponse) return proxyResponse; const routed = await dispatchHttpRequest(request, env, { withCors }); if (routed) { return observeRoutedResponse(routed, { @@ -198,6 +198,7 @@ export default { method: request.method, path, startedAt, + secrets: redactionSecrets, }); } @@ -213,6 +214,7 @@ export default { path, startedAt, withCors: applyCors, + secrets: redactionSecrets, }); } }, diff --git a/packages/worker/src/logging.ts b/packages/worker/src/logging.ts index e6a1404..32843cb 100644 --- a/packages/worker/src/logging.ts +++ b/packages/worker/src/logging.ts @@ -5,8 +5,8 @@ import { type Severity, } from "@codevil/shared"; import { isHealthCheckPath } from "./health.js"; - -const workerLogger = createComponentLogger("worker"); +import { redactEvent } from "./redaction.js"; +import { collectWorkerSecretValues, type WorkerSecretEnv } from "./worker-env.js"; const SESSION_PATH_RE = /^\/sessions\/([^/]+)/; @@ -31,7 +31,13 @@ export function isWebSocketUpgradeResponse(response: Response): boolean { // breaks the handshake in workerd. export function observeRoutedResponse( routed: Response, - ctx: { requestId: string; method: string; path: string; startedAt: number }, + ctx: { + requestId: string; + method: string; + path: string; + startedAt: number; + secrets: readonly string[]; + }, ): Response { if (isWebSocketUpgradeResponse(routed)) return routed; @@ -43,7 +49,7 @@ export function observeRoutedResponse( status: response.status, durationMs: Date.now() - ctx.startedAt, sessionId: extractSessionIdFromPath(ctx.path), - }); + }, ctx.secrets); return response; } @@ -54,7 +60,7 @@ export function logHttpApiRequest(opts: { status: number; durationMs: number; sessionId?: string; -}): void { +}, secrets: readonly string[]): void { const { requestId, method, path, status, durationMs, sessionId } = opts; if (isHealthCheckPath(path) && status < 400) return; @@ -67,7 +73,7 @@ export function logHttpApiRequest(opts: { }; if (sessionId) attributes.session_id = sessionId; - workerLog(severity, "request.http", attributes); + workerLog(severity, "request.http", attributes, secrets); } export function handleUncaughtHttpError( @@ -78,13 +84,14 @@ export function handleUncaughtHttpError( path: string; startedAt: number; withCors: (response: Response) => Response; + secrets: readonly string[]; }, ): Response { workerLogException("request.http.failed", error, { request_id: ctx.requestId, duration_ms: Date.now() - ctx.startedAt, request: { method: ctx.method, path: ctx.path }, - }); + }, ctx.secrets); return ctx.withCors(withRequestId( Response.json({ error: "Internal error" }, { status: 500 }), ctx.requestId, @@ -95,8 +102,14 @@ export function workerLog( severity: Parameters[0], event: string, attributes: Record = {}, + secrets: readonly string[], ): void { - workerLogger.log(severity, event, attributes); + redactLogger(createComponentLogger("worker"), secrets).log(severity, event, attributes); +} + +/** Create a Worker logger bound to one explicit request or Durable Object environment. */ +export function workerLoggerForEnv(env: WorkerSecretEnv): ComponentLogger { + return redactLogger(createComponentLogger("worker"), collectWorkerSecretValues(env)); } export function workerLogForSession( @@ -104,8 +117,9 @@ export function workerLogForSession( severity: Parameters[0], event: string, attributes: Record = {}, + secrets: readonly string[], ): void { - const logger = createComponentLogger("worker"); + const logger = redactLogger(createComponentLogger("worker"), secrets); logger.withSessionId(sessionId); logger.log(severity, event, attributes); } @@ -114,23 +128,47 @@ export function workerLogException( event: string, error: unknown, attributes: Record = {}, + secrets: readonly string[], ): void { - logException(workerLogger, event, error, attributes); + logException(redactLogger(createComponentLogger("worker"), secrets), event, error, attributes); } -export function workerLogSessionException( +/** + * Log a session exception with the complete credential inventory bound from + * the Worker environment. Prefer this at Worker request boundaries so callers + * cannot accidentally omit deployment secrets from redaction. + */ +export function workerLogSessionExceptionForEnv( sessionId: string, event: string, error: unknown, + env: WorkerSecretEnv, attributes: Record = {}, ): void { - const logger = createComponentLogger("worker"); + const logger = workerLoggerForEnv(env); logger.withSessionId(sessionId); logException(logger, event, error, attributes); } -export function sandboxLifecycleLogger(sessionId?: string): ComponentLogger { - const logger = createComponentLogger("worker"); +export function sandboxLifecycleLogger( + secrets: readonly string[], + sessionId?: string, +): ComponentLogger { + const logger = redactLogger(createComponentLogger("worker"), secrets); if (sessionId) logger.withSessionId(sessionId); return logger; } + +function redactLogger(logger: ComponentLogger, secrets: readonly string[]): ComponentLogger { + return { + log(severity, event, attributes = {}) { + logger.log(severity, event, redactEvent(attributes, secrets)); + }, + withTraceId(traceId) { + logger.withTraceId(traceId); + }, + withSessionId(sessionId) { + logger.withSessionId(sessionId); + }, + }; +} diff --git a/packages/worker/src/memberships.ts b/packages/worker/src/memberships.ts index 772f9c8..60509bb 100644 --- a/packages/worker/src/memberships.ts +++ b/packages/worker/src/memberships.ts @@ -46,14 +46,21 @@ export function activeMembershipByUserSelect(userId: string): SqlStatement { export function createOwnerMembershipInsert(userId: string, now: string): SqlStatement { return { + // This is deliberately one conditional statement rather than a + // SELECT-then-INSERT sequence. Setup is a one-time privilege boundary, + // and concurrent D1 requests must not be able to observe "no owner" and + // both promote themselves. sql: `INSERT INTO memberships ( user_id, role, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?)`, - bindings: [userId, "owner", "active", now, now], + ) SELECT ?, ?, ?, ?, ? + WHERE NOT EXISTS ( + SELECT 1 FROM memberships WHERE role = ? AND status = ? + )`, + bindings: [userId, "owner", "active", now, now, "owner", "active"], }; } diff --git a/packages/worker/src/orchestrator.ts b/packages/worker/src/orchestrator.ts index 5862891..d24b8f7 100644 --- a/packages/worker/src/orchestrator.ts +++ b/packages/worker/src/orchestrator.ts @@ -15,6 +15,7 @@ import { clientValidationErrorMessage, parseInbound, createTracer, + safeExceptionAttributes, setValidationDropSink, tracerValidationDropSink, type Span, @@ -33,8 +34,8 @@ import { sandboxReconnectDeadline, sandboxReconnectExpired, } from "./sandbox-connection.js"; -import { collectProviderCredentialSecrets } from "./provider-credentials.js"; import { redactEvent } from "./redaction.js"; +import { collectWorkerSecretValues } from "./worker-env.js"; import { sanitizeDisplayName, sanitizeParticipantId, @@ -48,6 +49,7 @@ import { type SocketAuthContext, } from "./ws-authorization.js"; import { sessionIdFromWebSocketPath, verifySocketAuthToken } from "./ws-token.js"; +import { verifySandboxWebSocketToken } from "./sandbox-ws-token.js"; import { sendSnapshotIfBehind } from "./snapshot-frame.js"; export { sendSnapshotIfBehind } from "./snapshot-frame.js"; import { @@ -66,6 +68,7 @@ import { } from "./orchestrator/session-guards.js"; export { traceIdFromSessionId } from "./orchestrator/session-guards.js"; import { runOrchestratorSchemaMigrations } from "./orchestrator/schema-migrations.js"; +import { DirectoryUpdateQueue } from "./orchestrator/directory-update-queue.js"; import type { OrchestratorHost } from "./orchestrator/host.js"; import { SessionEventLog } from "./orchestrator/event-log.js"; import { loadSessionMeta, saveSessionMeta } from "./orchestrator/session-meta.js"; @@ -106,7 +109,10 @@ import { initializeSandboxConnection, provisionSessionSandbox, } from "./orchestrator/sandbox-handlers.js"; -import { runSessionDirectoryUpdateWithRetry } from "./session-directory.js"; +import { + nextSessionDirectoryTimestamp, + runSessionDirectoryUpdateWithRetry, +} from "./session-directory.js"; export type { InitOptions } from "./orchestrator/types.js"; @@ -118,19 +124,20 @@ export class Orchestrator extends DurableObject implements OrchestratorHost redactionSecrets: string[]; private tracer: Tracer | null = null; private phaseSpans = new Map(); + private directoryUpdates = new DirectoryUpdateQueue(); + private pendingDirectoryPatch: { + room_state?: string; + sandbox_state?: string; + active_run_state?: string | null; + } = {}; + private lastDirectoryUpdateAt = ""; eventLog: SessionEventLog; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); this.ctx = ctx as DurableObjectState<{}>; this.workerEnv = env; - this.redactionSecrets = [ - env.CODEVIL_API_KEY, - ...collectProviderCredentialSecrets(env), - env.GITHUB_PAT, - env.R2_ACCESS_KEY_ID, - env.R2_SECRET_ACCESS_KEY, - ].filter((secret): secret is string => Boolean(secret)); + this.redactionSecrets = collectWorkerSecretValues(env); this.sql = ctx.storage.sql; this.eventLog = new SessionEventLog( this.sql, @@ -275,7 +282,12 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } if (this.meta.state === "provisioning_sandbox" && now >= createdAt + 60_000) { - const logs = await readProcessLogs(this.workerEnv.Sandbox, this.meta.session_id, "codevil-agent"); + const logs = await readProcessLogs( + this.workerEnv.Sandbox, + this.meta.session_id, + "codevil-agent", + this.redactionSecrets, + ); this.getTracer()?.log("ERROR", "sandbox.timeout", { stdout: logs?.stdout ?? "(none)", stderr: logs?.stderr ?? "(none)", @@ -305,7 +317,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost const isSandbox = url.pathname.endsWith("/sandbox/ws"); if (isSandbox) { - return this.acceptSandboxWebSocket(); + return this.acceptSandboxWebSocket(request, url); } // Hydrate snapshot and session meta from SQLite on cold-start BEFORE reading @@ -360,12 +372,23 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return new Response(null, { status: 101, webSocket: client }); } - private acceptSandboxWebSocket(): Response { + private async acceptSandboxWebSocket(request: Request, url: URL): Promise { this.loadMeta(); if (!this.meta) { return new Response("Session not initialized", { status: 409 }); } + if (sandboxSessionIdFromPath(url.pathname) !== this.meta.session_id) { + return new Response("Unauthorized", { status: 401 }); + } + + const capability = await verifySandboxWebSocketToken( + url.searchParams.get("sandbox_ws_token"), + this.meta.session_id, + this.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? "", + ); + if (!capability) return new Response("Unauthorized", { status: 401 }); + const mode = sandboxConnectionMode(this.meta.state, this.meta.sandbox_disconnected_at); if (mode === "reject") { this.getTracer()?.log("WARN", "sandbox.ws.rejected", { @@ -383,6 +406,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost const pair = new WebSocketPair(); const [client, server] = Object.values(pair); this.ctx.acceptWebSocket(server, ["sandbox"]); + server.serializeAttachment({ sandbox: { aud: capability.aud, role: capability.role, sessionId: capability.sid } }); initializeSandboxConnection(this, server, mode); if (mode === "resume") { @@ -400,6 +424,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost if (typeof message !== "string") return; if (this.ctx.getWebSockets("sandbox").includes(ws)) { + if (!this.isAuthenticatedSandboxSocket(ws)) return; await dispatchSandboxSocketMessage(this, ws, message); return; } @@ -490,6 +515,13 @@ export class Orchestrator extends DurableObject implements OrchestratorHost } } + private isAuthenticatedSandboxSocket(ws: WebSocket): boolean { + const attachment = ws.deserializeAttachment() as { sandbox?: { aud?: unknown; role?: unknown; sessionId?: unknown } } | null; + return attachment?.sandbox?.aud === "sandbox_ws" + && attachment.sandbox.role === "sandbox" + && attachment.sandbox.sessionId === this.meta?.session_id; + } + async webSocketClose(ws: WebSocket, code: number, reason: string): Promise { const isSandbox = this.ctx.getWebSockets("sandbox").includes(ws); this.loadMeta(); @@ -539,7 +571,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost this.loadMeta(); this.getTracer()?.log("ERROR", "ws.error", { source: isSandbox ? "sandbox" : "cli", - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); try { ws.close(1011, "WebSocket error"); } catch { /* already closed */ } } @@ -613,6 +645,9 @@ export class Orchestrator extends DurableObject implements OrchestratorHost component: "orchestrator", trace_id: traceIdFromSessionId(this.meta.session_id), session_id: this.meta.session_id, + // Trace records include errors, stacks, span events, and nested diagnostics. + // Redact at the final sink boundary so new call sites cannot bypass it. + transform: (line) => redactEvent(line, this.redactionSecrets), }); setValidationDropSink(tracerValidationDropSink(this.tracer)); return this.tracer; @@ -682,7 +717,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost await sandbox.stop(); } catch (error) { this.getTracer()?.log("ERROR", "sandbox.stop.failed", { - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); } this.closeSandboxSockets(reason); @@ -760,28 +795,88 @@ export class Orchestrator extends DurableObject implements OrchestratorHost this.loadMeta(); if (!this.meta) return; + // Coalesce updates that accumulate while a D1 write is in flight. Each + // field keeps its latest value, so a terminal state cannot be erased by a + // burst containing unrelated field updates. + Object.assign(this.pendingDirectoryPatch, patch); + const update = this.directoryUpdates.enqueue(() => { + const pendingPatch = this.pendingDirectoryPatch; + this.pendingDirectoryPatch = {}; + return this.persistDirectoryUpdate(pendingPatch); + }, { key: this.meta.session_id }); + this.ctx.waitUntil(update); + } + + private nextDirectoryUpdateAt(): string { const now = new Date().toISOString(); + if (now > this.lastDirectoryUpdateAt) { + this.lastDirectoryUpdateAt = now; + } else { + this.lastDirectoryUpdateAt = new Date( + Date.parse(this.lastDirectoryUpdateAt) + 1, + ).toISOString(); + } + return this.lastDirectoryUpdateAt; + } + + private async persistDirectoryUpdate(patch: { + room_state?: string; + sandbox_state?: string; + active_run_state?: string | null; + }): Promise { + if (!this.meta) return; + const entries: [string, unknown][] = [ - ["updated_at", now], - ["last_event_at", now], + ["updated_at", ""], + ["last_event_at", ""], ]; if (patch.room_state !== undefined) entries.push(["room_state", patch.room_state]); if (patch.sandbox_state !== undefined) entries.push(["sandbox_state", patch.sandbox_state]); if (patch.active_run_state !== undefined) entries.push(["active_run_state", patch.active_run_state]); const assignments = entries.map(([key]) => `${key} = ?`).join(", "); - const bindings = [...entries.map(([, value]) => value), this.meta.session_id]; - const sql = `UPDATE sessions SET ${assignments} WHERE id = ?`; - - this.ctx.waitUntil( - runSessionDirectoryUpdateWithRetry(this.workerEnv.DB, sql, bindings, { + // A timed-out D1 request may still finish later. Do not let that older + // request overwrite a newer state that has already reached the directory. + const sql = `UPDATE sessions SET ${assignments} WHERE id = ? AND last_event_at < ?`; + + // A restarted DO starts with an empty in-memory clock. A zero-row update + // means this timestamp was not newer than D1 (or a late attempt already + // won), so read the baseline and retry with a strictly greater value. + for (let conflictAttempt = 0; conflictAttempt < 3; conflictAttempt++) { + const now = this.nextDirectoryUpdateAt(); + entries[0][1] = now; + entries[1][1] = now; + const bindings = [...entries.map(([, value]) => value), this.meta.session_id, now]; + const result = await runSessionDirectoryUpdateWithRetry(this.workerEnv.DB, sql, bindings, { onFailure: (error) => { this.getTracer()?.log("ERROR", "session_directory.update.failed", { - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); }, - }), - ); + }); + if (!result) return; + if (Number(result.meta.changes ?? 0) > 0) return; + + const current = await this.workerEnv.DB.prepare( + "SELECT last_event_at FROM sessions WHERE id = ?", + ).bind(this.meta.session_id).first<{ last_event_at: string }>(); + if (!current) { + this.getTracer()?.log("ERROR", "session_directory.update.conflict", { + session_id: this.meta.session_id, + reason: "session_not_found", + }); + return; + } + this.lastDirectoryUpdateAt = nextSessionDirectoryTimestamp( + this.lastDirectoryUpdateAt, + current.last_event_at, + ); + } + + this.getTracer()?.log("ERROR", "session_directory.update.conflict", { + session_id: this.meta.session_id, + reason: "retry_exhausted", + }); } freezePlanRevision(runId: string, round: number, markdown: string): void { @@ -868,7 +963,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost private armNextAlarmSafe(now?: number): Promise { return this.armNextAlarm(now).catch((error) => { this.getTracer()?.log("ERROR", "alarm.arm.failed", { - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); }); } @@ -886,7 +981,12 @@ export class Orchestrator extends DurableObject implements OrchestratorHost state: SessionState; }): Promise { try { - const diagnostics = await readSandboxDiagnostics(this.workerEnv.Sandbox, options.sessionId, "codevil-agent"); + const diagnostics = await readSandboxDiagnostics( + this.workerEnv.Sandbox, + options.sessionId, + "codevil-agent", + this.redactionSecrets, + ); const payload = buildSandboxDisconnectLogPayload({ sessionId: options.sessionId, closeCode: options.closeCode, @@ -905,7 +1005,7 @@ export class Orchestrator extends DurableObject implements OrchestratorHost close_code: options.closeCode, close_reason: options.closeReason || "none", state: options.state, - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); } } @@ -944,3 +1044,8 @@ export class Orchestrator extends DurableObject implements OrchestratorHost return proxyPreviewRequest(request, this.meta, token, this.workerEnv.Sandbox); } } + +function sandboxSessionIdFromPath(pathname: string): string | null { + const match = pathname.match(/^\/sessions\/([^/]+)\/sandbox\/ws$/); + return match?.[1] ?? null; +} diff --git a/packages/worker/src/orchestrator/directory-update-queue.ts b/packages/worker/src/orchestrator/directory-update-queue.ts new file mode 100644 index 0000000..087a372 --- /dev/null +++ b/packages/worker/src/orchestrator/directory-update-queue.ts @@ -0,0 +1,81 @@ +/** + * Serializes best-effort directory writes in mutation order. + * + * A stalled D1 call must not permanently hold this lane (the retry helper + * bounds each attempt). While a write is active, repeated updates for the + * same session coalesce into one pending slot. This keeps `waitUntil` work + * bounded during an event burst without hiding capacity failures. + */ +export class DirectoryUpdateQueue { + private readonly maxPending: number; + private readonly pending: QueueEntry[] = []; + private readonly pendingByKey = new Map(); + private running = false; + + constructor({ maxPending = 32 }: { maxPending?: number } = {}) { + if (!Number.isSafeInteger(maxPending) || maxPending < 1) { + throw new Error("DirectoryUpdateQueue maxPending must be a positive integer"); + } + this.maxPending = maxPending; + } + + get pendingCount(): number { + return this.pending.length; + } + + enqueue(work: () => Promise, { key }: { key?: string } = {}): Promise { + if (key) { + const existing = this.pendingByKey.get(key); + if (existing) { + existing.work = work; + return existing.promise; + } + } + + if (this.pending.length >= this.maxPending) { + return Promise.reject(new Error("Directory update queue capacity exhausted")); + } + + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + const entry: QueueEntry = { work, key, promise, resolve, reject }; + this.pending.push(entry); + if (key) this.pendingByKey.set(key, entry); + void this.drain(); + return promise; + } + + private async drain(): Promise { + if (this.running) return; + this.running = true; + try { + while (this.pending.length > 0) { + const entry = this.pending.shift()!; + if (entry.key) this.pendingByKey.delete(entry.key); + try { + await entry.work(); + entry.resolve(); + } catch (error) { + entry.reject(error); + } + } + } finally { + this.running = false; + // An enqueue can happen after the loop observes an empty queue but + // before `running` is reset. + if (this.pending.length > 0) void this.drain(); + } + } +} + +type QueueEntry = { + work: () => Promise; + key?: string; + promise: Promise; + resolve: () => void; + reject: (error: unknown) => void; +}; diff --git a/packages/worker/src/orchestrator/event-log-limits.ts b/packages/worker/src/orchestrator/event-log-limits.ts index 56276c4..293cdd1 100644 --- a/packages/worker/src/orchestrator/event-log-limits.ts +++ b/packages/worker/src/orchestrator/event-log-limits.ts @@ -1,7 +1,27 @@ -import type { DOToCLIEvent } from "@codevil/shared"; +import type { DOToCLIEvent, SessionSnapshot } from "@codevil/shared"; export const EVENT_LOG_RETENTION_DAYS = 7; export const MAX_EVENT_JSON_BYTES = 64 * 1024; +/** + * Snapshots are sent wholesale to reconnecting clients, so keep their + * presentation-only history below a size that is cheap to persist and replay. + * Structural session fields are deliberately never evicted here. + */ +export const MAX_SESSION_SNAPSHOT_BYTES = 1024 * 1024; +export const MAX_SESSION_MESSAGES = 500; +export const MAX_SESSION_ACTIVITY_ENTRIES = 1_000; +export const MAX_PREVIEW_OUTPUT_LINE_CHARS = 4 * 1024; +/** Prevent a busy session from accumulating an unbounded un-snapshotted tail. */ +export const MAX_EVENTS_BETWEEN_SNAPSHOTS = 1_000; + +export interface PreparedSnapshotCheckpoint { + snapshot: SessionSnapshot; + canPersist: boolean; +} + +export function shouldCompactEventTail(eventCount: number): boolean { + return eventCount >= MAX_EVENTS_BETWEEN_SNAPSHOTS; +} export function eventJsonByteLength(json: string): number { return new TextEncoder().encode(json).length; @@ -47,6 +67,57 @@ export function capEventForStorage( }; } +/** + * Bounds only derived, non-protocol display history. The durable snapshot is + * the authoritative cursor checkpoint, so its session state (phase, plan, + * participants, questions, annotations, and preview state) must remain exact. + * Messages and activity are historical UI projections and can be evicted from + * oldest to newest without changing protocol behaviour or cursor semantics. + */ +export function capSessionSnapshotForStorage(snapshot: SessionSnapshot): SessionSnapshot { + let messages = snapshot.messages.slice(-MAX_SESSION_MESSAGES); + let activityLog = snapshot.activityLog.slice(-MAX_SESSION_ACTIVITY_ENTRIES); + const preview = { + ...snapshot.preview, + outputLines: snapshot.preview.outputLines.map((line) => + line.length <= MAX_PREVIEW_OUTPUT_LINE_CHARS + ? line + : `${line.slice(0, MAX_PREVIEW_OUTPUT_LINE_CHARS)}… [truncated]`, + ), + }; + + const build = (): SessionSnapshot => ({ ...snapshot, preview, messages, activityLog }); + let capped = build(); + + while (eventJsonByteLength(JSON.stringify(capped)) > MAX_SESSION_SNAPSHOT_BYTES) { + if (messages.length === 0 && activityLog.length === 0) break; + + const messageTimestamp = messages[0]?.timestamp ?? Number.POSITIVE_INFINITY; + const activityTimestamp = activityLog[0]?.timestamp ?? Number.POSITIVE_INFINITY; + if (messageTimestamp <= activityTimestamp) { + messages = messages.slice(1); + } else { + activityLog = activityLog.slice(1); + } + capped = build(); + } + + return capped; +} + +/** + * Prepare a display-bounded checkpoint without ever truncating structural + * session data. A structural field that still exceeds the storage budget + * leaves the previous durable checkpoint and its event tail authoritative. + */ +export function prepareSnapshotCheckpoint(snapshot: SessionSnapshot): PreparedSnapshotCheckpoint { + const capped = capSessionSnapshotForStorage(snapshot); + return { + snapshot: capped, + canPersist: eventJsonByteLength(JSON.stringify(capped)) <= MAX_SESSION_SNAPSHOT_BYTES, + }; +} + function findLongestStringPath( value: unknown, path: string[] = [], diff --git a/packages/worker/src/orchestrator/event-log.ts b/packages/worker/src/orchestrator/event-log.ts index c416e2f..b3bc86a 100644 --- a/packages/worker/src/orchestrator/event-log.ts +++ b/packages/worker/src/orchestrator/event-log.ts @@ -4,6 +4,9 @@ import { DOToCLIEventSchema, emptySessionSnapshot, parseSessionSnapshot, + safeExceptionAttributes, + safeOwnDataProperty, + safePrimitiveString, type ProjectionContext, type SessionSnapshot, type Tracer, @@ -12,8 +15,11 @@ import { redactEvent } from "../redaction.js"; import { buildReplayBatch } from "../replay-batch.js"; import { capEventForStorage, + capSessionSnapshotForStorage, EVENT_LOG_RETENTION_DAYS, eventJsonByteLength, + prepareSnapshotCheckpoint, + shouldCompactEventTail, } from "./event-log-limits.js"; export class SessionEventLog { @@ -23,6 +29,8 @@ export class SessionEventLog { private snapshotMessageCounter = 0; private snapshotHydrated = false; private snapshotAlarmScheduled = false; + private lastPersistedSnapshot: SessionSnapshot = emptySessionSnapshot(); + private lastPersistedSnapshotCursor = 0; constructor( private readonly sql: SqlStorage, @@ -34,11 +42,15 @@ export class SessionEventLog { ) {} getSnapshot(): SessionSnapshot { - return this.snapshot; + return prepareSnapshotCheckpoint(this.snapshot).canPersist + ? this.snapshot + : this.lastPersistedSnapshot; } getSnapshotCursor(): number { - return this.snapshotCursor; + return prepareSnapshotCheckpoint(this.snapshot).canPersist + ? this.snapshotCursor + : this.lastPersistedSnapshotCursor; } isSnapshotDirty(): boolean { @@ -61,9 +73,9 @@ export class SessionEventLog { const validated = DOToCLIEventSchema.safeParse(event); if (!validated.success) { this.getTracer()?.log("ERROR", "event.append.rejected", { - raw_type: typeof event === "object" && event && "type" in event - ? String((event as { type: unknown }).type) - : null, + raw_type: safePrimitiveString( + safeOwnDataProperty(event, "type"), + ), issues: validated.error.issues, }); return; @@ -93,7 +105,9 @@ export class SessionEventLog { uid: () => `msg_${row.id}_${this.snapshotMessageCounter++}`, now: Date.now(), }; - this.snapshot = applyToSessionSnapshot(this.snapshot, row.id, stored, ctx); + this.snapshot = capSessionSnapshotForStorage( + applyToSessionSnapshot(this.snapshot, row.id, stored, ctx), + ); this.snapshotCursor = row.id; this.snapshotDirty = true; @@ -103,7 +117,10 @@ export class SessionEventLog { } // Persist synchronously on terminal events; otherwise debounce via alarm. - if (this.snapshotTerminalEventTypes.has(stored.type)) { + if ( + this.snapshotTerminalEventTypes.has(stored.type) || + shouldCompactEventTail(this.countEventsSinceSnapshot()) + ) { this.persistSnapshot(); } else { this.scheduleSnapshotPersist(); @@ -112,21 +129,40 @@ export class SessionEventLog { persistSnapshot(): void { try { + const prepared = prepareSnapshotCheckpoint(this.snapshot); + if (!prepared.canPersist) { + this.getTracer()?.log("WARN", "snapshot.persist.skipped", { + reason: "snapshot_exceeds_storage_budget", + bytes: eventJsonByteLength(JSON.stringify(prepared.snapshot)), + }); + return; + } this.sql.exec( "INSERT OR REPLACE INTO snapshots (path, cursor, state_json, updated_at) VALUES (?, ?, ?, datetime('now'))", "session", this.snapshotCursor, - JSON.stringify(this.snapshot), + JSON.stringify(prepared.snapshot), ); + this.lastPersistedSnapshot = prepared.snapshot; + this.lastPersistedSnapshotCursor = this.snapshotCursor; this.snapshotDirty = false; } catch (error) { this.getTracer()?.log("ERROR", "snapshot.persist.failed", { - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); // Do NOT rethrow — persistence failure is recoverable on the next alarm. } } + private countEventsSinceSnapshot(): number { + // Events are the seven-day canonical history; snapshots only accelerate + // reconnects and must never make display history disappear. + return this.sql.exec( + "SELECT COUNT(*) AS count FROM events WHERE id > ?", + this.lastPersistedSnapshotCursor, + ).one().count as number; + } + scheduleSnapshotPersist(): void { if (this.snapshotAlarmScheduled) return; this.snapshotAlarmScheduled = true; @@ -167,6 +203,8 @@ export class SessionEventLog { if (parsed) { this.snapshot = parsed; this.snapshotCursor = snapRow.cursor; + this.lastPersistedSnapshot = parsed; + this.lastPersistedSnapshotCursor = snapRow.cursor; } else { this.getTracer()?.log("ERROR", "snapshot.hydrate.failed", { error: "SessionSnapshot validation failed", @@ -174,7 +212,7 @@ export class SessionEventLog { } } catch (error) { this.getTracer()?.log("ERROR", "snapshot.hydrate.failed", { - error: error instanceof Error ? error.message : String(error), + ...redactEvent(safeExceptionAttributes(error), this.redactionSecrets), }); // Leave defaults; next append will rebuild from scratch. } diff --git a/packages/worker/src/orchestrator/provisioning.ts b/packages/worker/src/orchestrator/provisioning.ts index 7b84641..bd30c82 100644 --- a/packages/worker/src/orchestrator/provisioning.ts +++ b/packages/worker/src/orchestrator/provisioning.ts @@ -1,5 +1,5 @@ +import { safeExceptionAttributes, type Tracer } from "@codevil/shared"; import { redactEvent } from "../redaction.js"; -import type { Tracer } from "@codevil/shared"; interface SandboxProvisioningTraceOptions { tracer: Tracer; @@ -27,9 +27,9 @@ export async function traceSandboxProvisioning( } function redactedProvisioningError(error: unknown, secrets: readonly string[]): Error { - const attributes = redactEvent(provisioningErrorAttributes(error), secrets); - const redacted = new Error(String(attributes.message)); - redacted.name = String(attributes.name ?? "Error"); + const attributes = redactEvent(safeExceptionAttributes(error), secrets); + const redacted = new Error(typeof attributes.error === "string" ? attributes.error : "[UNAVAILABLE]"); + redacted.name = typeof attributes.name === "string" ? attributes.name : "Error"; if (typeof attributes.stack === "string") redacted.stack = attributes.stack; for (const [key, value] of Object.entries(attributes)) { @@ -44,16 +44,3 @@ function redactedProvisioningError(error: unknown, secrets: readonly string[]): return redacted; } - -function provisioningErrorAttributes(error: unknown): Record { - const structured = typeof error === "object" && error !== null - ? Object.fromEntries(Object.entries(error)) - : {}; - - return { - ...structured, - name: error instanceof Error ? error.name : undefined, - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }; -} diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index 70cbf7a..7b7b318 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -3,7 +3,8 @@ import type { DOToSandboxMessage, SandboxToDOMessage, } from "@codevil/shared"; -import { SandboxToDOMessageSchema, clientValidationErrorMessage, parseInbound } from "@codevil/shared"; +import { SandboxToDOMessageSchema, clientValidationErrorMessage, getProviderDefinition, parseInbound, safeExceptionAttributes, type ProviderApi } from "@codevil/shared"; +import { redactEvent } from "../redaction.js"; import { buildSandboxWebSocketUrl, provisionSandbox, @@ -14,8 +15,10 @@ import { type WorkspaceCacheSandbox, } from "../workspace-cache.js"; import type { SandboxConnectionMode } from "../sandbox-connection.js"; -import { createDraftPullRequest, credentialRequestAllowed } from "../github.js"; -import { getProvisioningCredentialContext } from "../provider-credentials.js"; +import { createDraftPullRequest, normalizeGitHubRepoName } from "../github.js"; +import { getProvisioningCredentialContext, requireProviderPublicConfig } from "../provider-credentials.js"; +import { createSandboxGitProxyToken, createSandboxProxyToken } from "../sandbox-proxy.js"; +import { createSandboxWebSocketToken } from "../sandbox-ws-token.js"; import { traceSandboxProvisioning } from "./provisioning.js"; import { buildPreviewUrl, @@ -46,7 +49,13 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise { const restored = await restoreWorkspaceCacheBeforeStart(host, sandbox as WorkspaceCacheSandbox); if (host.meta) { @@ -78,7 +89,7 @@ export async function provisionSessionSandbox(host: OrchestratorHost): Promise>> { + host.loadMeta(); + if (!host.meta) throw new Error("Session not initialized"); + const providerDefinition = getProviderDefinition(host.meta.provider); + if (!providerDefinition) throw new Error("Unsupported LLM provider"); + const llm = Object.fromEntries(await Promise.all(providerDefinition.authPolicies.map(async ({ api }) => [ + api, + await createSandboxProxyToken(host.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? "", { + sessionId: host.meta!.session_id, + provider: host.meta!.provider, + api, + }), + ]))) as Partial>; + const primaryRepo = githubRepoName(host.meta.repo); + if (!primaryRepo) throw new Error("Session repository must be a github.com HTTPS repository"); + return { + ...llm, + git: await createSandboxGitProxyToken(host.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? "", { + sessionId: host.meta.session_id, + primaryRepo, + }), + }; +} + +function githubRepoName(repo: string): string | undefined { + return normalizeGitHubRepoName(repo); +} + +/** Only call after the connection has passed the sandbox-only upgrade check. */ +export async function issueSandboxWebSocketCapability(host: OrchestratorHost): Promise { + host.loadMeta(); + if (!host.meta) throw new Error("Session not initialized"); + return createSandboxWebSocketToken( + host.meta.session_id, + host.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? "", + ); +} + export function handleSandboxCloneStarted(host: OrchestratorHost): void { if (!host.meta) return; if (host.meta.state !== "provisioning_sandbox") return; @@ -488,38 +546,6 @@ export function handleAskQuestionRequest( }); } -export function handleCredentialRequest( - host: OrchestratorHost, - ws: WebSocket, - request: Extract, -): void { - if (!host.meta) return; - - if (!host.workerEnv.GITHUB_PAT) { - ws.send(JSON.stringify({ - type: "credential_response", - request_id: request.request_id, - error: "GitHub credentials are not configured.", - } satisfies DOToSandboxMessage)); - return; - } - - if (!credentialRequestAllowed(host.meta.repo, request)) { - ws.send(JSON.stringify({ - type: "credential_response", - request_id: request.request_id, - error: "Credential request does not match this session repository.", - } satisfies DOToSandboxMessage)); - return; - } - - ws.send(JSON.stringify({ - type: "credential_response", - request_id: request.request_id, - username: "x-access-token", - password: host.workerEnv.GITHUB_PAT, - } satisfies DOToSandboxMessage)); -} export async function handleCreatePullRequestRequest( host: OrchestratorHost, @@ -551,7 +577,7 @@ export async function handleCreatePullRequestRequest( ws.send(JSON.stringify({ type: "create_pr_response", request_id: request.request_id, - error: error instanceof Error ? error.message : String(error), + error: "Unable to create the pull request.", } satisfies DOToSandboxMessage)); } } @@ -579,7 +605,9 @@ export async function handleBranchPushed( completeActiveRun(host, prUrl); } catch (error) { - failActiveRunAndReturnReady(host, error instanceof Error ? error.message : String(error)); + const details = redactEvent(safeExceptionAttributes(error), host.redactionSecrets); + host.getTracer()?.log("ERROR", "github.pull_request.failed", details); + failActiveRunAndReturnReady(host, "Unable to create the pull request."); } } diff --git a/packages/worker/src/orchestrator/types.ts b/packages/worker/src/orchestrator/types.ts index d5ca2e4..df1a019 100644 --- a/packages/worker/src/orchestrator/types.ts +++ b/packages/worker/src/orchestrator/types.ts @@ -1,24 +1,22 @@ import type { ParticipantIdentity, SessionState } from "@codevil/shared"; import type { Sandbox } from "@cloudflare/sandbox"; +import type { WorkerSecretEnv } from "../worker-env.js"; export type { SessionMeta } from "@codevil/shared"; -export interface Env { +export interface Env extends WorkerSecretEnv { Sandbox: DurableObjectNamespace; DB: D1Database; BACKUP_BUCKET?: R2Bucket; CODEVIL_API_KEY: string; + CODEVIL_PROXY_SIGNING_SECRET: string; CLOUDFLARE_ACCOUNT_ID?: string; + CLOUDFLARE_GATEWAY_ID?: string; CLOUDFLARE_R2_ACCOUNT_ID?: string; R2_ACCESS_KEY_ID?: string; R2_SECRET_ACCESS_KEY?: string; BACKUP_BUCKET_NAME?: string; - OPENCODE_API_KEY?: string; - OPENROUTER_API_KEY?: string; - OPENAI_API_KEY?: string; - CODEVIL_LLM_KEY?: string; - GITHUB_PAT?: string; CODEVIL_PREVIEW_ORIGIN?: string; } diff --git a/packages/worker/src/provider-credentials.ts b/packages/worker/src/provider-credentials.ts index bf6b7c7..b6ea3b9 100644 --- a/packages/worker/src/provider-credentials.ts +++ b/packages/worker/src/provider-credentials.ts @@ -1,11 +1,14 @@ -import { getProviderDefinition, LLM_PROVIDERS } from "@codevil/shared"; +import { + getProviderDefinition, + LLM_PROVIDER_CAPABILITIES, + type ProviderPublicConfig, + type ProviderPublicConfigKey, + type WorkerProviderSecretName, +} from "@codevil/shared"; -export interface ProviderCredentialEnv { - OPENCODE_API_KEY?: string; - OPENROUTER_API_KEY?: string; - OPENAI_API_KEY?: string; +export type ProviderCredentialEnv = Partial> & { CODEVIL_LLM_KEY?: string; -} +} & Partial>; export interface ProvisioningCredentialContext { llmKey: string; @@ -20,14 +23,7 @@ export function resolveProviderCredential( const definition = getProviderDefinition(provider); if (!definition) return legacy; - switch (definition.secretName) { - case "OPENCODE_API_KEY": - return normalizeCredential(env.OPENCODE_API_KEY) ?? legacy; - case "OPENROUTER_API_KEY": - return normalizeCredential(env.OPENROUTER_API_KEY) ?? legacy; - case "OPENAI_API_KEY": - return normalizeCredential(env.OPENAI_API_KEY) ?? legacy; - } + return normalizeCredential(env[definition.secretName]) ?? legacy; } export function requireProviderCredential( @@ -52,10 +48,35 @@ export function getProvisioningCredentialContext( }; } +/** + * Return only registry-declared non-secret configuration for a provider. + * Required values fail before the sandbox starts, rather than leaving Pi to + * issue requests containing unresolved URL placeholders. + */ +export function requireProviderPublicConfig( + env: ProviderCredentialEnv, + provider: string, +): ProviderPublicConfig { + const definition = getProviderDefinition(provider); + if (!definition) throw new Error("Unsupported LLM provider"); + + const config: ProviderPublicConfig = {}; + const missing: string[] = []; + for (const key of definition.configKeys) { + const value = normalizeCredential(env[key]); + if (!value) missing.push(key); + else config[key] = value; + } + if (missing.length > 0) { + throw new Error(`${definition.displayName} is missing required configuration: ${missing.join(", ")}`); + } + return config; +} + export function collectProviderCredentialSecrets(env: ProviderCredentialEnv): string[] { return [...new Set( [ - ...LLM_PROVIDERS.map((provider) => env[provider.secretName]), + ...LLM_PROVIDER_CAPABILITIES.map((provider) => env[provider.secretName]), env.CODEVIL_LLM_KEY, ] .map(normalizeCredential) @@ -65,5 +86,6 @@ export function collectProviderCredentialSecrets(env: ProviderCredentialEnv): st function normalizeCredential(value: string | undefined): string | undefined { if (!value) return undefined; - return value.trim() === "" ? undefined : value; + const normalized = value.trim(); + return normalized === "" ? undefined : normalized; } diff --git a/packages/worker/src/redaction.ts b/packages/worker/src/redaction.ts index 04c03f5..d4f1393 100644 --- a/packages/worker/src/redaction.ts +++ b/packages/worker/src/redaction.ts @@ -1,4 +1,5 @@ const REDACTED = "[REDACTED]"; +const UNAVAILABLE = "[UNAVAILABLE]"; const secretPatterns = [ /sk-ant-api[0-9a-zA-Z_-]*/g, @@ -7,23 +8,133 @@ const secretPatterns = [ /(?:api[_-]?key|token|secret|password)=([^&\s]+)/gi, ]; -import { isRecord } from "@codevil/shared"; +import { isRecord, safePrimitiveString } from "@codevil/shared"; export function redactEvent(event: T, exactSecrets: readonly string[]): T { - return redactValue(event, normalizeSecrets(exactSecrets)) as T; + try { + return redactValue(event, normalizeSecrets(exactSecrets), new WeakMap()) as T; + } catch { + // Logging must never turn an application error into a second failure, or + // fall back to an unredacted diagnostic path. + return { redaction: UNAVAILABLE } as T; + } } -function redactValue(value: unknown, exactSecrets: readonly string[]): unknown { +function redactValue( + value: unknown, + exactSecrets: readonly string[], + seen: WeakMap, +): unknown { if (typeof value === "string") return redactString(value, exactSecrets); - if (Array.isArray(value)) return value.map((item) => redactValue(item, exactSecrets)); + if (value instanceof Error) return redactError(value, exactSecrets, seen); + if (Array.isArray(value)) { + const existing = seen.get(value); + if (existing) return existing; + const redacted: unknown[] = []; + seen.set(value, redacted); + const descriptors = propertyDescriptors(value); + if (!descriptors) return [UNAVAILABLE]; + const length = descriptors.length?.value; + if (typeof length !== "number") return [UNAVAILABLE]; + for (let index = 0; index < length; index += 1) { + const descriptor = descriptors[index]; + if (!descriptor) continue; + redacted[index] = redactDescriptorValue(descriptor, exactSecrets, seen); + } + return redacted; + } if (isRecord(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, nested]) => [key, redactValue(nested, exactSecrets)]), - ); + const existing = seen.get(value); + if (existing) return existing; + const redacted: Record = {}; + seen.set(value, redacted); + const descriptors = propertyDescriptors(value); + if (!descriptors) return { redaction: UNAVAILABLE }; + for (const [key, descriptor] of Object.entries(descriptors)) { + if (!descriptor.enumerable) continue; + redacted[key] = sensitiveAttributeName.test(key) + ? REDACTED + : redactDescriptorValue(descriptor, exactSecrets, seen); + } + return redacted; } return value; } +const sensitiveAttributeName = /(?:api[_-]?key|token|secret|password|authorization)/i; + +function redactError(error: Error, exactSecrets: readonly string[], seen: WeakMap): Error { + const existing = seen.get(error); + if (existing) return existing as Error; + + const descriptors = propertyDescriptors(error); + const redacted = new Error(redactString(errorText(error, descriptors, "message"), exactSecrets)); + redacted.name = redactString(errorText(error, descriptors, "name"), exactSecrets); + const stack = errorText(error, descriptors, "stack"); + if (stack !== UNAVAILABLE) redacted.stack = redactString(stack, exactSecrets); + seen.set(error, redacted); + if (!descriptors) { + Object.defineProperty(redacted, "redaction", { value: UNAVAILABLE, enumerable: true }); + return redacted; + } + for (const [key, descriptor] of Object.entries(descriptors)) { + if (key === "message" || key === "name" || key === "stack") continue; + if (!descriptor.enumerable && key !== "cause") continue; + const value = sensitiveAttributeName.test(key) + ? REDACTED + : redactDescriptorValue(descriptor, exactSecrets, seen); + Object.defineProperty(redacted, key, { + value, + enumerable: descriptor.enumerable, + configurable: true, + writable: true, + }); + } + return redacted; +} + +function propertyDescriptors(value: object): Record | undefined { + try { + return Object.getOwnPropertyDescriptors(value); + } catch { + return undefined; + } +} + +function redactDescriptorValue( + descriptor: PropertyDescriptor, + exactSecrets: readonly string[], + seen: WeakMap, +): unknown { + return "value" in descriptor + ? redactValue(descriptor.value, exactSecrets, seen) + : UNAVAILABLE; +} + +function errorText( + error: Error, + descriptors: Record | undefined, + key: "message" | "name" | "stack", +): string { + const descriptor = descriptors?.[key] ?? inheritedDescriptor(error, key); + if (!descriptor || !("value" in descriptor)) return UNAVAILABLE; + return typeof descriptor.value === "string" ? descriptor.value : safePrimitiveString(descriptor.value); +} + +function inheritedDescriptor(error: Error, key: string): PropertyDescriptor | undefined { + try { + let prototype: object | null = Object.getPrototypeOf(error); + while (prototype) { + const descriptor = Object.getOwnPropertyDescriptor(prototype, key); + if (descriptor) return descriptor; + prototype = Object.getPrototypeOf(prototype); + } + } catch { + return undefined; + } + return undefined; +} + function redactString(value: string, exactSecrets: readonly string[]): string { let redacted = value; for (const secret of exactSecrets) { @@ -41,5 +152,5 @@ function redactString(value: string, exactSecrets: readonly string[]): string { } function normalizeSecrets(secrets: readonly string[]): string[] { - return secrets.filter((secret) => secret.length >= 6); + return [...new Set(secrets.map((secret) => secret.trim()).filter(Boolean))]; } diff --git a/packages/worker/src/sandbox-proxy.ts b/packages/worker/src/sandbox-proxy.ts new file mode 100644 index 0000000..45857c7 --- /dev/null +++ b/packages/worker/src/sandbox-proxy.ts @@ -0,0 +1,220 @@ +import { + getProviderOutboundAuthPolicy, + PROVIDER_APIS, + type ProviderApi, +} from "@codevil/shared"; +import { resolveProviderCredential } from "./provider-credentials.js"; +import type { Env } from "./worker-env.js"; + +const TOKEN_TTL_SECONDS = 15 * 60; +const PROXY_TARGET_HEADER = "x-codevil-proxy-target"; +const TOKEN_VERSION = "v1"; +const GIT_TOKEN_VERSION = "git1"; + +export interface SandboxProxyClaims { + /** + * Capability scope, checked against the immutable proxy path. This prevents a + * capability from being used for another session/provider/API. The proxy has + * no authenticated sandbox identity, so this is intentionally a bearer + * capability: theft of the token itself remains the security boundary. + */ + sessionId: string; + provider: string; + api: ProviderApi; + exp: number; +} + +/** Deliberately separate from LLM capabilities: it authorizes Git only. */ +export interface SandboxGitProxyClaims { + sessionId: string; + primaryRepo: string; + exp: number; +} + +/** Issue a short-lived, HMAC-authenticated sandbox-only capability. */ +export async function createSandboxProxyToken( + secret: string, + claims: Omit, + now = Date.now(), +): Promise { + if (!secret.trim()) throw new Error("CODEVIL_PROXY_SIGNING_SECRET is not configured"); + const payload: SandboxProxyClaims = { ...claims, exp: Math.floor(now / 1000) + TOKEN_TTL_SECONDS }; + const encoded = base64url(JSON.stringify(payload)); + const signed = `${TOKEN_VERSION}.${encoded}`; + return `${signed}.${await signature(secret, signed)}`; +} + +export async function createSandboxGitProxyToken(secret: string, claims: Omit, now = Date.now()): Promise { + if (!secret.trim()) throw new Error("CODEVIL_PROXY_SIGNING_SECRET is not configured"); + if (!isRepoName(claims.primaryRepo)) throw new Error("Invalid primary Git repository"); + const payload: SandboxGitProxyClaims = { ...claims, exp: Math.floor(now / 1000) + TOKEN_TTL_SECONDS }; + const encoded = base64url(JSON.stringify(payload)); + const signed = `${GIT_TOKEN_VERSION}.${encoded}`; + return `${signed}.${await signature(secret, signed)}`; +} + +export async function handleSandboxProxy(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (!url.pathname.startsWith("/sandbox-proxy/")) return null; + const secret = env.CODEVIL_PROXY_SIGNING_SECRET?.trim(); + if (!secret) return proxyError("Sandbox proxy is not configured", 503); + + const llm = url.pathname.match(/^\/sandbox-proxy\/sessions\/([^/]+)\/llm\/([^/]+)\/([^/]+)(\/.*)$/); + if (llm && request.method === "POST") { + const [_, sessionId, provider, api, suffix] = llm; + const claims = await verifyRequestToken(request, secret, "llm"); + if (!claims || claims.sessionId !== sessionId || claims.provider !== provider || claims.api !== api) return proxyError("Unauthorized", 401); + if (!isProviderApi(api)) return proxyError("Invalid provider API", 400); + return proxyLlm(request, env, claims, provider, api, suffix); + } + const git = url.pathname.match(/^\/sandbox-proxy\/sessions\/([^/]+)\/github\/([^/]+)\/([^/]+)\.git(?:\/(.*))?$/); + if (git) { + const [, sessionId, owner, repo, suffix = ""] = git; + const claims = await verifyGitRequestToken(request, secret); + if (!claims || claims.sessionId !== sessionId) return proxyError("Unauthorized", 401); + return proxyGit(request, env, claims, owner, repo, suffix); + } + return proxyError("Not found", 404); +} + +async function proxyGit(request: Request, env: Env, claims: SandboxGitProxyClaims, owner: string, repo: string, suffix: string): Promise { + if (!isRepoPart(owner) || !isRepoPart(repo) || !isSafeGitSuffix(suffix)) return proxyError("Invalid Git repository path", 400); + const operation = gitSmartHttpOperation(request, suffix); + if (!operation) return proxyError("Git operation is not allowed", 400); + if (!gitCapabilityFromBasic(request.headers)) return proxyError("Unauthorized", 401); + if (operation === "write" && `${owner}/${repo}` !== claims.primaryRepo) return proxyError("Git write is not authorized for this repository", 403); + const pat = env.GITHUB_PAT?.trim(); + if (!pat) return proxyError("GitHub credentials are not configured", 503); + const upstream = new URL(`https://github.com/${owner}/${repo}.git${suffix ? `/${suffix}` : ""}`); + upstream.search = new URL(request.url).search; + const headers = cleanedHeaders(request.headers); + headers.set("authorization", `Basic ${btoa(`x-access-token:${pat}`)}`); + return fetch(upstream, { method: request.method, headers, body: request.body, redirect: "error" }); +} + +async function proxyLlm(request: Request, env: Env, claims: SandboxProxyClaims, provider: string, api: ProviderApi, suffix: string): Promise { + const target = request.headers.get(PROXY_TARGET_HEADER); + if (!target) return proxyError("Missing proxy target", 400); + const targetUrl = safeHttpsUrl(target); + const policy = targetUrl && getProviderOutboundAuthPolicy(provider, targetUrl.hostname, api); + if (!targetUrl || !policy) return proxyError("Provider target is not allowed", 403); + const key = resolveProviderCredential(env, provider); + if (!key) return proxyError("Provider is not configured", 503); + const upstream = appendSafeRelativePath(targetUrl, suffix, new URL(request.url).search); + if (!upstream) return proxyError("Provider request path is not allowed", 400); + // Defense in depth: path construction must never turn a vetted target into a + // different authority before the Worker attaches the provider credential. + if (upstream.origin !== targetUrl.origin) return proxyError("Provider request path is not allowed", 400); + const headers = cleanedHeaders(request.headers); + headers.set(policy.header, `${policy.prefix}${key}`); + return fetch(upstream, { method: request.method, headers, body: request.body, redirect: "error" }); +} + +async function verifyRequestToken(request: Request, secret: string, kind: "llm"): Promise { + const token = proxyToken(request.headers); + if (!token) return undefined; + const parts = token.split("."); + if (parts.length !== 3) return undefined; + const [version, encoded, supplied] = parts; + const signed = `${version}.${encoded}`; + if (version !== TOKEN_VERSION || !encoded || !supplied || !await timingSafeEqual(await signature(secret, signed), supplied)) return undefined; + try { + const claims = JSON.parse(unbase64url(encoded)) as SandboxProxyClaims; + if (!claims || !isSessionId(claims.sessionId) || typeof claims.provider !== "string" || !isProviderApi(claims.api) || !Number.isInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1000)) return undefined; + return claims; + } catch { return undefined; } +} +async function verifyGitRequestToken(request: Request, secret: string): Promise { + const token = gitCapabilityFromBasic(request.headers); + if (!token) return undefined; + const parts = token.split("."); + if (parts.length !== 3) return undefined; + const [version, encoded, supplied] = parts; + const signed = `${version}.${encoded}`; + if (version !== GIT_TOKEN_VERSION || !encoded || !supplied || !await timingSafeEqual(await signature(secret, signed), supplied)) return undefined; + try { + const claims = JSON.parse(unbase64url(encoded)) as SandboxGitProxyClaims; + return claims && isSessionId(claims.sessionId) && isRepoName(claims.primaryRepo) && Number.isInteger(claims.exp) && claims.exp > Math.floor(Date.now() / 1000) ? claims : undefined; + } catch { return undefined; } +} +function gitCapabilityFromBasic(headers: Headers): string | undefined { + const raw = headers.get("authorization")?.trim(); + const match = raw?.match(/^Basic\s+([A-Za-z0-9+/=]+)$/i); + if (!match) return undefined; + try { const decoded = atob(match[1]); const colon = decoded.indexOf(":"); return colon >= 0 && decoded.slice(0, colon) === "x-access-token" ? decoded.slice(colon + 1) || undefined : undefined; } catch { return undefined; } +} + +function proxyToken(headers: Headers): string | undefined { + for (const header of ["authorization", "x-api-key", "x-goog-api-key", "cf-aig-authorization"]) { + const value = headers.get(header)?.trim(); + if (!value) continue; + return value.replace(/^Bearer\s+/i, ""); + } + return undefined; +} +function safeHttpsUrl(value: string): URL | undefined { + try { + const url = new URL(value); + return url.protocol === "https:" + && !url.username + && !url.password + && (url.port === "" || url.port === "443") + && !url.search + && !url.hash + ? url + : undefined; + } catch { return undefined; } +} +function cleanedHeaders(source: Headers): Headers { + const headers = new Headers(source); + for (const key of ["authorization", "x-api-key", "x-goog-api-key", "cf-aig-authorization", PROXY_TARGET_HEADER, "host", "content-length"]) headers.delete(key); + for (const key of [...headers.keys()]) { + if (key.startsWith("x-codevil-")) headers.delete(key); + } + return headers; +} +function isProviderApi(value: string): value is ProviderApi { return (PROVIDER_APIS as readonly string[]).includes(value); } +function isSessionId(value: unknown): value is string { return typeof value === "string" && /^ses_[a-zA-Z0-9_-]+$/.test(value); } +function isRepoPart(value: string): boolean { return /^[A-Za-z0-9_.-]+$/.test(value) && value !== "." && value !== ".."; } +function isRepoName(value: unknown): value is string { const parts = typeof value === "string" ? value.split("/") : []; return parts.length === 2 && isRepoPart(parts[0]) && isRepoPart(parts[1]); } +function isSafeGitSuffix(value: string): boolean { return !value || (!value.includes("\\") && !/%2f|%5c/i.test(value) && value.split("/").every((part) => /^[A-Za-z0-9_.-]+$/.test(part) && part !== "." && part !== "..")); } + +/** + * Only Git smart-HTTP negotiation and pack transfer are proxyable. Keeping the + * query string exact prevents a capability-bearing request from becoming a + * general authenticated github.com fetch via duplicate or extra parameters. + */ +function gitSmartHttpOperation(request: Request, suffix: string): "read" | "write" | undefined { + const search = new URL(request.url).search; + if (request.method === "GET" && suffix === "info/refs") { + if (search === "?service=git-upload-pack") return "read"; + if (search === "?service=git-receive-pack") return "write"; + return undefined; + } + if (request.method === "POST" && search === "") { + if (suffix === "git-upload-pack") return "read"; + if (suffix === "git-receive-pack") return "write"; + } + return undefined; +} + +/** + * Append Pi's request suffix without URL resolution. URL resolution would allow + * a network-path or absolute reference to replace the vetted provider origin. + */ +function appendSafeRelativePath(target: URL, suffix: string, search: string): URL | undefined { + if (!suffix.startsWith("/") || suffix.startsWith("//") || suffix.includes("\\") || /%2f|%5c/i.test(suffix)) return undefined; + const path = suffix.slice(1); + if (!path || path.includes("://")) return undefined; + const parts = path.split("/"); + if (parts.some((part) => !part || part === "." || part === ".." || part.includes(":"))) return undefined; + const upstream = new URL(target.toString()); + upstream.pathname = `${target.pathname.replace(/\/$/, "")}/${parts.join("/")}`; + upstream.search = search; + return upstream; +} +function proxyError(error: string, status: number): Response { return Response.json({ error }, { status }); } +function base64url(value: string): string { return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } +function unbase64url(value: string): string { return atob(value.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((value.length + 3) % 4)); } +async function signature(secret: string, value: string): Promise { const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))))); } +async function timingSafeEqual(a: string, b: string): Promise { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } diff --git a/packages/worker/src/sandbox-ws-token.ts b/packages/worker/src/sandbox-ws-token.ts new file mode 100644 index 0000000..8da1401 --- /dev/null +++ b/packages/worker/src/sandbox-ws-token.ts @@ -0,0 +1,77 @@ +const TOKEN_VERSION = "sws1"; + +/** The sandbox process refreshes this over its authenticated socket well before expiry. */ +export const SANDBOX_WS_TOKEN_TTL_MS = 15 * 60 * 1000; + +export interface SandboxWebSocketClaims { + aud: "sandbox_ws"; + role: "sandbox"; + sid: string; + exp: number; + jti: string; +} + +export async function createSandboxWebSocketToken( + sessionId: string, + secret: string, + now = Date.now(), +): Promise { + if (!secret.trim()) throw new Error("CODEVIL_PROXY_SIGNING_SECRET is not configured"); + const payload: SandboxWebSocketClaims = { + aud: "sandbox_ws", + role: "sandbox", + sid: sessionId, + exp: now + SANDBOX_WS_TOKEN_TTL_MS, + // A fresh identifier makes refreshes actual rotations even within one clock tick. + jti: crypto.randomUUID(), + }; + const encoded = base64url(JSON.stringify(payload)); + const signed = `${TOKEN_VERSION}.${encoded}`; + return `${signed}.${await signature(secret, signed)}`; +} + +export async function verifySandboxWebSocketToken( + token: string | null | undefined, + sessionId: string, + secret: string, + now = Date.now(), +): Promise { + if (!token || !secret.trim()) return null; + const [version, encoded, supplied, ...extra] = token.split("."); + if (extra.length || version !== TOKEN_VERSION || !encoded || !supplied) return null; + const signed = `${version}.${encoded}`; + if (!await timingSafeEqual(await signature(secret, signed), supplied)) return null; + try { + const claims = JSON.parse(unbase64url(encoded)) as Partial; + if ( + claims.aud !== "sandbox_ws" + || claims.role !== "sandbox" + || claims.sid !== sessionId + || typeof claims.exp !== "number" + || !Number.isFinite(claims.exp) + || claims.exp <= now + || typeof claims.jti !== "string" + || claims.jti.length < 1 + ) return null; + return claims as SandboxWebSocketClaims; + } catch { + return null; + } +} + +function base64url(value: string): string { + return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} +function unbase64url(value: string): string { + return atob(value.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((value.length + 3) % 4)); +} +async function signature(secret: string, value: string): Promise { + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))))); +} +async function timingSafeEqual(a: string, b: string): Promise { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index); + return difference === 0; +} diff --git a/packages/worker/src/sandbox.ts b/packages/worker/src/sandbox.ts index a4328b4..814e557 100644 --- a/packages/worker/src/sandbox.ts +++ b/packages/worker/src/sandbox.ts @@ -1,15 +1,21 @@ import type { Sandbox } from "@cloudflare/sandbox"; +import { safeExceptionAttributes, safeOwnDataProperty, type ProviderPublicConfig } from "@codevil/shared"; +import { redactEvent } from "./redaction.js"; export interface SandboxProcessEnvOptions { wsUrl: string; - apiKey: string; + wsToken: string; provider: string; + /** Registry-limited, non-secret provider values (for example Cloudflare IDs). */ + providerConfig?: ProviderPublicConfig; + proxyBase?: string; + /** Per-Pi-API capabilities; provider credentials never enter the sandbox. */ + proxyTokens?: Partial>; } export interface ProvisionSandboxOptions extends SandboxProcessEnvOptions { binding: DurableObjectNamespace; sessionId: string; - llmKey?: string; beforeStart?: (sandbox: Sandbox) => Promise; } @@ -47,6 +53,27 @@ export interface SandboxLifecycleEvent { error?: string; } +export function lifecycleErrorEvent( + error: unknown, + secrets: readonly string[], +): SandboxLifecycleEvent { + const details = redactEvent(safeExceptionAttributes(error), secrets); + return { + type: "error", + at: new Date().toISOString(), + error: typeof details.error === "string" ? details.error : "[UNAVAILABLE]", + }; +} + +export function stopDiagnostics(params: unknown): Pick { + const exitCode = safeOwnDataProperty(params, "exitCode"); + const reason = safeOwnDataProperty(params, "reason"); + return { + ...(typeof exitCode === "number" ? { exit_code: exitCode } : {}), + ...(typeof reason === "string" ? { reason } : {}), + }; +} + export interface SandboxLifecycleSnapshot { keepAlive?: SandboxKeepAliveState; lastEvent?: SandboxLifecycleEvent; @@ -117,12 +144,18 @@ export function buildSandboxWebSocketUrl(workerUrl: string, sessionId: string): } export function sandboxProcessEnv(options: SandboxProcessEnvOptions): Record { + const wsUrl = new URL(options.wsUrl); + wsUrl.searchParams.set("sandbox_ws_token", options.wsToken); return { - CODEVIL_DO_WS_URL: options.wsUrl, - CODEVIL_API_KEY: options.apiKey, + CODEVIL_DO_WS_URL: wsUrl.toString(), + CODEVIL_SANDBOX_WS_TOKEN: options.wsToken, CODEVIL_WORKSPACE: "/workspace", CODEVIL_PROVIDER: options.provider, - CODEVIL_LLM_KEY_FILE: "/run/secrets/llm_key", + ...(options.providerConfig && Object.keys(options.providerConfig).length > 0 + ? { CODEVIL_PROVIDER_CONFIG: JSON.stringify(options.providerConfig) } + : {}), + ...(options.proxyBase ? { CODEVIL_PROXY_BASE: options.proxyBase } : {}), + ...(options.proxyTokens ? { CODEVIL_PROXY_TOKENS: JSON.stringify(options.proxyTokens) } : {}), }; } @@ -155,8 +188,17 @@ export async function setCodevilSandboxKeepAlive( export async function recordSandboxLifecycleEvent( storage: SandboxLifecycleStorage, event: SandboxLifecycleEvent, + secrets: readonly string[], ): Promise { - await storage.put(SANDBOX_LIFECYCLE_EVENT_KEY, event); + await storage.put(SANDBOX_LIFECYCLE_EVENT_KEY, redactEvent(event, secrets)); +} + +/** Sanitize state before it crosses a Durable Object storage boundary. */ +export function redactSandboxKeepAliveState( + state: SandboxKeepAliveState, + secrets: readonly string[], +): SandboxKeepAliveState { + return redactEvent(state, secrets); } export function shouldDeferSandboxActivityExpiry(state: SandboxKeepAliveState | undefined): boolean { @@ -170,13 +212,6 @@ export async function provisionSandbox(options: ProvisionSandboxOptions): Promis setCodevilSandboxKeepAlive(sandbox as CodevilKeepAliveSandbox, true, "session provisioning"), ); - await retrySandboxOperation(() => sandbox.mkdir("/run/secrets", { recursive: true })); - - const llmKey = options.llmKey; - if (llmKey) { - await retrySandboxOperation(() => sandbox.writeFile("/run/secrets/llm_key", llmKey)); - } - const env = sandboxProcessEnv(options); await retrySandboxOperation(() => sandbox.writeFile( "/run/secrets/env.json", @@ -226,9 +261,9 @@ function retryDelay(baseDelayMs: number, attempt: number): number { } function isTransientSandboxError(error: unknown): boolean { - if (!(error instanceof Error)) return false; - - const message = error.message.toLowerCase(); + const candidate = safeExceptionAttributes(error).error; + if (typeof candidate !== "string") return false; + const message = candidate.toLowerCase(); return message.includes("503") || message.includes("temporarily unavailable") || message.includes("no container instance") @@ -246,11 +281,12 @@ export async function readProcessLogs( binding: DurableObjectNamespace, sessionId: string, processId: string, + secrets: readonly string[], ): Promise<{ stdout: string; stderr: string } | null> { try { const { getSandbox } = await import("@cloudflare/sandbox"); const sandbox = getCodevilSandbox(getSandbox, binding, sessionId); - return await sandbox.getProcessLogs(processId); + return redactEvent(await sandbox.getProcessLogs(processId), secrets); } catch { return null; } @@ -260,6 +296,7 @@ export async function readSandboxDiagnostics( binding: Binding, sessionId: string, processId: string, + secrets: readonly string[], ): Promise { const { getSandbox } = await import("@cloudflare/sandbox"); const sandbox = getCodevilSandbox( @@ -271,12 +308,13 @@ export async function readSandboxDiagnostics( binding, sessionId, ); - return collectSandboxDiagnostics(sandbox as SandboxLogReader & CodevilLifecycleSandbox, processId); + return collectSandboxDiagnostics(sandbox as SandboxLogReader & CodevilLifecycleSandbox, processId, secrets); } export async function collectSandboxDiagnostics( sandbox: SandboxLogReader & CodevilLifecycleSandbox, processId: string, + secrets: readonly string[], ): Promise { const [logs, lifecycle] = await Promise.allSettled([ sandbox.getProcessLogs(processId), @@ -286,18 +324,19 @@ export async function collectSandboxDiagnostics( ]); const errors: SandboxDiagnostics["errors"] = {}; - if (logs.status === "rejected") errors.logs = errorMessage(logs.reason); - if (lifecycle.status === "rejected") errors.lifecycle = errorMessage(lifecycle.reason); + if (logs.status === "rejected") errors.logs = errorMessage(logs.reason, secrets); + if (lifecycle.status === "rejected") errors.lifecycle = errorMessage(lifecycle.reason, secrets); return { - logs: logs.status === "fulfilled" ? logs.value : null, - lifecycle: lifecycle.status === "fulfilled" ? lifecycle.value : null, + logs: logs.status === "fulfilled" ? redactEvent(logs.value, secrets) : null, + lifecycle: lifecycle.status === "fulfilled" ? redactEvent(lifecycle.value, secrets) : null, ...(Object.keys(errors).length > 0 ? { errors } : {}), }; } -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function errorMessage(error: unknown, secrets: readonly string[]): string { + const details = redactEvent(safeExceptionAttributes(error), secrets); + return typeof details.error === "string" ? details.error : "[UNAVAILABLE]"; } export function buildSandboxDisconnectLogPayload( diff --git a/packages/worker/src/session-directory.ts b/packages/worker/src/session-directory.ts index 3974879..1e2fb90 100644 --- a/packages/worker/src/session-directory.ts +++ b/packages/worker/src/session-directory.ts @@ -210,6 +210,19 @@ export function sessionDirectoryFailureUpdate(sessionId: string, now: string): S }; } +/** + * Returns a timestamp that is strictly newer than a persisted directory + * baseline. Durable Objects can restart with an empty in-memory clock, and + * wall clocks can be behind a value previously written by another attempt. + */ +export function nextSessionDirectoryTimestamp(candidate: string, persistedBaseline: string): string { + if (candidate > persistedBaseline) return candidate; + + const parsedBaseline = Date.parse(persistedBaseline); + if (!Number.isFinite(parsedBaseline)) return candidate; + return new Date(parsedBaseline + 1).toISOString(); +} + export async function runSessionDirectoryUpdateWithRetry( db: D1Database, sql: string, @@ -217,17 +230,21 @@ export async function runSessionDirectoryUpdateWithRetry( options: { attempts?: number; backoffMs?: number; + attemptTimeoutMs?: number; onFailure: (error: unknown) => void; }, -): Promise { +): Promise | undefined> { const attempts = options.attempts ?? 3; const backoffMs = options.backoffMs ?? 50; + const attemptTimeoutMs = options.attemptTimeoutMs ?? 5_000; let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt++) { try { - await db.prepare(sql).bind(...bindings).run(); - return; + return await withAttemptTimeout( + db.prepare(sql).bind(...bindings).run(), + attemptTimeoutMs, + ); } catch (error) { lastError = error; if (attempt < attempts - 1) { @@ -237,6 +254,23 @@ export async function runSessionDirectoryUpdateWithRetry( } options.onFailure(lastError); + return undefined; +} + +async function withAttemptTimeout(operation: Promise, timeoutMs: number): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`Session directory update attempt timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + } } export function recentSessionsSelect(cutoffIso: string, limit: number): SqlStatement { diff --git a/packages/worker/src/worker-env.ts b/packages/worker/src/worker-env.ts index 3fd81d9..3545c26 100644 --- a/packages/worker/src/worker-env.ts +++ b/packages/worker/src/worker-env.ts @@ -1,21 +1,61 @@ +import { LLM_PROVIDER_CAPABILITIES, type WorkerProviderSecretName } from "@codevil/shared"; + import type { Orchestrator } from "./orchestrator.js"; -export interface Env { +/** Credentials that must never cross a Worker diagnostic boundary unredacted. */ +export type WorkerSecretEnv = Partial> & { + CODEVIL_API_KEY?: string; + CODEVIL_LLM_KEY?: string; + GITHUB_PAT?: string; + R2_ACCESS_KEY_ID?: string; + R2_SECRET_ACCESS_KEY?: string; + BETTER_AUTH_SECRET?: string; + GOOGLE_CLIENT_SECRET?: string; + CODEVIL_SETUP_TOKEN?: string; + RESEND_API_KEY?: string; + CODEVIL_PROXY_SIGNING_SECRET?: string; +}; + +/** + * The one credential inventory for every Worker-owned diagnostic sink. + * Keep this independent of feature-specific credential resolution so a new + * provider secret is automatically covered through its shared capability. + */ +export function collectWorkerSecretValues(env: WorkerSecretEnv): string[] { + return [...new Set([ + ...LLM_PROVIDER_CAPABILITIES.map((provider) => env[provider.secretName]), + env.CODEVIL_LLM_KEY, + env.CODEVIL_API_KEY, + env.GITHUB_PAT, + env.R2_ACCESS_KEY_ID, + env.R2_SECRET_ACCESS_KEY, + env.BETTER_AUTH_SECRET, + env.GOOGLE_CLIENT_SECRET, + env.CODEVIL_SETUP_TOKEN, + env.RESEND_API_KEY, + env.CODEVIL_PROXY_SIGNING_SECRET, + ].map(normalizeSecret).filter((secret): secret is string => secret !== undefined))]; +} + +function normalizeSecret(value: string | undefined): string | undefined { + const secret = value?.trim(); + return secret ? secret : undefined; +} + +export interface Env extends WorkerSecretEnv { ORCHESTRATOR: DurableObjectNamespace; Sandbox: DurableObjectNamespace; DB: D1Database; BACKUP_BUCKET?: R2Bucket; ASSETS: Fetcher; CODEVIL_API_KEY: string; + CODEVIL_PROXY_SIGNING_SECRET: string; CLOUDFLARE_ACCOUNT_ID?: string; + CLOUDFLARE_GATEWAY_ID?: string; CLOUDFLARE_R2_ACCOUNT_ID?: string; R2_ACCESS_KEY_ID?: string; R2_SECRET_ACCESS_KEY?: string; BACKUP_BUCKET_NAME?: string; - OPENCODE_API_KEY?: string; - OPENROUTER_API_KEY?: string; - OPENAI_API_KEY?: string; - CODEVIL_LLM_KEY?: string; BETTER_AUTH_URL?: string; BETTER_AUTH_SECRET?: string; GOOGLE_CLIENT_ID?: string; diff --git a/packages/worker/test/deployment-config.test.mjs b/packages/worker/test/deployment-config.test.mjs new file mode 100644 index 0000000..105a2c8 --- /dev/null +++ b/packages/worker/test/deployment-config.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; + +const here = dirname(fileURLToPath(import.meta.url)); +const workerRoot = resolve(here, ".."); +const repoRoot = resolve(workerRoot, "..", ".."); + +test("checked-in Worker config is portable and defaults to workers.dev", async () => { + const config = await readFile(resolve(workerRoot, "wrangler.toml"), "utf8"); + + assert.match(config, /^workers_dev = true$/m); + assert.doesNotMatch(config, /^account_id\s*=/m); + assert.doesNotMatch(config, /^database_id\s*=/m); + assert.doesNotMatch(config, /^\[\[routes\]\]/m); + assert.doesNotMatch(config, /lexmora\.app|pages\.dev/); + assert.match(config, /binding = "DB"/); + assert.match(config, /binding = "BACKUP_BUCKET"/); +}); + +test("operator config and local variables are templates, not deployment credentials", async () => { + const [ignore, operatorTemplate, varsTemplate] = await Promise.all([ + readFile(resolve(repoRoot, ".gitignore"), "utf8"), + readFile(resolve(workerRoot, "wrangler.operator.example.toml"), "utf8"), + readFile(resolve(workerRoot, ".dev.vars.example"), "utf8"), + ]); + + assert.match(ignore, /^wrangler\.operator\.toml$/m); + assert.match(operatorTemplate, /database_id = "your-d1-database-id"/); + assert.match(varsTemplate, /^CODEVIL_PROXY_SIGNING_SECRET=$/m); + for (const name of ["CODEVIL_API_KEY", "CODEVIL_SETUP_TOKEN", "CODEVIL_PROXY_SIGNING_SECRET", "BETTER_AUTH_SECRET", "GOOGLE_CLIENT_SECRET", "GITHUB_PAT"]) { + assert.match(varsTemplate, new RegExp(`^${name}=$`, "m")); + } +}); diff --git a/packages/worker/test/directory-update-queue.test.mjs b/packages/worker/test/directory-update-queue.test.mjs new file mode 100644 index 0000000..797eefe --- /dev/null +++ b/packages/worker/test/directory-update-queue.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DirectoryUpdateQueue } from "../dist/orchestrator/directory-update-queue.js"; + +function deferred() { + let resolve; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +test("DirectoryUpdateQueue preserves mutation order when an earlier write is delayed", async () => { + const queue = new DirectoryUpdateQueue(); + const first = deferred(); + const calls = []; + + const firstWrite = queue.enqueue(async () => { + calls.push("first:start"); + await first.promise; + calls.push("first:end"); + }); + const secondWrite = queue.enqueue(async () => { + calls.push("second"); + }); + + await Promise.resolve(); + assert.deepEqual(calls, ["first:start"]); + first.resolve(); + await Promise.all([firstWrite, secondWrite]); + assert.deepEqual(calls, ["first:start", "first:end", "second"]); +}); + +test("DirectoryUpdateQueue continues after a failed best-effort write", async () => { + const queue = new DirectoryUpdateQueue(); + const calls = []; + + await assert.rejects(queue.enqueue(async () => { + calls.push("failed"); + throw new Error("transient D1 failure"); + })); + await queue.enqueue(async () => { calls.push("recovered"); }); + + assert.deepEqual(calls, ["failed", "recovered"]); +}); + +test("DirectoryUpdateQueue coalesces a burst to the latest pending keyed update", async () => { + const queue = new DirectoryUpdateQueue({ maxPending: 1 }); + const gate = deferred(); + const calls = []; + + const active = queue.enqueue(async () => { + calls.push("active:start"); + await gate.promise; + calls.push("active:end"); + }); + + await Promise.resolve(); + const updates = Array.from({ length: 100 }, (_, index) => queue.enqueue(async () => { + calls.push(`update:${index}`); + }, { key: "session" })); + + // The active write is separate; all queued updates share one bounded slot. + assert.equal(queue.pendingCount, 1); + gate.resolve(); + await Promise.all([active, ...updates]); + await delay(0); + + assert.deepEqual(calls, ["active:start", "active:end", "update:99"]); +}); diff --git a/packages/worker/test/event-log-limits.test.mjs b/packages/worker/test/event-log-limits.test.mjs index 25f417d..bacafde 100644 --- a/packages/worker/test/event-log-limits.test.mjs +++ b/packages/worker/test/event-log-limits.test.mjs @@ -3,8 +3,14 @@ import test from "node:test"; import { capEventForStorage, + capSessionSnapshotForStorage, eventJsonByteLength, MAX_EVENT_JSON_BYTES, + MAX_EVENTS_BETWEEN_SNAPSHOTS, + MAX_PREVIEW_OUTPUT_LINE_CHARS, + MAX_SESSION_SNAPSHOT_BYTES, + prepareSnapshotCheckpoint, + shouldCompactEventTail, } from "../dist/orchestrator/event-log-limits.js"; test("capEventForStorage leaves small events unchanged", () => { @@ -14,6 +20,11 @@ test("capEventForStorage leaves small events unchanged", () => { assert.deepEqual(result.event, event); }); +test("shouldCompactEventTail caps a long uncheckpointed replay tail deterministically", () => { + assert.equal(shouldCompactEventTail(MAX_EVENTS_BETWEEN_SNAPSHOTS - 1), false); + assert.equal(shouldCompactEventTail(MAX_EVENTS_BETWEEN_SNAPSHOTS), true); +}); + test("capEventForStorage truncates oversized string fields", () => { const event = { type: "agent_event", @@ -43,3 +54,61 @@ test("capEventForStorage eventually fits within byte limit for huge payloads", ( assert.ok(eventJsonByteLength(JSON.stringify(result.event)) <= MAX_EVENT_JSON_BYTES); assert.equal(result.truncated, true); }); + +test("capSessionSnapshotForStorage keeps structural state and deterministically evicts oldest display history", () => { + const snapshot = { + cursor: 42, + sessionPhase: "executing", + planApproved: true, + participants: [{ id: "owner", name: "Owner" }], + preview: { + status: "ready", url: "https://preview.example", command: "pnpm dev", port: 3000, + error: null, apps: [], selectedAppKey: null, reloadRevision: 0, + outputLines: ["output".repeat(MAX_PREVIEW_OUTPUT_LINE_CHARS)], + }, + planRevision: { runId: "run_1", round: 1, markdown: "# Plan", locked: true, createdAt: null, revisionId: null }, + annotations: [], + questions: [], + selectedAnnotationId: null, + messages: Array.from({ length: 60 }, (_unused, index) => ({ + id: `message_${index}`, role: "assistant", variant: "text", content: String(index).repeat(12_000), timestamp: index, + })), + activityLog: Array.from({ length: 60 }, (_unused, index) => ({ + id: `activity_${index}`, kind: "event", status: "success", timestamp: index, + event: { label: "status", detail: String(index).repeat(12_000) }, + })), + }; + + const capped = capSessionSnapshotForStorage(snapshot); + + assert.ok(eventJsonByteLength(JSON.stringify(capped)) <= MAX_SESSION_SNAPSHOT_BYTES); + assert.equal(capped.cursor, 42); + assert.equal(capped.sessionPhase, "executing"); + assert.deepEqual(capped.participants, snapshot.participants); + assert.deepEqual(capped.planRevision, snapshot.planRevision); + assert.ok(capped.preview.outputLines[0].length < snapshot.preview.outputLines[0].length); + assert.ok(capped.messages.length < snapshot.messages.length); + assert.equal(capped.messages.at(-1)?.id, "message_59"); + assert.equal(capped.activityLog.at(-1)?.id, "activity_59"); +}); + +test("prepareSnapshotCheckpoint refuses an oversized structural snapshot without truncating it", () => { + const snapshot = { + cursor: 42, + sessionPhase: "executing", + planApproved: true, + participants: [], + preview: { status: "idle", url: null, command: null, port: null, error: null, apps: [], selectedAppKey: null, reloadRevision: 0, outputLines: [] }, + planRevision: { runId: "run_1", round: 1, markdown: "x".repeat(MAX_SESSION_SNAPSHOT_BYTES), locked: true, createdAt: null, revisionId: null }, + annotations: [], + questions: [], + selectedAnnotationId: null, + messages: [], + activityLog: [], + }; + + const result = prepareSnapshotCheckpoint(snapshot); + + assert.equal(result.canPersist, false); + assert.equal(result.snapshot.planRevision.markdown, snapshot.planRevision.markdown); +}); diff --git a/packages/worker/test/event-log-persistence.test.mjs b/packages/worker/test/event-log-persistence.test.mjs new file mode 100644 index 0000000..1f44a56 --- /dev/null +++ b/packages/worker/test/event-log-persistence.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SessionEventLog } from "../dist/orchestrator/event-log.js"; + +function rows(items) { + return Object.assign(items, { + one() { return items[0]; }, + toArray() { return items; }, + }); +} + +function createEventSql() { + const events = []; + let snapshot; + const calls = []; + return { + events, + calls, + get snapshot() { return snapshot; }, + exec(query, ...params) { + calls.push({ query, params }); + if (query.startsWith("INSERT INTO events")) { + events.push({ id: events.length + 1, event_json: params[0] }); + return rows([]); + } + if (query.includes("SELECT id FROM events ORDER BY id DESC")) return rows([{ id: events.at(-1).id }]); + if (query.includes("SELECT COUNT(*) AS count FROM events")) { + return rows([{ count: events.filter((event) => event.id > (params[0] ?? 0)).length }]); + } + if (query.startsWith("INSERT OR REPLACE INTO snapshots")) { + snapshot = { cursor: params[1], state_json: params[2] }; + return rows([]); + } + if (query.includes("SELECT cursor, state_json FROM snapshots")) return rows(snapshot ? [snapshot] : []); + if (query.includes("SELECT id, event_json FROM events WHERE id >")) { + return rows(events.filter((event) => event.id > params[0])); + } + return rows([]); + }, + }; +} + +function createLog(sql) { + return new SessionEventLog(sql, () => [], () => {}, [], () => null, new Set()); +} + +test("checkpointed display history keeps the canonical event history for hydrate and tail replay", () => { + const sql = createEventSql(); + const log = createLog(sql); + + for (let index = 0; index < 1_001; index++) { + log.appendAndBroadcast({ type: "status", message: `event ${index}` }); + } + + assert.equal(log.getSnapshotCursor(), 1_001); + assert.equal(sql.snapshot.cursor, 1_000, "the automatic checkpoint precedes the uncheckpointed tail"); + assert.equal(sql.events.length, 1_001, "checkpointing must not erase canonical history"); + + const hydrated = createLog(sql); + hydrated.hydrateFromSql(); + assert.equal(hydrated.getSnapshotCursor(), 1_000); + + const sent = []; + hydrated.replayEvents({ send: (frame) => sent.push(JSON.parse(frame)) }, 1_000); + assert.deepEqual(sent[0].events.map((event) => event.cursor), [1_001]); +}); + +test("oversized structural state does not advance a checkpoint or delete its replay tail", () => { + const sql = createEventSql(); + const log = createLog(sql); + log.appendAndBroadcast({ type: "status", message: "still replayable" }); + + log.snapshot = { + ...log.snapshot, + cursor: 1, + planRevision: { + runId: "run_1", round: 1, markdown: "x".repeat(1024 * 1024), locked: true, createdAt: null, revisionId: null, + }, + }; + log.snapshotCursor = 1; + log.persistSnapshot(); + + assert.equal(sql.snapshot, undefined); + assert.equal(sql.events.length, 1); + assert.equal(log.getSnapshotCursor(), 0, "reconnect falls back to the prior durable checkpoint"); +}); diff --git a/packages/worker/test/github.test.mjs b/packages/worker/test/github.test.mjs index 8d3e8b1..046376e 100644 --- a/packages/worker/test/github.test.mjs +++ b/packages/worker/test/github.test.mjs @@ -3,7 +3,6 @@ import test from "node:test"; import { buildCreatePullRequestRequest, - credentialRequestAllowed, createDraftPullRequest, parseGitHubRepo, } from "../dist/github.js"; @@ -16,26 +15,6 @@ test("parseGitHubRepo extracts owner and repo from https URLs", () => { }); }); -test("credentialRequestAllowed accepts only the configured GitHub repo", () => { - const sessionRepo = "https://github.com/acme/private-app.git"; - - assert.equal(credentialRequestAllowed(sessionRepo, { - type: "credential_request", - request_id: "cred_1", - protocol: "https", - host: "github.com", - path: "acme/private-app.git", - }), true); - - assert.equal(credentialRequestAllowed(sessionRepo, { - type: "credential_request", - request_id: "cred_2", - protocol: "https", - host: "github.com", - path: "other/private-app.git", - }), false); -}); - test("buildCreatePullRequestRequest creates a GitHub API draft PR request", () => { assert.deepEqual(buildCreatePullRequestRequest({ repo: "https://github.com/acme/private-app.git", diff --git a/packages/worker/test/http-edge.test.mjs b/packages/worker/test/http-edge.test.mjs index 97562b9..be4ae5f 100644 --- a/packages/worker/test/http-edge.test.mjs +++ b/packages/worker/test/http-edge.test.mjs @@ -33,6 +33,7 @@ test("dispatchHttpRequest routes GET /health before auth", async () => { test("handleReady returns 200 when all checks pass", async () => { const env = { CODEVIL_API_KEY: "test-api-key", + CODEVIL_PROXY_SIGNING_SECRET: "test-proxy-signing-secret", BETTER_AUTH_SECRET: "secret", GOOGLE_CLIENT_ID: "client-id", GOOGLE_CLIENT_SECRET: "client-secret", @@ -47,13 +48,14 @@ test("handleReady returns 200 when all checks pass", async () => { assert.equal(response.status, 200); assert.deepEqual(await response.json(), { ok: true, - checks: { d1: true, auth_config: true, api_key: true }, + checks: { d1: true, auth_config: true, api_key: true, proxy_signing_secret: true }, }); }); test("handleReady returns 503 when D1 is unreachable", async () => { const env = { CODEVIL_API_KEY: "test-api-key", + CODEVIL_PROXY_SIGNING_SECRET: "test-proxy-signing-secret", BETTER_AUTH_SECRET: "secret", GOOGLE_CLIENT_ID: "client-id", GOOGLE_CLIENT_SECRET: "client-secret", @@ -70,13 +72,14 @@ test("handleReady returns 503 when D1 is unreachable", async () => { assert.equal(response.status, 503); assert.deepEqual(await response.json(), { ok: false, - checks: { d1: false, auth_config: true, api_key: true }, + checks: { d1: false, auth_config: true, api_key: true, proxy_signing_secret: true }, }); }); test("dispatchHttpRequest routes GET /ready with failing D1", async () => { const env = { CODEVIL_API_KEY: "test-api-key", + CODEVIL_PROXY_SIGNING_SECRET: "test-proxy-signing-secret", BETTER_AUTH_SECRET: "secret", GOOGLE_CLIENT_ID: "client-id", GOOGLE_CLIENT_SECRET: "client-secret", @@ -98,10 +101,31 @@ test("dispatchHttpRequest routes GET /ready with failing D1", async () => { assert.equal(response.status, 503); assert.deepEqual(await response.json(), { ok: false, - checks: { d1: false, auth_config: true, api_key: true }, + checks: { d1: false, auth_config: true, api_key: true, proxy_signing_secret: true }, }); }); +test("handleReady rejects a missing or blank proxy signing secret without exposing it", async () => { + const baseEnv = { + CODEVIL_API_KEY: "test-api-key", + BETTER_AUTH_SECRET: "secret", + GOOGLE_CLIENT_ID: "client-id", + GOOGLE_CLIENT_SECRET: "client-secret", + DB: { prepare: () => ({ first: async () => ({ "1": 1 }) }) }, + }; + + for (const secret of [undefined, " "]) { + const response = await handleReady({ ...baseEnv, CODEVIL_PROXY_SIGNING_SECRET: secret }); + assert.equal(response.status, 503); + const payload = await response.json(); + assert.deepEqual(payload, { + ok: false, + checks: { d1: true, auth_config: true, api_key: true, proxy_signing_secret: false }, + }); + assert.doesNotMatch(JSON.stringify(payload), /test-api-key|client-secret/); + } +}); + test("checkD1Reachable returns false when query fails", async () => { const db = { prepare: () => ({ @@ -131,6 +155,7 @@ test("observeRoutedResponse passes WebSocket upgrade responses through unchanged method: "GET", path: "/sessions/ses_abc/ws", startedAt: Date.now(), + secrets: [], }); assert.equal(result, upgrade); @@ -144,6 +169,7 @@ test("observeRoutedResponse attaches x-request-id to plain API responses", () => method: "GET", path: "/sessions", startedAt: Date.now(), + secrets: [], }); assert.equal(result.status, 200); @@ -157,6 +183,7 @@ test("handleUncaughtHttpError returns generic 500 without leaking error message" path: "/sessions", startedAt: Date.now(), withCors: (innerResponse) => innerResponse, + secrets: [], }); assert.equal(response.status, 500); diff --git a/packages/worker/test/http-router.test.mjs b/packages/worker/test/http-router.test.mjs index 7ee79b5..e1ff312 100644 --- a/packages/worker/test/http-router.test.mjs +++ b/packages/worker/test/http-router.test.mjs @@ -57,3 +57,47 @@ test("dispatchHttpRequest ignores preview-looking referers from another origin", assert.equal(response, null); }); + +test("dispatchHttpRequest forwards sandbox WebSocket capabilities without the deployment API key", async () => { + let forwarded = null; + const env = { + ORCHESTRATOR: { + idFromName: (name) => `do:${name}`, + get: (id) => ({ + fetch: (request) => { + forwarded = { id, url: request.url, upgrade: request.headers.get("Upgrade") }; + return new Response("forwarded"); + }, + }), + }, + }; + const request = new Request( + "https://codevil.example.workers.dev/sessions/ses_abc/sandbox/ws?sandbox_ws_token=session-bound-capability", + { headers: { Upgrade: "websocket" } }, + ); + + const response = await dispatchHttpRequest(request, env, { + withCors: (_request, _env, innerResponse) => innerResponse, + }); + + assert.equal(await response.text(), "forwarded"); + assert.deepEqual(forwarded, { + id: "do:ses_abc", + url: request.url, + upgrade: "websocket", + }); +}); + +test("dispatchHttpRequest still requires the deployment API key for logs", async () => { + const env = { + CODEVIL_API_KEY: "deployment-key", + ORCHESTRATOR: {}, + }; + const request = new Request("https://codevil.example.workers.dev/sessions/ses_abc/logs"); + + const response = await dispatchHttpRequest(request, env, { + withCors: (_request, _env, innerResponse) => innerResponse, + }); + + assert.equal(response.status, 401); +}); diff --git a/packages/worker/test/logging-redaction.test.mjs b/packages/worker/test/logging-redaction.test.mjs new file mode 100644 index 0000000..3666329 --- /dev/null +++ b/packages/worker/test/logging-redaction.test.mjs @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { setTracerSink } from "@codevil/shared"; + +import { + workerLog, + workerLogException, + sandboxLifecycleLogger, +} from "../dist/logging.js"; +import { handleCreateSession } from "../dist/http-handlers.js"; +import { redactSandboxDiagnosticResponse } from "../dist/http-handlers.js"; + +test("worker logging redacts configured secrets from exceptions and nested sandbox output", () => { + const lines = []; + setTracerSink((line) => lines.push(line)); + try { + const secrets = [" short-secret "]; + workerLogException("request.failed", new Error("provider rejected short-secret"), { + sandbox: { + stdout: "token=short-secret", + stderr: "short-secret appeared in stderr", + }, + }, secrets); + workerLog("ERROR", "sandbox.logs", { + diagnostics: { nested: ["short-secret"] }, + }, secrets); + } finally { + setTracerSink(() => {}); + } + + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, /short-secret/); + assert.match(serialized, /\[REDACTED\]/); + assert.match(serialized, /provider rejected/); +}); + +test("worker logging accepts an explicit empty secret context", () => { + const lines = []; + setTracerSink((line) => lines.push(line)); + try { + workerLog("ERROR", "sandbox.logs", { api_key: "pre-init-secret" }, []); + } finally { + setTracerSink(() => {}); + } + + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, /pre-init-secret/); + assert.match(serialized, /\[REDACTED\]/); +}); + +test("sandbox lifecycle diagnostics use their explicit deployment secret set", () => { + const lines = []; + setTracerSink((line) => lines.push(line)); + try { + sandboxLifecycleLogger(["sandbox-diagnostic-secret"], "session_123").log( + "ERROR", + "sandbox.lifecycle", + { sandbox: { stderr: "failed with sandbox-diagnostic-secret" } }, + ); + } finally { + setTracerSink(() => {}); + } + + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, /sandbox-diagnostic-secret/); + assert.match(serialized, /\[REDACTED\]/); +}); + +test("session initialization failure logging redacts Worker deployment secrets", async () => { + const lines = []; + setTracerSink((line) => lines.push(line)); + try { + const env = { + CODEVIL_API_KEY: "worker-deployment-secret", + DB: { + prepare: () => ({ + bind: () => ({ + run: async () => ({}), + }), + }), + batch: async () => ({}), + }, + ORCHESTRATOR: { + idFromName: (name) => name, + get: () => ({ + init: async () => { + throw new Error("initialization failed with worker-deployment-secret"); + }, + }), + }, + }; + const response = await handleCreateSession( + new Request("https://codevil.example/sessions", { + method: "POST", + body: JSON.stringify({ repo: "github.com/acme/app" }), + }), + env, + { userId: "usr_123", email: "owner@example.com", name: "Owner", role: "owner" }, + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "Failed to initialize session" }); + } finally { + setTracerSink(() => {}); + } + + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, /worker-deployment-secret/); + assert.match(serialized, /\[REDACTED\]/); + assert.match(serialized, /session\.init\.failed/); +}); + +test("final logging boundary survives throwing Error getters and redacts Error causes", () => { + const secret = "boundary-error-secret"; + const cause = new Error(`upstream included ${secret}`); + cause.detail = `nested ${secret}`; + const error = new Error(`request included ${secret}`, { cause }); + Object.defineProperty(error, "diagnostic", { + enumerable: true, + get() { + throw new Error("must not read diagnostic getter"); + }, + }); + + const lines = []; + setTracerSink((line) => lines.push(line)); + try { + assert.doesNotThrow(() => workerLog("ERROR", "request.failed", { error }, [secret])); + } finally { + setTracerSink(() => {}); + } + + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, /boundary-error-secret/); + assert.match(serialized, /request\.failed/); +}); + +test("final logging boundary never coerces hostile Error fields", () => { + const error = new Error("safe"); + Object.defineProperty(error, "message", { value: { toString() { throw new Error("must not coerce message"); } } }); + Object.defineProperty(error, "name", { value: { toString() { throw new Error("must not coerce name"); } } }); + Object.defineProperty(error, "stack", { value: { toString() { throw new Error("must not coerce stack"); } } }); + + assert.doesNotThrow(() => workerLog("ERROR", "hostile.error", { error }, [])); +}); + +test("sandbox diagnostics responses redact deployment secrets and tolerate hostile errors", () => { + const secret = "diagnostics-response-secret"; + const hostile = {}; + Object.defineProperty(hostile, "message", { + get() { + throw new Error("must not read hostile error"); + }, + }); + + const response = redactSandboxDiagnosticResponse({ + logs: { stdout: `stdout ${secret}`, stderr: `stderr ${secret}` }, + lifecycle: { lastEvent: { type: "error", at: "2026-07-10", error: secret } }, + errors: { logs: hostile }, + }, { CODEVIL_API_KEY: secret }); + + const serialized = JSON.stringify(response); + assert.doesNotMatch(serialized, new RegExp(secret)); + assert.match(serialized, /\[REDACTED\]/); +}); diff --git a/packages/worker/test/memberships.test.mjs b/packages/worker/test/memberships.test.mjs index e0ea123..1588f59 100644 --- a/packages/worker/test/memberships.test.mjs +++ b/packages/worker/test/memberships.test.mjs @@ -1,5 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { DatabaseSync } from "node:sqlite"; import { activeMembershipByUserSelect, @@ -30,19 +31,47 @@ test("activeMembershipByUserSelect fetches one active membership", () => { assert.deepEqual(query.bindings, ["usr_123", "active"]); }); -test("createOwnerMembershipInsert creates an active owner membership", () => { +test("createOwnerMembershipInsert atomically creates the first active owner only", () => { const query = createOwnerMembershipInsert("usr_123", "2026-06-11T00:00:00.000Z"); assert.match(query.sql, /^INSERT INTO memberships/i); + assert.match(query.sql, /SELECT \?, \?, \?, \?, \?/i); + assert.match(query.sql, /WHERE NOT EXISTS/i); assert.deepEqual(query.bindings, [ "usr_123", "owner", "active", "2026-06-11T00:00:00.000Z", "2026-06-11T00:00:00.000Z", + "owner", + "active", ]); }); +test("racing setup claims deterministically promote one owner", () => { + const db = new DatabaseSync(":memory:"); + db.exec(`CREATE TABLE memberships ( + user_id TEXT PRIMARY KEY, + role TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); + + const first = createOwnerMembershipInsert("usr_first", "2026-06-11T00:00:00.000Z"); + const second = createOwnerMembershipInsert("usr_second", "2026-06-11T00:00:00.001Z"); + const firstResult = db.prepare(first.sql).run(...first.bindings); + const secondResult = db.prepare(second.sql).run(...second.bindings); + + assert.equal(firstResult.changes, 1); + assert.equal(secondResult.changes, 0); + assert.deepEqual( + Array.from(db.prepare("SELECT user_id, role, status FROM memberships").all(), (row) => ({ ...row })), + [{ user_id: "usr_first", role: "owner", status: "active" }], + ); + db.close(); +}); + test("pendingInviteByEmailSelect finds active pending invites by normalized email", () => { const query = pendingInviteByEmailSelect("Alice@Example.COM", "2026-06-11T00:00:00.000Z"); diff --git a/packages/worker/test/provider-credentials.test.mjs b/packages/worker/test/provider-credentials.test.mjs index 712f52a..2a3eb60 100644 --- a/packages/worker/test/provider-credentials.test.mjs +++ b/packages/worker/test/provider-credentials.test.mjs @@ -1,12 +1,45 @@ import assert from "node:assert/strict"; import test from "node:test"; +import { LLM_PROVIDER_CAPABILITIES } from "@codevil/shared"; + import { collectProviderCredentialSecrets, getProvisioningCredentialContext, + requireProviderPublicConfig, requireProviderCredential, resolveProviderCredential, } from "../dist/provider-credentials.js"; +import { redactEvent } from "../dist/redaction.js"; +import { collectWorkerSecretValues } from "../dist/worker-env.js"; + +test("collectWorkerSecretValues includes every deployment credential used by worker diagnostics", () => { + const secrets = collectWorkerSecretValues({ + OPENAI_API_KEY: "provider-secret", + CODEVIL_LLM_KEY: "legacy-secret", + CODEVIL_API_KEY: "control-secret", + GITHUB_PAT: "github-secret", + R2_ACCESS_KEY_ID: "r2-access-secret", + R2_SECRET_ACCESS_KEY: "r2-secret", + BETTER_AUTH_SECRET: "auth-secret", + GOOGLE_CLIENT_SECRET: "google-secret", + CODEVIL_SETUP_TOKEN: "setup-secret", + RESEND_API_KEY: "resend-secret", + }); + + assert.deepEqual(secrets, [ + "provider-secret", + "legacy-secret", + "control-secret", + "github-secret", + "r2-access-secret", + "r2-secret", + "auth-secret", + "google-secret", + "setup-secret", + "resend-secret", + ]); +}); test("resolveProviderCredential uses dedicated credentials for canonical providers and alias", () => { const env = { @@ -22,6 +55,62 @@ test("resolveProviderCredential uses dedicated credentials for canonical provide assert.equal(resolveProviderCredential(env, "openai"), "openai-key"); }); +test("resolveProviderCredential returns the normalized configured credential", () => { + assert.equal( + resolveProviderCredential({ OPENAI_API_KEY: " padded-provider-key " }, "openai"), + "padded-provider-key", + ); +}); + +test("resolveProviderCredential uses every declared provider secret, including shared secrets", () => { + assert.equal( + resolveProviderCredential({ ANTHROPIC_API_KEY: "anthropic-key" }, "anthropic"), + "anthropic-key", + ); + assert.equal( + resolveProviderCredential({ MOONSHOT_API_KEY: "moonshot-key" }, "moonshotai-cn"), + "moonshot-key", + ); + assert.equal( + resolveProviderCredential({ CLOUDFLARE_API_KEY: "cloudflare-key" }, "cloudflare-ai-gateway"), + "cloudflare-key", + ); +}); + +test("Cloudflare provider provisioning passes only declared public configuration", () => { + const env = { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + ARBITRARY_SANDBOX_ENV: "must-not-cross-boundary", + }; + + assert.deepEqual( + requireProviderPublicConfig(env, "cloudflare-workers-ai"), + { CLOUDFLARE_ACCOUNT_ID: "account_123" }, + ); + assert.deepEqual( + requireProviderPublicConfig(env, "cloudflare-ai-gateway"), + { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + }, + ); +}); + +test("Cloudflare provider provisioning fails closed when required public configuration is blank or absent", () => { + assert.throws( + () => requireProviderPublicConfig({ CLOUDFLARE_ACCOUNT_ID: " " }, "cloudflare-workers-ai"), + /Cloudflare Workers AI is missing required configuration: CLOUDFLARE_ACCOUNT_ID/, + ); + assert.throws( + () => requireProviderPublicConfig( + { CLOUDFLARE_ACCOUNT_ID: "account_123" }, + "cloudflare-ai-gateway", + ), + /Cloudflare AI Gateway is missing required configuration: CLOUDFLARE_GATEWAY_ID/, + ); +}); + test("resolveProviderCredential falls back to legacy key when dedicated provider key is missing or blank", () => { const env = { OPENCODE_API_KEY: "", @@ -117,7 +206,7 @@ test("getProvisioningCredentialContext throws before any provisioning context ex (error) => { assert.equal( error.message, - "OpenAI Platform is not configured. Run `pnpm providers` on the Codevil host.", + "OpenAI is not configured. Run `pnpm providers` on the Codevil host.", ); return true; }, @@ -145,3 +234,17 @@ test("collectProviderCredentialSecrets includes dedicated and legacy values with ["legacy-key"], ); }); + +test("collectProviderCredentialSecrets includes every capability secret so configured credentials are redacted", () => { + const secretNames = [...new Set(LLM_PROVIDER_CAPABILITIES.map((provider) => provider.secretName))]; + const env = Object.fromEntries( + secretNames.map((secretName) => [secretName, `credential-for-${secretName}`]), + ); + + const secrets = secretNames.map((secretName) => `credential-for-${secretName}`); + assert.deepEqual(collectProviderCredentialSecrets(env), secrets); + assert.equal( + redactEvent({ error: secrets.join(",") }, secrets).error, + secrets.map(() => "[REDACTED]").join(","), + ); +}); diff --git a/packages/worker/test/provisioning-redaction.test.mjs b/packages/worker/test/provisioning-redaction.test.mjs index 8542933..c894a0b 100644 --- a/packages/worker/test/provisioning-redaction.test.mjs +++ b/packages/worker/test/provisioning-redaction.test.mjs @@ -70,3 +70,44 @@ test("provisioning telemetry redacts provider credentials from errors without hi true, ); }); + +test("provisioning telemetry survives hostile exception accessors", async () => { + const [{ traceSandboxProvisioning }, { createTracer }] = await Promise.all([ + import("../dist/orchestrator.js"), + import("@codevil/shared"), + ]); + const emitted = []; + const tracer = createTracer({ + component: "orchestrator", + trace_id: "0123456789abcdef0123456789abcdef", + sink: (event) => emitted.push(event), + }); + const error = {}; + for (const key of ["name", "message", "stack", "details"]) { + Object.defineProperty(error, key, { + enumerable: true, + get() { + throw new Error(`hostile ${key} getter`); + }, + }); + } + + await assert.rejects( + () => traceSandboxProvisioning({ + tracer, + secrets: [], + attributes: { provider: "openai" }, + provision: async () => { + throw error; + }, + }), + (caught) => { + assert.equal(caught.message, "[UNAVAILABLE]"); + return true; + }, + ); + + const serialized = JSON.stringify(emitted); + assert.doesNotMatch(serialized, /hostile .* getter/); + assert.match(serialized, /\[UNAVAILABLE\]/); +}); diff --git a/packages/worker/test/redaction.test.mjs b/packages/worker/test/redaction.test.mjs index 19c8d65..0cc2c58 100644 --- a/packages/worker/test/redaction.test.mjs +++ b/packages/worker/test/redaction.test.mjs @@ -39,3 +39,60 @@ test("redacts common token patterns without mutating original event", () => { message: "ANTHROPIC_API_KEY=[REDACTED] and [REDACTED]", }); }); + +test("redacts explicitly configured short and whitespace-padded secrets", () => { + const configuredSecret = " key "; + + const redacted = redactEvent({ + nested: { + stdout: "sandbox printed key", + stderr: "failed with key", + }, + }, [configuredSecret]); + + assert.deepEqual(redacted, { + nested: { + stdout: "sandbox printed [REDACTED]", + stderr: "failed with [REDACTED]", + }, + }); +}); + +test("redacts cyclic diagnostic objects without throwing or retaining secrets", () => { + const diagnostic = { message: "sandbox returned cycle-secret" }; + diagnostic.self = diagnostic; + const error = new Error("cycle-secret in error"); + error.diagnostic = diagnostic; + + const redacted = redactEvent({ diagnostic, error }, ["cycle-secret"]); + + assert.equal(redacted.diagnostic.message, "sandbox returned [REDACTED]"); + assert.equal(redacted.diagnostic.self, redacted.diagnostic); + assert.equal(redacted.error.message, "[REDACTED] in error"); + assert.equal(redacted.error.diagnostic.self, redacted.error.diagnostic); +}); + +test("redacts Error causes and custom properties without invoking throwing diagnostic getters", () => { + const secret = "error-boundary-secret"; + const cause = new Error(`upstream rejected ${secret}`); + cause.context = { detail: `provider returned ${secret}` }; + const error = new Error(`request failed with ${secret}`, { cause }); + error.provider_context = { stdout: `sandbox printed ${secret}` }; + Object.defineProperty(error, "diagnostic", { + enumerable: true, + get() { + throw new Error("diagnostic getter must not run"); + }, + }); + + let redacted; + assert.doesNotThrow(() => { + redacted = redactEvent({ error }, [secret]); + }); + + assert.equal(redacted.error.message, "request failed with [REDACTED]"); + assert.equal(redacted.error.cause.message, "upstream rejected [REDACTED]"); + assert.equal(redacted.error.cause.context.detail, "provider returned [REDACTED]"); + assert.equal(redacted.error.provider_context.stdout, "sandbox printed [REDACTED]"); + assert.equal(redacted.error.diagnostic, "[UNAVAILABLE]"); +}); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index a8a2d4f..db4d3f8 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -7,8 +7,10 @@ import { handleSandboxCloneComplete, handleSandboxCloneStarted, handleSandboxPlanReady, + issueProxyCapabilities, provisionSessionSandbox, } from "../dist/orchestrator/sandbox-handlers.js"; +import { handleSandboxProxy } from "../dist/sandbox-proxy.js"; import { actor, createFakeHost, createFakeTracer } from "./helpers/fake-host.mjs"; function createWsRecorder() { @@ -54,6 +56,73 @@ test("dispatchSandboxSocketMessage routes valid clone_started to its handler", a assert.deepEqual(directoryPatches.at(-1), { sandbox_state: "cloning" }); }); +test("authenticated sandbox capability refresh returns new session-bound short-lived tokens", async () => { + const { host } = createFakeHost({}, { + workerEnv: { CODEVIL_PROXY_SIGNING_SECRET: "test-signing-secret", CODEVIL_API_KEY: "test-key", Sandbox: {}, DB: {} }, + }); + const { ws, sent } = createWsRecorder(); + + await dispatchSandboxSocketMessage(host, ws, JSON.stringify({ type: "proxy_capabilities_refresh_request" })); + + assert.equal(sent.length, 1); + assert.equal(sent[0].type, "proxy_capabilities"); + assert.match(sent[0].tokens["openai-responses"], /^v1\.[^.]+\.[^.]+$/); + assert.match(sent[0].sandbox_ws_token, /^sws1\.[^.]+\.[^.]+$/); + assert.ok((await issueProxyCapabilities(host))["openai-responses"]); +}); + +test("proxy capability refresh normalizes an HTTPS primary repository without .git", async () => { + const { host } = createFakeHost({ repo: "https://github.com/acme/app" }, { + workerEnv: { + CODEVIL_PROXY_SIGNING_SECRET: "test-signing-secret", + CODEVIL_API_KEY: "test-key", + GITHUB_PAT: "github-pat", + Sandbox: {}, + DB: {}, + }, + }); + const { ws, sent } = createWsRecorder(); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: String(input), init }); + return new Response("ok"); + }; + try { + await dispatchSandboxSocketMessage(host, ws, JSON.stringify({ type: "proxy_capabilities_refresh_request" })); + + assert.equal(sent[0].type, "proxy_capabilities"); + const capability = sent[0].tokens.git; + assert.match(capability, /^git1\.[^.]+\.[^.]+$/); + const authorization = `Basic ${Buffer.from(`x-access-token:${capability}`).toString("base64")}`; + const response = await handleSandboxProxy(new Request( + "https://worker.test/sandbox-proxy/sessions/ses_test/github/acme/app.git/info/refs?service=git-upload-pack", + { headers: { authorization } }, + ), host.workerEnv); + + assert.equal(response.status, 200); + assert.equal(calls[0].url, "https://github.com/acme/app.git/info/refs?service=git-upload-pack"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("proxy capabilities reject unsafe primary repository forms", async () => { + for (const repo of [ + "https://user@github.com/acme/app", + "https://github.com/acme/app?ref=main", + "https://github.com/acme/app#readme", + "https://github.com/acme/app/extra", + "https://gitlab.com/acme/app", + "https://github.com/acme/../private", + ]) { + const { host } = createFakeHost({ repo }, { + workerEnv: { CODEVIL_PROXY_SIGNING_SECRET: "test-signing-secret", CODEVIL_API_KEY: "test-key", Sandbox: {}, DB: {} }, + }); + await assert.rejects(() => issueProxyCapabilities(host), /Session repository must be a github\.com HTTPS repository/, repo); + } +}); + test("handleSandboxCloneStarted transitions from provisioning_sandbox to cloning_repo", () => { const { host, transitions, directoryPatches } = createFakeHost({ state: "provisioning_sandbox" }); diff --git a/packages/worker/test/sandbox-proxy.test.mjs b/packages/worker/test/sandbox-proxy.test.mjs new file mode 100644 index 0000000..0dc04bf --- /dev/null +++ b/packages/worker/test/sandbox-proxy.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createSandboxGitProxyToken, createSandboxProxyToken, handleSandboxProxy } from "../dist/sandbox-proxy.js"; + +const secret = "proxy-signing-secret"; +const env = { + CODEVIL_PROXY_SIGNING_SECRET: secret, + OPENAI_API_KEY: "real-provider-key", + GITHUB_PAT: "real-github-pat", +}; + +test("LLM proxy rejects invalid provider target before any upstream request", async () => { + const token = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }); + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/openai/openai-responses/responses", { + method: "POST", headers: { authorization: `Bearer ${token}`, "x-codevil-proxy-target": "https://evil.test" }, + }), env); + assert.equal(response.status, 403); +}); + +test("expired and provider-mismatched capabilities are rejected", async () => { + const expired = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }, 0); + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/anthropic/anthropic-messages/messages", { + method: "POST", headers: { "x-api-key": expired, "x-codevil-proxy-target": "https://api.anthropic.com" }, + }), env); + assert.equal(response.status, 401); +}); + +test("LLM proxy preserves the provider base path, strips capability headers, and attaches only the provider key", async () => { + const token = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: String(input), init }); + return new Response("ok"); + }; + try { + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/openai/openai-responses/responses?stream=true", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "x-codevil-proxy-target": "https://api.openai.com/v1", + "x-codevil-internal": "kept", + "x-codevil-debug": "must-not-reach-provider", + }, + body: "{}", + }), env); + assert.equal(response.status, 200); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.openai.com/v1/responses?stream=true"); + assert.equal(calls[0].init.headers.get("authorization"), "Bearer real-provider-key"); + assert.equal(calls[0].init.headers.get("x-codevil-proxy-target"), null); + assert.equal(calls[0].init.headers.get("x-codevil-proxy-token"), null); + assert.equal(calls[0].init.headers.get("x-codevil-internal"), null); + assert.equal(calls[0].init.headers.get("x-codevil-debug"), null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("proxy token parser rejects capabilities with missing or extra JWT-like segments", async () => { + const token = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }); + for (const malformed of [token.split(".").slice(1).join("."), `${token}.extra`]) { + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/openai/openai-responses/responses", { + method: "POST", + headers: { authorization: `Bearer ${malformed}`, "x-codevil-proxy-target": "https://api.openai.com/v1" }, + }), env); + assert.equal(response.status, 401); + } +}); + +test("malicious suffixes and target authorities never receive the provider key", async () => { + const token = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (input, init) => { + calls.push({ url: String(input), init }); + return new Response("unexpected"); + }; + try { + for (const suffix of ["//evil.test", "/https://evil.test", "/%2F%2Fevil.test", "/\\\\evil.test", "/../evil.test"]) { + const response = await handleSandboxProxy(new Request(`https://worker.test/sandbox-proxy/sessions/ses_s1/llm/openai/openai-responses${suffix}`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "x-codevil-proxy-target": "https://api.openai.com/v1" }, + }), env); + // URL parsing may normalize dot segments before routing; either rejection + // is acceptable, but no malformed path may reach the credentialed fetch. + assert.ok([400, 404].includes(response.status), `${suffix}: ${response.status}`); + } + for (const target of ["http://api.openai.com/v1", "https://user@api.openai.com/v1", "https://api.openai.com:444/v1", "https://api.openai.com/v1?x=1"]) { + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/openai/openai-responses/responses", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "x-codevil-proxy-target": target }, + }), env); + assert.equal(response.status, 403, target); + } + assert.equal(calls.length, 0); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("capabilities are bound to their signed session and Pi API route", async () => { + const token = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }); + for (const path of [ + "/sandbox-proxy/sessions/ses_s2/llm/openai/openai-responses/responses", + "/sandbox-proxy/sessions/ses_s1/llm/openai/openai-completions/chat/completions", + ]) { + const response = await handleSandboxProxy(new Request(`https://worker.test${path}`, { + method: "POST", + headers: { authorization: `Bearer ${token}`, "x-codevil-proxy-target": "https://api.openai.com/v1" }, + }), env); + assert.equal(response.status, 401, path); + } +}); + +test("Git proxy permits PAT-authenticated reads of any repository without exposing the PAT", async () => { + const token = await createSandboxGitProxyToken(secret, { sessionId: "ses_s1", primaryRepo: "primary/app" }); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (input, init) => { calls.push({ url: String(input), init }); return new Response("pack"); }; + try { + const cap = Buffer.from(`x-access-token:${token}`).toString("base64"); + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/github/other/private.git/info/refs?service=git-upload-pack", { + headers: { authorization: `Basic ${cap}`, "x-codevil-proxy-target": "https://evil.test" }, + }), env); + assert.equal(response.status, 200); + assert.equal(calls[0].url, "https://github.com/other/private.git/info/refs?service=git-upload-pack"); + assert.equal(calls[0].init.headers.get("authorization"), `Basic ${Buffer.from("x-access-token:real-github-pat").toString("base64")}`); + assert.equal(JSON.stringify(calls[0]).includes(token), false); + } finally { globalThis.fetch = originalFetch; } +}); + +test("Git proxy permits writes only to the signed primary repository and rejects suffix/authority attacks", async () => { + const token = await createSandboxGitProxyToken(secret, { sessionId: "ses_s1", primaryRepo: "primary/app" }); + const cap = Buffer.from(`x-access-token:${token}`).toString("base64"); + const headers = { authorization: `Basic ${cap}` }; + const originalFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = async () => { calls++; return new Response("ok"); }; + try { + const allowed = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/github/primary/app.git/git-receive-pack", { method: "POST", headers }), env); + assert.equal(allowed.status, 200); + const other = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/github/other/private.git/git-receive-pack", { method: "POST", headers }), env); + assert.equal(other.status, 403); + for (const path of ["/sandbox-proxy/sessions/ses_s1/github/primary/app.git//evil", "/sandbox-proxy/sessions/ses_s1/github/primary/app.git/%2F%2Fevil"]) { + const response = await handleSandboxProxy(new Request(`https://worker.test${path}`, { headers }), env); + assert.ok([400, 404].includes(response.status)); + } + assert.equal(calls, 1); + } finally { globalThis.fetch = originalFetch; } +}); + +test("Git proxy exposes only the smart-HTTP read and primary-repository write endpoints", async () => { + const token = await createSandboxGitProxyToken(secret, { sessionId: "ses_s1", primaryRepo: "primary/app" }); + const cap = Buffer.from(`x-access-token:${token}`).toString("base64"); + const headers = { authorization: `Basic ${cap}` }; + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (input, init) => { calls.push({ url: String(input), init }); return new Response("unexpected"); }; + try { + const rejected = [ + ["GET", "/sandbox-proxy/sessions/ses_s1/github/other/private.git/info/refs?service=git-upload-pack&extra=1"], + ["GET", "/sandbox-proxy/sessions/ses_s1/github/other/private.git/info/refs?service=git-upload-pack&service=git-upload-pack"], + ["POST", "/sandbox-proxy/sessions/ses_s1/github/other/private.git/git-upload-pack?service=git-upload-pack"], + ["GET", "/sandbox-proxy/sessions/ses_s1/github/other/private.git/git-upload-pack"], + ["POST", "/sandbox-proxy/sessions/ses_s1/github/other/private.git/info/refs?service=git-upload-pack"], + ["POST", "/sandbox-proxy/sessions/ses_s1/github/primary/app.git/git-receive-pack?extra=1"], + ["DELETE", "/sandbox-proxy/sessions/ses_s1/github/primary/app.git/git-receive-pack"], + ["POST", "/sandbox-proxy/sessions/ses_s1/github/primary/app.git/objects/aa/bb"], + ]; + for (const [method, path] of rejected) { + const response = await handleSandboxProxy(new Request(`https://worker.test${path}`, { method, headers }), env); + assert.equal(response.status, 400, `${method} ${path}`); + } + assert.equal(calls.length, 0); + + const foreignReceiveAdvertisement = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/github/other/private.git/info/refs?service=git-receive-pack", { headers }), env); + assert.equal(foreignReceiveAdvertisement.status, 403); + assert.equal(calls.length, 0); + + const receiveAdvertisement = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/github/primary/app.git/info/refs?service=git-receive-pack", { headers }), env); + assert.equal(receiveAdvertisement.status, 200); + assert.equal(calls.length, 1); + } finally { globalThis.fetch = originalFetch; } +}); diff --git a/packages/worker/test/sandbox-ws-token.test.mjs b/packages/worker/test/sandbox-ws-token.test.mjs new file mode 100644 index 0000000..d6dc3da --- /dev/null +++ b/packages/worker/test/sandbox-ws-token.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createSandboxWebSocketToken, + SANDBOX_WS_TOKEN_TTL_MS, + verifySandboxWebSocketToken, +} from "../dist/sandbox-ws-token.js"; +import { createSandboxProxyToken } from "../dist/sandbox-proxy.js"; + +const SECRET = "sandbox-ws-signing-secret"; +const NOW = 1_700_000_000_000; + +test("session-bound sandbox WebSocket capability verifies with its explicit audience and role", async () => { + const token = await createSandboxWebSocketToken("ses_123", SECRET, NOW); + assert.deepEqual(await verifySandboxWebSocketToken(token, "ses_123", SECRET, NOW), { + aud: "sandbox_ws", + role: "sandbox", + sid: "ses_123", + exp: NOW + SANDBOX_WS_TOKEN_TTL_MS, + jti: (await verifySandboxWebSocketToken(token, "ses_123", SECRET, NOW)).jti, + }); + const replacement = await createSandboxWebSocketToken("ses_123", SECRET, NOW); + assert.notEqual(replacement, token); +}); + +test("sandbox WebSocket capability rejects forged, expired, wrong-session, wrong-audience, and proxy tokens", async () => { + const token = await createSandboxWebSocketToken("ses_123", SECRET, NOW); + assert.equal(await verifySandboxWebSocketToken(token, "ses_wrong", SECRET, NOW), null); + assert.equal(await verifySandboxWebSocketToken(token, "ses_123", "wrong-secret", NOW), null); + assert.equal(await verifySandboxWebSocketToken(token, "ses_123", SECRET, NOW + SANDBOX_WS_TOKEN_TTL_MS), null); + assert.equal(await verifySandboxWebSocketToken("v1.invalid.signature", "ses_123", SECRET, NOW), null); + assert.equal(await verifySandboxWebSocketToken("control-plane-global-key", "ses_123", SECRET, NOW), null); + assert.equal(await verifySandboxWebSocketToken(await signClaims({ + aud: "llm_proxy", + role: "sandbox", + sid: "ses_123", + exp: NOW + 1, + jti: "wrong-audience", + }), "ses_123", SECRET, NOW), null); + const proxyToken = await createSandboxProxyToken(SECRET, { sessionId: "ses_123", provider: "openai", api: "openai-responses" }, NOW); + assert.equal(await verifySandboxWebSocketToken(proxyToken, "ses_123", SECRET, NOW), null); +}); + +async function signClaims(claims) { + const encoded = base64url(JSON.stringify(claims)); + const signed = `sws1.${encoded}`; + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + const signature = base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed))))); + return `${signed}.${signature}`; +} + +function base64url(value) { + return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} diff --git a/packages/worker/test/sandbox.test.mjs b/packages/worker/test/sandbox.test.mjs index b672a9c..52f8d65 100644 --- a/packages/worker/test/sandbox.test.mjs +++ b/packages/worker/test/sandbox.test.mjs @@ -7,12 +7,15 @@ import { CODEVIL_SANDBOX_OPTIONS, collectSandboxDiagnostics, getCodevilSandbox, + lifecycleErrorEvent, recordSandboxLifecycleEvent, + redactSandboxKeepAliveState, retrySandboxOperation, SANDBOX_LIFECYCLE_EVENT_KEY, sandboxProcessEnv, setCodevilSandboxKeepAlive, shouldDeferSandboxActivityExpiry, + stopDiagnostics, } from "../dist/sandbox.js"; test("builds sandbox WebSocket URL from worker origin", () => { @@ -22,20 +25,57 @@ test("builds sandbox WebSocket URL from worker origin", () => { ); }); -test("builds sandbox process env without exposing llm key", () => { +test("builds sandbox process env with only short-lived per-Pi-API proxy capabilities", () => { assert.deepEqual(sandboxProcessEnv({ wsUrl: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", - apiKey: "secret", + wsToken: "sandbox-ws-capability", provider: "anthropic", + proxyBase: "https://codevil.example.com", + proxyTokens: { "anthropic-messages": "sandbox-capability" }, }), { - CODEVIL_DO_WS_URL: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", - CODEVIL_API_KEY: "secret", + CODEVIL_DO_WS_URL: "wss://codevil.example.com/sessions/ses_123/sandbox/ws?sandbox_ws_token=sandbox-ws-capability", + CODEVIL_SANDBOX_WS_TOKEN: "sandbox-ws-capability", CODEVIL_WORKSPACE: "/workspace", CODEVIL_PROVIDER: "anthropic", - CODEVIL_LLM_KEY_FILE: "/run/secrets/llm_key", + CODEVIL_PROXY_BASE: "https://codevil.example.com", + CODEVIL_PROXY_TOKENS: '{"anthropic-messages":"sandbox-capability"}', }); }); +test("sandbox process environment never receives the deployment API key or provider credential", () => { + const env = sandboxProcessEnv({ + wsUrl: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", + wsToken: "session-bound-ws-capability", + provider: "anthropic", + proxyBase: "https://codevil.example.com", + proxyTokens: { "anthropic-messages": "proxy-capability" }, + }); + assert.equal(env.CODEVIL_API_KEY, undefined); + assert.doesNotMatch(JSON.stringify(env), /sk-ant-real-provider-key/); +}); + +test("sandbox process environment carries Cloudflare identifiers only in the allowlisted provider config payload", () => { + const env = sandboxProcessEnv({ + wsUrl: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", + wsToken: "session-bound-ws-capability", + provider: "cloudflare-ai-gateway", + providerConfig: { + CLOUDFLARE_ACCOUNT_ID: "account_123", + CLOUDFLARE_GATEWAY_ID: "gateway_456", + }, + proxyBase: "https://codevil.example.com", + proxyTokens: { "anthropic-messages": "proxy-capability" }, + }); + + assert.equal( + env.CODEVIL_PROVIDER_CONFIG, + '{"CLOUDFLARE_ACCOUNT_ID":"account_123","CLOUDFLARE_GATEWAY_ID":"gateway_456"}', + ); + assert.equal(env.CLOUDFLARE_ACCOUNT_ID, undefined); + assert.equal(env.CLOUDFLARE_GATEWAY_ID, undefined); + assert.equal(env.CLOUDFLARE_API_KEY, undefined); +}); + test("gets codevil sandbox with keepAlive enabled", () => { const binding = {}; const sandbox = {}; @@ -68,7 +108,7 @@ test("records sandbox lifecycle events for stop diagnostics", async () => { at: "2026-06-16T00:00:00.000Z", exit_code: 137, reason: "out of memory", - }); + }, []); assert.deepEqual(writes, [{ key: SANDBOX_LIFECYCLE_EVENT_KEY, @@ -81,6 +121,52 @@ test("records sandbox lifecycle events for stop diagnostics", async () => { }]); }); +test("redacts lifecycle values before persisting stop diagnostics or keepalive state", async () => { + const secret = "sandbox-lifecycle-storage-secret"; + const writes = []; + const storage = { put: async (key, value) => writes.push({ key, value }) }; + + await recordSandboxLifecycleEvent(storage, { + type: "stop", + at: "2026-06-16T00:00:00.000Z", + reason: `container stopped with ${secret}`, + }, [secret]); + + assert.deepEqual(writes[0].value, { + type: "stop", + at: "2026-06-16T00:00:00.000Z", + reason: "container stopped with [REDACTED]", + }); + assert.deepEqual(redactSandboxKeepAliveState({ + active: true, + reason: `working with ${secret}`, + }, [secret]), { + active: true, + reason: "working with [REDACTED]", + }); +}); + +test("lifecycle errors and stop diagnostics do not invoke hostile accessors", () => { + const secret = "sandbox-lifecycle-secret"; + const hostileError = {}; + Object.defineProperty(hostileError, "message", { + get() { + throw new Error("must not read lifecycle message"); + }, + }); + const hostileStop = {}; + Object.defineProperty(hostileStop, "reason", { + get() { + throw new Error("must not read stop reason"); + }, + }); + Object.defineProperty(hostileStop, "exitCode", { value: 137 }); + + assert.doesNotThrow(() => lifecycleErrorEvent(hostileError, [secret])); + assert.equal(lifecycleErrorEvent(new Error(`failed ${secret}`), [secret]).error, "failed [REDACTED]"); + assert.deepEqual(stopDiagnostics(hostileStop), { exit_code: 137 }); +}); + test("defers activity expiry while codevil keepalive is active", () => { assert.equal(shouldDeferSandboxActivityExpiry({ active: true }), true); assert.equal(shouldDeferSandboxActivityExpiry({ active: false }), false); @@ -124,7 +210,7 @@ test("collects sandbox diagnostics from logs and lifecycle storage", async () => reason: "session provisioning", }, }), - }, "codevil-agent"); + }, "codevil-agent", []); assert.deepEqual(diagnostics, { logs: { stdout: "out", stderr: "err" }, @@ -143,6 +229,20 @@ test("collects sandbox diagnostics from logs and lifecycle storage", async () => }); }); +test("redacts successful sandbox process output before diagnostic callers can broadcast it", async () => { + const secret = "sandbox-timeout-output-secret"; + const diagnostics = await collectSandboxDiagnostics({ + getProcessLogs: async () => ({ + stdout: `stdout ${secret}`, + stderr: `stderr ${secret}`, + }), + }, "codevil-agent", [secret]); + + const serialized = JSON.stringify(diagnostics); + assert.doesNotMatch(serialized, new RegExp(secret)); + assert.match(serialized, /\[REDACTED\]/); +}); + test("returns lifecycle diagnostics when process logs fail", async () => { const diagnostics = await collectSandboxDiagnostics({ getProcessLogs: async () => { @@ -156,7 +256,7 @@ test("returns lifecycle diagnostics when process logs fail", async () => { reason: "out of memory", }, }), - }, "codevil-agent"); + }, "codevil-agent", []); assert.deepEqual(diagnostics, { logs: null, @@ -174,6 +274,28 @@ test("returns lifecycle diagnostics when process logs fail", async () => { }); }); +test("sanitizes rejected sandbox diagnostics without invoking hostile getters or coercion", async () => { + const secret = "sandbox-rejection-secret"; + const hostile = {}; + Object.defineProperty(hostile, "message", { + get() { throw new Error("must not read message"); }, + }); + Object.defineProperty(hostile, "toString", { + value() { throw new Error("must not coerce error"); }, + }); + + const diagnostics = await collectSandboxDiagnostics({ + getProcessLogs: async () => { throw new Error(`logs ${secret}`); }, + getCodevilLifecycleSnapshot: async () => { throw hostile; }, + }, "codevil-agent", [secret]); + + assert.deepEqual(diagnostics, { + logs: null, + lifecycle: null, + errors: { logs: "logs [REDACTED]", lifecycle: "[UNAVAILABLE]" }, + }); +}); + test("builds bounded sandbox disconnect log payload with actual diagnostics", () => { const payload = buildSandboxDisconnectLogPayload({ sessionId: "ses_123", diff --git a/packages/worker/test/session-directory.test.mjs b/packages/worker/test/session-directory.test.mjs index 5ce9ba5..61c382f 100644 --- a/packages/worker/test/session-directory.test.mjs +++ b/packages/worker/test/session-directory.test.mjs @@ -7,11 +7,13 @@ import { deriveSessionTitle, normalizeCreateSessionBody, normalizeIdempotencyKey, + nextSessionDirectoryTimestamp, runSessionDirectoryUpdateWithRetry, sessionDirectoryInsert, sessionIdempotencyInsert, sessionIdempotencyLookup, } from "../dist/session-directory.js"; +import { DirectoryUpdateQueue } from "../dist/orchestrator/directory-update-queue.js"; test("deriveSessionTitle uses owner/repo for GitHub URLs", () => { assert.equal(deriveSessionTitle("https://github.com/acme/app.git"), "acme/app"); @@ -147,6 +149,36 @@ test("buildCreateSessionResponse builds ws_url and summary", () => { assert.equal(response.summary.title, "acme/app"); }); +test("nextSessionDirectoryTimestamp advances past an equal persisted baseline", () => { + assert.equal( + nextSessionDirectoryTimestamp( + "2026-07-10T00:00:00.000Z", + "2026-07-10T00:00:00.000Z", + ), + "2026-07-10T00:00:00.001Z", + ); +}); + +test("nextSessionDirectoryTimestamp advances a restarted DO clock past persisted state", () => { + assert.equal( + nextSessionDirectoryTimestamp( + "2026-07-10T00:00:00.000Z", + "2026-07-10T00:00:05.000Z", + ), + "2026-07-10T00:00:05.001Z", + ); +}); + +test("nextSessionDirectoryTimestamp advances past a future persisted baseline", () => { + assert.equal( + nextSessionDirectoryTimestamp( + "2026-07-10T00:00:00.000Z", + "2027-01-01T00:00:00.000Z", + ), + "2027-01-01T00:00:00.001Z", + ); +}); + test("runSessionDirectoryUpdateWithRetry succeeds on first attempt", async () => { let calls = 0; const db = { @@ -223,3 +255,36 @@ test("runSessionDirectoryUpdateWithRetry calls onFailure after exhausting retrie assert.equal(calls, 2); assert.equal(failureError?.message, "persistent"); }); + +test("runSessionDirectoryUpdateWithRetry times out a non-settling attempt and lets a later queue item proceed", async () => { + const queue = new DirectoryUpdateQueue(); + let attempts = 0; + const db = { + prepare() { + return { + bind() { + return { + run() { + attempts++; + return new Promise(() => {}); + }, + }; + }, + }; + }, + }; + const calls = []; + + const stalled = queue.enqueue(() => runSessionDirectoryUpdateWithRetry( + db, + "UPDATE sessions SET room_state = ?", + ["ready"], + { attempts: 2, backoffMs: 1, attemptTimeoutMs: 2, onFailure: () => calls.push("failed") }, + )); + const later = queue.enqueue(async () => { calls.push("later"); }); + + await Promise.all([stalled, later]); + + assert.equal(attempts, 2); + assert.deepEqual(calls, ["failed", "later"]); +}); diff --git a/packages/worker/test/tracer-redaction.test.mjs b/packages/worker/test/tracer-redaction.test.mjs new file mode 100644 index 0000000..b545bfc --- /dev/null +++ b/packages/worker/test/tracer-redaction.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTracer } from "@codevil/shared"; +import { redactEvent } from "../dist/redaction.js"; + +test("trace sink redacts exception messages and nested sandbox output at the final boundary", async () => { + const secret = "short-trace-secret"; + const lines = []; + const tracer = createTracer({ + component: "orchestrator", + trace_id: "0123456789abcdef0123456789abcdef", + sink: (line) => lines.push(line), + transform: (line) => redactEvent(line, [secret]), + }); + + await assert.rejects( + tracer.span("sandbox.provision", {}, async () => { + const error = new Error(`provisioning failed: ${secret}`); + error.sandbox = { stdout: secret, nested: { stderr: `also ${secret}` } }; + throw error; + }), + ); + + const serialized = JSON.stringify(lines); + assert.doesNotMatch(serialized, new RegExp(secret)); + assert.match(serialized, /\[REDACTED\]/); + assert.match(serialized, /provisioning failed/); +}); diff --git a/packages/worker/wrangler.operator.example.toml b/packages/worker/wrangler.operator.example.toml new file mode 100644 index 0000000..f2ce70a --- /dev/null +++ b/packages/worker/wrangler.operator.example.toml @@ -0,0 +1,67 @@ +# Copy this file to wrangler.operator.toml and fill in values created in your +# Cloudflare account. It is intentionally ignored by Git. +# +# Deploy with: wrangler deploy --config wrangler.operator.toml +# Keep the binding names below unchanged: Worker code depends on them. + +name = "your-codevil-worker" +main = "src/index.ts" +compatibility_date = "2026-04-07" +compatibility_flags = ["nodejs_compat"] +workers_dev = true +upload_source_maps = true + +[vars] +EMAIL_PROVIDER = "none" +CODEVIL_WEB_ORIGIN = "https://your-web-origin.example" +CODEVIL_PREVIEW_ORIGIN = "https://your-preview-origin.example" +BACKUP_BUCKET_NAME = "your-backup-bucket" + +[observability] +enabled = true +head_sampling_rate = 1 + +[observability.traces] +enabled = true +head_sampling_rate = 1 + +# To route a custom domain, add a [[routes]] block here with the domain and +# Cloudflare zone that belong to this deployment. + +[assets] +directory = "../web/dist" +binding = "ASSETS" +not_found_handling = "single-page-application" +run_worker_first = true + +[[containers]] +class_name = "Sandbox" +image = "../../Dockerfile.sandbox" +instance_type = "standard-1" +max_instances = 5 + +[[durable_objects.bindings]] +name = "ORCHESTRATOR" +class_name = "Orchestrator" + +[[durable_objects.bindings]] +name = "Sandbox" +class_name = "Sandbox" + +[[d1_databases]] +binding = "DB" +database_name = "your-d1-database-name" +database_id = "your-d1-database-id" +migrations_dir = "migrations" + +[[r2_buckets]] +binding = "BACKUP_BUCKET" +bucket_name = "your-r2-bucket-name" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["Orchestrator"] + +[[migrations]] +tag = "v2" +new_sqlite_classes = ["Sandbox"] diff --git a/packages/worker/wrangler.toml b/packages/worker/wrangler.toml index eeafa18..d0e245c 100644 --- a/packages/worker/wrangler.toml +++ b/packages/worker/wrangler.toml @@ -7,15 +7,11 @@ upload_source_maps = true [vars] EMAIL_PROVIDER = "none" -CODEVIL_WEB_ORIGIN = "https://codevil-ui.pages.dev,http://localhost:5173,http://localhost:8787" -# Preview subdomains: {token}.lexmora.app → worker (Next.js/Vite use root-absolute asset paths). -CODEVIL_PREVIEW_ORIGIN = "https://lexmora.app" +CODEVIL_WEB_ORIGIN = "http://localhost:5173,http://localhost:8787" +# Leaving CODEVIL_PREVIEW_ORIGIN unset keeps preview URLs on the portable +# workers.dev path. Set it in an untracked operator overlay to use a domain. BACKUP_BUCKET_NAME = "codevil-sandbox-backups" -[[routes]] -pattern = "*.lexmora.app/*" -zone_name = "lexmora.app" - [assets] directory = "../web/dist" binding = "ASSETS" @@ -47,7 +43,6 @@ class_name = "Sandbox" [[d1_databases]] binding = "DB" database_name = "codevil" -database_id = "f497dece-7db6-441d-8eaf-cc57e02fb56a" migrations_dir = "migrations" [[r2_buckets]] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9661295..6eb367b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,9 @@ importers: '@codevil/shared': specifier: workspace:* version: link:../shared - '@mariozechner/pi-ai': - specifier: ^0.73.1 - version: 0.73.1(ws@8.18.0)(zod@4.3.6) + '@earendil-works/pi-ai': + specifier: ^0.80.3 + version: 0.80.3(ws@8.18.0)(zod@3.25.76) ws: specifier: ^8.18.0 version: 8.18.0 @@ -51,15 +51,12 @@ importers: '@codevil/shared': specifier: workspace:* version: link:../shared - '@mariozechner/pi-ai': - specifier: ^0.73.1 - version: 0.73.1(ws@8.18.0)(zod@4.3.6) - '@mariozechner/pi-coding-agent': - specifier: ^0.73.1 - version: 0.73.1(ws@8.18.0)(zod@4.3.6) - '@sinclair/typebox': - specifier: ^0.34.49 - version: 0.34.49 + '@earendil-works/pi-ai': + specifier: ^0.80.3 + version: 0.80.3(ws@8.18.0)(zod@4.3.6) + '@earendil-works/pi-coding-agent': + specifier: ^0.80.3 + version: 0.80.3(ws@8.18.0)(zod@4.3.6) ws: specifier: ^8.18.0 version: 8.18.0 @@ -277,50 +274,98 @@ packages: resolution: {integrity: sha512-CDI4njdtLEd3voxApQMI32IJN/HhpM3FtAh0quJ+aIWNmyDbW3cp2SwQ56Pnsrq1OV40Apw/O4yD822K4aK9HA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.2': resolution: {integrity: sha512-oav5AOAz+1XkwUfp6SrEm42UPDpUP5D4jNYXkDwFR1VfWqYX62+jpytdfzURmJ9McSoJIQwi0OJlC4oCi6t0VQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.26': + resolution: {integrity: sha512-wRj7Pthvjk3anees97pUWlxlTa0DUjeGrEQU5fKDZVdWZV0ekaprbof0df2uaE9g8u67t035v2j+ne2AW2UMkA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.28': resolution: {integrity: sha512-87GdRJ2OR0qR4VkMjXN/SZi66DZsunW2qQCbtw9rKw3Y7JurFi6tQWYKOSLY/gOADrU6OxGqFmdw3hKzZqDZOQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.52': + resolution: {integrity: sha512-sxuaHZGHqOgKB8OdL3doXa1NJjqmO60FPfyTnYVKGjX9taRsIEGS9pd+2yALmo06hijZ8L94uSK0kfXZsRmVyA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.30': resolution: {integrity: sha512-6quozmW2PKwBJTUQLb+lk1q8w5Pm45qaqhx4Tld9EIqYYQOVGj+MT0a8NRVS7QgWJj7rzGlB7rQu3KYBFHemJw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.54': + resolution: {integrity: sha512-e6yz52nq3SpR1oPLcvfsDM7H7k2gIYk/NSn/rwsFqzGXEwr3g0mRMlPbLaKCPCGNZJMU/gZg6/64B3eSam+gBw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.32': resolution: {integrity: sha512-Nkr+UKtczZlocUjc6g96WzQadZSIZO/HVXPki4qbfaVOZYSbfLQKWKfADtJ0kGYsCvSYOZrO66tSc9dkboUt/w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.59': + resolution: {integrity: sha512-9Um/UpruN76AdpiLnvwChVkJJwJ9Vx9ykk/2AeLxxSCM/YYRD8Kkq2towUk9fZQLV7dd9ATlsi87U7hKs0z/iQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.32': resolution: {integrity: sha512-UxgwT1HmZz1QPXuBy5ZUPJNFXOSlhwdQL61eGhWRthF0xRrT02BCOVJ1p5Ejg5AXfnESTWoKPJ7v/sCkNUtB9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.58': + resolution: {integrity: sha512-H3q96qF8/DJsPsXMVtMRqSWOc85K5O4zos32untdw+vE5vw0f3a6qJo1YqbND4BsEIKd4iZmzzVUq9kV4LjbHg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.33': resolution: {integrity: sha512-6pGQnEdSeRvBViTQh/FwaRKB38a3Th+W2mVxuvqAd2Z1Ayo3e6eJ5QqJoZwEMwR6xoxkl3wz3qAfiB1xRhMC+w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.61': + resolution: {integrity: sha512-2U2KHMRCt1dlZoLU3KZR5g5EL4b0h2HHw96SkaUBK7qvEXPZj5rGRO/3ZTeJmh37dIYQuCnA2273rZOQvmsiHw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.28': resolution: {integrity: sha512-CRAlD8u6oNBhjnX/3ekVGocarD+lFmEn/qeDzytgIdmwrmwMJGFPqS9lGwEfhOTihZKrQ0xSp3z6paX+iXJJhA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.52': + resolution: {integrity: sha512-Aff9Ebs42lz+Ep1wkS+Nlwh5S0eahakpyskPsuKGjiBJ6ExOjNtxbfKJTKovQtQNgJ7oG1BH6esJwGrbs7qgSA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.32': resolution: {integrity: sha512-whhmQghRYOt9mJxFyVMhX7eB8n0oA25OCvqoR7dzFAZjmioCkf7WVB22Bc6llM5cFpBXFX7s4Jv+xVq32VPGWg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.58': + resolution: {integrity: sha512-syloC58mXOacUqM2toPNfwd7X3jT+tWj0F/cN7qdW1FQyI0q41J0tPf6DIZ56BF0x82iS9j3ALP45MoBz79YuQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.32': resolution: {integrity: sha512-Z0Y0LDaqyQDznlmr9gv6n4+eWKKWNgmi9j5L6RENr6wyOCguhO8FRPmqDbVLSw0DPdMqICKnA3PurJiS8bD6Cw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.58': + resolution: {integrity: sha512-pTBImKzcGK+pcMKjL0fAJbnYzzYd1c0UDc7BSIOGNQhF9Nuk66vWlIXfYTYyzNSs+w8Q/vfbbNDDU8zdrouwLg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.14': resolution: {integrity: sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg==} engines: {node: '>=20.0.0'} + '@aws-sdk/eventstream-handler-node@3.972.25': + resolution: {integrity: sha512-df7HN1ozwMrB9+59re9PM7tSLxLAcheMWc5u/KyfCPCAWtN/vP7y7RTUZOy48uT1K9MESisVeOPPzF3O1AW01A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.10': resolution: {integrity: sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-eventstream@3.972.21': + resolution: {integrity: sha512-HvLgDnxBLaHi9E5K++6Vuk+1+qqn7Pmn8zrlzd+NXH3jBzwujnuzZtAR9WHPkbUGPO92FkoQWj/M1IsdxTlBmQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-host-header@3.972.10': resolution: {integrity: sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==} engines: {node: '>=20.0.0'} @@ -345,10 +390,18 @@ packages: resolution: {integrity: sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg==} engines: {node: '>= 14.0.0'} + '@aws-sdk/middleware-websocket@3.972.34': + resolution: {integrity: sha512-8dxKLu5bC74SLwwoYV8RIiCD48jMbMt1Ccl3m+xtQJKet6QsZ4xzJlK6UDg7QNEzm/ZCUknJfGsBHmhkgOfuIQ==} + engines: {node: '>= 14.0.0'} + '@aws-sdk/nested-clients@3.997.0': resolution: {integrity: sha512-4bI5GHjUiY5R8N6PtchpG6tW2Dl8I2IcZNg3JwqwxHRXjfvQlPoo4VMknG4qkd5W0t3Y20rQ6C7pSR561YG5JQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.26': + resolution: {integrity: sha512-Lwe3F6K7bs+jEubp1LbrvzeMBYb5fMazJ1IxV9TtKWPF8CSh67Fmwyq9fLz3NL/k55Dfpuph5Dimw76JFgr+SA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/region-config-resolver@3.972.12': resolution: {integrity: sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==} engines: {node: '>=20.0.0'} @@ -357,10 +410,26 @@ packages: resolution: {integrity: sha512-7Sy8+GhfwUi06NQNLplxuJuXMKJURDsNQfK8yTW6E9wN2J1B+8S5dWZG7vg3InvPPhaXqkcYTr8pzeE+dLjMbQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.38': + resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1033.0': resolution: {integrity: sha512-/TsXhqjyRAFb0xVgmbFAha3cJfZdWjnyn6ohJ3AB4E3peLgxNcmKfYr45hruHymyJAydiHoXC3N1a8qgl41cog==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1078.0': + resolution: {integrity: sha512-/uyXLBGu3Lw1GbBA2X66hcOMnKtMcqAIF+3/eHfxBQmUeXF2sdqozDPrTfEr/TnSd0D6deZar+eVyhEqqWu29w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.15': + resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.8': resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==} engines: {node: '>=20.0.0'} @@ -397,6 +466,10 @@ packages: resolution: {integrity: sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.33': + resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -670,6 +743,24 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@earendil-works/pi-agent-core@0.80.3': + resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.80.3': + resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.80.3': + resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.80.3': + resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + engines: {node: '>=22.19.0'} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -1009,6 +1100,15 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1249,31 +1349,17 @@ packages: resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} engines: {node: '>= 10'} - '@mariozechner/pi-agent-core@0.73.1': - resolution: {integrity: sha512-Y/KVOhuKSgRQgYBlwmRtO2gPkUcoavOSqGF9bpQIINvNZvc19k6Z1H3bFDTce3Vp5ApMmTsfLH3+tNvOg75fAQ==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-agent-core instead going forward - - '@mariozechner/pi-ai@0.73.1': - resolution: {integrity: sha512-Jh4lXawZYuC83HzSIYuVum9NBqJD49i4JOt3H96cGW/924cwJMOyUs1Mv/e4QPzTXnzrqMoGviNQnvGgSu1LSg==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-ai instead going forward - hasBin: true - - '@mariozechner/pi-coding-agent@0.73.1': - resolution: {integrity: sha512-gXQh3SaZmWTfVMc4Ao5+LGbVeKvzyO7tolok0nLsZgq9nGjZx/EEU3NM8C+qUnB4Nvs2rswG5qOVgLzQkq0fHQ==} - engines: {node: '>=20.6.0'} - deprecated: please use @earendil-works/pi-coding-agent instead going forward - hasBin: true - - '@mariozechner/pi-tui@0.73.1': - resolution: {integrity: sha512-ybVsRnUbzQRtbocltJ2OXb2QogrO67N2BlUyKjZz9BHcZYiDJtNkcKQockxDjsVvDc0uBCLDX6iZJoBElBd8fw==} - engines: {node: '>=20.0.0'} - deprecated: please use @earendil-works/pi-tui instead going forward - '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@noble/ciphers@2.2.0': resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} engines: {node: '>= 20.19.0'} @@ -1282,6 +1368,10 @@ packages: resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + '@opentelemetry/semantic-conventions@1.41.1': resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} @@ -2210,10 +2300,18 @@ packages: resolution: {integrity: sha512-JStomOrINQA1VqNEopLsgcdgwd42au7mykKqVr30XFw89wLt9sDxJDi4djVPRwQmmzyTGy/uOvTc2ultMpFi1w==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.0': + resolution: {integrity: sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.2.14': resolution: {integrity: sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.5': + resolution: {integrity: sha512-LnjUTNG0GgQlKIq7IioeOrPaEmC5xOd1WtAz24TLSiYQnWX2uHr53GrFuQhkrJBktPYCMga/NbUOW7hFbSA2Cg==} + engines: {node: '>=18.0.0'} + '@smithy/eventstream-codec@4.2.14': resolution: {integrity: sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==} engines: {node: '>=18.0.0'} @@ -2238,6 +2336,10 @@ packages: resolution: {integrity: sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.2': + resolution: {integrity: sha512-q96PSDOAGw+X+nuELd7Cjebps0SYr+YlPbviEX9sLVw+VM4M7VV8hn1nL1mGS6urDu33eQ5A7WhlphaDO6kUyQ==} + engines: {node: '>=18.0.0'} + '@smithy/hash-node@4.2.14': resolution: {integrity: sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==} engines: {node: '>=18.0.0'} @@ -2282,6 +2384,14 @@ packages: resolution: {integrity: sha512-P734cAoTFtuGfWa/R3jgBnGlURt2w9bYEBwQNMKf58sRM9RShirB2mKwLsVP+jlG/wxpCu8abv8NxdUts8tdLA==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.2': + resolution: {integrity: sha512-s0yAIRj6TVfHgl+QzVyqal1KMGZ9B5512IrxKc6+dOpw8fUmFL3CvuAhjv0J+aNjUPfVZ2IhqPEDvkB5Ncx9oA==} + engines: {node: '>=18.0.0'} + '@smithy/property-provider@4.2.14': resolution: {integrity: sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==} engines: {node: '>=18.0.0'} @@ -2310,6 +2420,10 @@ packages: resolution: {integrity: sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.1': + resolution: {integrity: sha512-SqvuP75p/DmgWWI7jv4kf/UW+V4LFmlUn19s604SgAcRuJRB1vDnWwzZMYCLUcmKxko9wDn6iLgGEIpTNgZbIQ==} + engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.12.12': resolution: {integrity: sha512-daO7SJn4eM6ArbmrEs+/BTbH7af8AEbSL3OMQdcRvvn8tuUcR5rU2n6DgxIV53aXMS42uwK8NgKKCh5XgqYOPQ==} engines: {node: '>=18.0.0'} @@ -2318,6 +2432,10 @@ packages: resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.15.1': + resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} + engines: {node: '>=18.0.0'} + '@smithy/url-parser@4.2.14': resolution: {integrity: sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==} engines: {node: '>=18.0.0'} @@ -3007,6 +3125,10 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} @@ -3280,6 +3402,10 @@ packages: resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -3370,6 +3496,10 @@ packages: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -3464,6 +3594,9 @@ packages: resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} engines: {node: '>=18'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3636,6 +3769,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + mdast-util-definitions@6.0.0: resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} @@ -3933,6 +4071,10 @@ packages: resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} engines: {node: '>=14.0.0'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -4172,6 +4314,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + seroval-plugins@1.5.2: resolution: {integrity: sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==} engines: {node: '>=10'} @@ -4189,6 +4336,14 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + shiki@3.23.0: resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} @@ -4363,6 +4518,9 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typebox@1.2.16: resolution: {integrity: sha512-p85YWtR1RznP2kt/sftmj1leGiObfHVJwkK2hkDdEKhbtQONeUeBtbYdK0x9tLPh+5AaEPD62APv189Cy+/C2g==} @@ -4391,6 +4549,10 @@ packages: resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} engines: {node: '>=20.18.1'} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -4635,6 +4797,11 @@ packages: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -4697,6 +4864,11 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -4770,6 +4942,12 @@ packages: snapshots: + '@anthropic-ai/sdk@0.91.1(zod@3.25.76)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.76 + '@anthropic-ai/sdk@0.91.1(zod@4.3.6)': dependencies: json-schema-to-ts: 3.1.1 @@ -4929,6 +5107,23 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.26 + '@aws-sdk/credential-provider-node': 3.972.61 + '@aws-sdk/eventstream-handler-node': 3.972.25 + '@aws-sdk/middleware-eventstream': 3.972.21 + '@aws-sdk/middleware-websocket': 3.972.34 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.29.0 + '@smithy/fetch-http-handler': 5.6.2 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + '@aws-sdk/core@3.974.2': dependencies: '@aws-sdk/types': 3.973.8 @@ -4945,6 +5140,17 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@aws-sdk/core@3.974.26': + dependencies: + '@aws-sdk/types': 3.973.15 + '@aws-sdk/xml-builder': 3.972.33 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.29.0 + '@smithy/signature-v4': 5.6.1 + '@smithy/types': 4.15.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.28': dependencies: '@aws-sdk/core': 3.974.2 @@ -4953,6 +5159,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.30': dependencies: '@aws-sdk/core': 3.974.2 @@ -4966,6 +5180,16 @@ snapshots: '@smithy/util-stream': 4.5.24 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.54': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/fetch-http-handler': 5.6.2 + '@smithy/node-http-handler': 4.9.2 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.32': dependencies: '@aws-sdk/core': 3.974.2 @@ -4985,6 +5209,22 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-ini@3.972.59': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/credential-provider-env': 3.972.52 + '@aws-sdk/credential-provider-http': 3.972.54 + '@aws-sdk/credential-provider-login': 3.972.58 + '@aws-sdk/credential-provider-process': 3.972.52 + '@aws-sdk/credential-provider-sso': 3.972.58 + '@aws-sdk/credential-provider-web-identity': 3.972.58 + '@aws-sdk/nested-clients': 3.997.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/credential-provider-imds': 4.4.5 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.32': dependencies: '@aws-sdk/core': 3.974.2 @@ -4998,6 +5238,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-login@3.972.58': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/nested-clients': 3.997.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.33': dependencies: '@aws-sdk/credential-provider-env': 3.972.28 @@ -5015,6 +5264,20 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-node@3.972.61': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.52 + '@aws-sdk/credential-provider-http': 3.972.54 + '@aws-sdk/credential-provider-ini': 3.972.59 + '@aws-sdk/credential-provider-process': 3.972.52 + '@aws-sdk/credential-provider-sso': 3.972.58 + '@aws-sdk/credential-provider-web-identity': 3.972.58 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/credential-provider-imds': 4.4.5 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.28': dependencies: '@aws-sdk/core': 3.974.2 @@ -5024,6 +5287,14 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.52': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.32': dependencies: '@aws-sdk/core': 3.974.2 @@ -5037,6 +5308,16 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-sso@3.972.58': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/nested-clients': 3.997.26 + '@aws-sdk/token-providers': 3.1078.0 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.32': dependencies: '@aws-sdk/core': 3.974.2 @@ -5049,6 +5330,15 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/credential-provider-web-identity@3.972.58': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/nested-clients': 3.997.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.14': dependencies: '@aws-sdk/types': 3.973.8 @@ -5056,6 +5346,13 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/eventstream-handler-node@3.972.25': + dependencies: + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -5063,6 +5360,13 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/middleware-eventstream@3.972.21': + dependencies: + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/middleware-host-header@3.972.10': dependencies: '@aws-sdk/types': 3.973.8 @@ -5127,6 +5431,16 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@aws-sdk/middleware-websocket@3.972.34': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/fetch-http-handler': 5.6.2 + '@smithy/signature-v4': 5.6.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.0': dependencies: '@aws-crypto/sha256-browser': 5.2.0 @@ -5171,6 +5485,17 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/nested-clients@3.997.26': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/signature-v4-multi-region': 3.996.38 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/fetch-http-handler': 5.6.2 + '@smithy/node-http-handler': 4.9.2 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/region-config-resolver@3.972.12': dependencies: '@aws-sdk/types': 3.973.8 @@ -5188,6 +5513,13 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.38': + dependencies: + '@aws-sdk/types': 3.973.15 + '@smithy/signature-v4': 5.6.1 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1033.0': dependencies: '@aws-sdk/core': 3.974.2 @@ -5200,6 +5532,29 @@ snapshots: transitivePeerDependencies: - aws-crt + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/nested-clients': 3.997.26 + '@aws-sdk/types': 3.973.8 + '@smithy/core': 3.29.0 + '@smithy/types': 4.14.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1078.0': + dependencies: + '@aws-sdk/core': 3.974.26 + '@aws-sdk/nested-clients': 3.997.26 + '@aws-sdk/types': 3.973.15 + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.15': + dependencies: + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws-sdk/types@3.973.8': dependencies: '@smithy/types': 4.14.1 @@ -5250,6 +5605,11 @@ snapshots: fast-xml-parser: 5.5.8 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.33': + dependencies: + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.2.4': {} '@babel/code-frame@7.29.0': @@ -5486,6 +5846,97 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@earendil-works/pi-agent-core@0.80.3(ws@8.18.0)(zod@4.3.6)': + dependencies: + '@earendil-works/pi-ai': 0.80.3(ws@8.18.0)(zod@4.3.6) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.3(ws@8.18.0)(zod@3.25.76)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@3.25.76) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.18.0)(zod@3.25.76) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.3(ws@8.18.0)(zod@4.3.6)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.18.0)(zod@4.3.6) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.80.3(ws@8.18.0)(zod@4.3.6)': + dependencies: + '@earendil-works/pi-agent-core': 0.80.3(ws@8.18.0)(zod@4.3.6) + '@earendil-works/pi-ai': 0.80.3(ws@8.18.0)(zod@4.3.6) + '@earendil-works/pi-tui': 0.80.3 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.80.3': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -5675,6 +6126,17 @@ snapshots: - supports-color - utf-8-validate + '@google/genai@1.52.0': + dependencies: + google-auth-library: 10.6.2 + p-retry: 4.6.2 + protobufjs: 7.5.5 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': @@ -5839,90 +6301,23 @@ snapshots: '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 optional: true - '@mariozechner/pi-agent-core@0.73.1(ws@8.18.0)(zod@4.3.6)': - dependencies: - '@mariozechner/pi-ai': 0.73.1(ws@8.18.0)(zod@4.3.6) - typebox: 1.2.16 - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-ai@0.73.1(ws@8.18.0)(zod@4.3.6)': - dependencies: - '@anthropic-ai/sdk': 0.91.1(zod@4.3.6) - '@aws-sdk/client-bedrock-runtime': 3.1033.0 - '@google/genai': 1.50.1 - '@mistralai/mistralai': 2.2.1 - chalk: 5.6.2 - openai: 6.26.0(ws@8.18.0)(zod@4.3.6) - partial-json: 0.1.7 - proxy-agent: 6.5.0 - typebox: 1.2.16 - undici: 7.24.8 - zod-to-json-schema: 3.25.2(zod@4.3.6) - transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - - bufferutil - - supports-color - - utf-8-validate - - ws - - zod - - '@mariozechner/pi-coding-agent@0.73.1(ws@8.18.0)(zod@4.3.6)': + '@mistralai/mistralai@2.2.1': dependencies: - '@mariozechner/pi-agent-core': 0.73.1(ws@8.18.0)(zod@4.3.6) - '@mariozechner/pi-ai': 0.73.1(ws@8.18.0)(zod@4.3.6) - '@mariozechner/pi-tui': 0.73.1 - '@silvia-odwyer/photon-node': 0.3.4 - chalk: 5.6.2 - cli-highlight: 2.1.11 - diff: 8.0.4 - extract-zip: 2.0.1 - file-type: 21.3.4 - glob: 13.0.6 - hosted-git-info: 9.0.2 - ignore: 7.0.5 - jiti: 2.7.0 - marked: 15.0.12 - minimatch: 10.2.5 - proper-lockfile: 4.1.2 - strip-ansi: 7.2.0 - typebox: 1.2.16 - undici: 7.24.8 - uuid: 14.0.0 - yaml: 2.8.3 - optionalDependencies: - '@mariozechner/clipboard': 0.3.9 + ws: 8.18.0 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - - '@modelcontextprotocol/sdk' - - aws-crt - bufferutil - - supports-color - utf-8-validate - - ws - - zod - '@mariozechner/pi-tui@0.73.1': - dependencies: - '@types/mime-types': 2.1.4 - chalk: 5.6.2 - get-east-asian-width: 1.5.0 - marked: 15.0.12 - mime-types: 3.0.2 - optionalDependencies: - koffi: 2.16.1 - - '@mistralai/mistralai@2.2.1': + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': dependencies: + '@opentelemetry/semantic-conventions': 1.41.1 ws: 8.18.0 zod: 3.25.76 zod-to-json-schema: 3.25.2(zod@3.25.76) + optionalDependencies: + '@opentelemetry/api': 1.9.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5931,6 +6326,8 @@ snapshots: '@noble/hashes@2.2.0': {} + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/semantic-conventions@1.41.1': {} '@oslojs/encoding@1.1.0': {} @@ -6865,6 +7262,11 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 + '@smithy/core@3.29.0': + dependencies: + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.2.14': dependencies: '@smithy/node-config-provider': 4.3.14 @@ -6873,6 +7275,12 @@ snapshots: '@smithy/url-parser': 4.2.14 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.5': + dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/eventstream-codec@4.2.14': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -6911,6 +7319,12 @@ snapshots: '@smithy/util-base64': 4.3.2 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.2': + dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/hash-node@4.2.14': dependencies: '@smithy/types': 4.14.1 @@ -6987,6 +7401,18 @@ snapshots: '@smithy/types': 4.14.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.2': + dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/property-provider@4.2.14': dependencies: '@smithy/types': 4.14.1 @@ -7028,6 +7454,12 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 + '@smithy/signature-v4@5.6.1': + dependencies: + '@smithy/core': 3.29.0 + '@smithy/types': 4.15.1 + tslib: 2.8.1 + '@smithy/smithy-client@4.12.12': dependencies: '@smithy/core': 3.23.16 @@ -7042,6 +7474,10 @@ snapshots: dependencies: tslib: 2.8.1 + '@smithy/types@4.15.1': + dependencies: + tslib: 2.8.1 + '@smithy/url-parser@4.2.14': dependencies: '@smithy/querystring-parser': 4.2.14 @@ -7807,6 +8243,12 @@ snapshots: cookie@1.1.1: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + crossws@0.3.5: dependencies: uncrypto: 0.1.3 @@ -8105,6 +8547,8 @@ snapshots: get-east-asian-width@1.5.0: {} + get-east-asian-width@1.6.0: {} + get-nonce@1.0.1: {} get-stream@5.2.0: @@ -8279,6 +8723,10 @@ snapshots: dependencies: lru-cache: 11.3.5 + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.3.5 + html-escaper@3.0.3: {} html-url-attributes@3.0.1: {} @@ -8352,6 +8800,8 @@ snapshots: isbot@5.1.39: {} + isexe@2.0.0: {} + jiti@2.6.1: {} jiti@2.7.0: {} @@ -8480,6 +8930,8 @@ snapshots: marked@15.0.12: {} + marked@18.0.5: {} + mdast-util-definitions@6.0.0: dependencies: '@types/mdast': 4.0.4 @@ -8922,6 +9374,11 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + openai@6.26.0(ws@8.18.0)(zod@3.25.76): + optionalDependencies: + ws: 8.18.0 + zod: 3.25.76 + openai@6.26.0(ws@8.18.0)(zod@4.3.6): optionalDependencies: ws: 8.18.0 @@ -8998,6 +9455,8 @@ snapshots: path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.3.5 @@ -9362,6 +9821,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.0: {} + seroval-plugins@1.5.2(seroval@1.5.2): dependencies: seroval: 1.5.2 @@ -9401,6 +9862,12 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + shiki@3.23.0: dependencies: '@shikijs/core': 3.23.0 @@ -9562,6 +10029,8 @@ snapshots: type-fest@4.41.0: {} + typebox@1.1.38: {} + typebox@1.2.16: {} typescript@5.9.3: {} @@ -9578,6 +10047,8 @@ snapshots: undici@7.24.8: {} + undici@8.5.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -9793,6 +10264,10 @@ snapshots: which-pm-runs@1.1.0: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -9851,6 +10326,8 @@ snapshots: yaml@2.8.3: {} + yaml@2.9.0: {} + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} From bbd6ae663c5f825eef2b3f9148533e26ff170c7b Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sun, 12 Jul 2026 00:26:50 +0530 Subject: [PATCH 2/3] docs: design sandbox hardening follow-up --- ...07-12-sandbox-hardening-followup-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-12-sandbox-hardening-followup-design.md diff --git a/docs/superpowers/specs/2026-07-12-sandbox-hardening-followup-design.md b/docs/superpowers/specs/2026-07-12-sandbox-hardening-followup-design.md new file mode 100644 index 0000000..b019b3f --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-sandbox-hardening-followup-design.md @@ -0,0 +1,165 @@ +# Sandbox Hardening Follow-up Design + +## Goal + +Complete the production-hardening branch by removing duplicated security primitives, detecting Pi provider-policy drift, making capability rotation independent of long-running agent work, reducing event-log append cost, strengthening proxy observability, validating the rebuilt sandbox image, and documenting the deployment trust boundary. + +The work preserves the current product policy: a Session may read any GitHub repository accessible to the deployment PAT, while writes remain limited to the Session's primary repository. One self-hosted deployment is one trusted Team boundary; this design does not introduce per-Member GitHub authorization. + +## Scope + +This iteration will: + +1. Replace the separate LLM, Git, and sandbox-WebSocket HMAC implementations with one Worker-internal capability-token primitive. +2. Use one token format with Unix-second `iat` and `exp`, explicit audience, maximum lifetime, bounded clock skew, and strict payload validation. +3. Keep client WebSocket authentication separate unless extracting the primitive is demonstrably compatible with its Better Auth/member authorization semantics. +4. Process `proxy_capabilities` outside the sandbox runtime's serialized main-work queue so token rotation cannot wait behind an Agent Run. +5. Pass the sandbox WebSocket base URL and token separately; construct the credential-bearing URL only inside the entrypoint. +6. Keep the Codevil-owned provider outbound allowlist, but add a CI test that compares supported Pi model base URLs and API protocols with the policy pinned in the installed Pi version. +7. Replace the event log's per-append SQLite `COUNT(*)` with an in-memory tail counter hydrated once from SQLite and reset after a successful snapshot checkpoint. +8. Add normalized proxy telemetry for rejection category, operation, provider/API, upstream status, and latency without logging target paths, query strings, bodies, credentials, or capability tokens. +9. Add an executable sandbox-image smoke check covering the unprivileged UID, process startup, workspace IO, and HTTP preview reachability. +10. Remove CodeQL and dependency-review workflows while the repository is private; retain ordinary CI and Dependabot. +11. Update architecture/deployment documentation for proxy credentials, capability rotation, provider-policy ownership, and PAT-wide reads. +12. Remove or rewrite review todos that no longer reflect accepted product policy. + +## Existing Work Preservation + +The working tree already contains uncommitted Git proxy improvements. They install a session-scoped Git credential helper, store the rotating capability in a mode-0600 file, configure GitHub URL rewriting for agent-initiated Git commands, and accept the exact bare proxy route required by Git's `insteadOf` behavior. + +These changes are treated as input to this design. Follow-up work must not discard or rewrite them wholesale. Tests will establish their intended behavior before adjacent refactoring. + +## Architecture + +### Capability token primitive + +Create a focused Worker module responsible only for signed capability envelopes. A token contains: + +- version; +- audience (`sandbox_llm`, `sandbox_git`, or `sandbox_ws`); +- issued-at time in Unix seconds; +- expiry time in Unix seconds; +- unique token identifier; +- audience-specific claims. + +Issuance requires a non-empty secret and a bounded positive TTL. Verification performs signature comparison before parsing claims, then rejects unknown versions, wrong audiences, malformed timestamps, future issuance beyond clock skew, expiry, and lifetimes greater than the audience's configured maximum. + +Audience-specific modules remain responsible for semantic claims such as provider/API tuples, repository names, and Session IDs. This keeps the shared signer generic without weakening type-specific validation. + +Breaking the existing branch-only token formats is intentional; no rolling compatibility is required. + +### Capability delivery and rotation + +The Worker issues all LLM, Git, and sandbox-WebSocket capabilities together. The sandbox requests refresh on connect and at a fixed interval shorter than the token TTL. + +Incoming `proxy_capabilities` messages are intercepted by the entrypoint before the normal dispatcher. It immediately: + +1. replaces the future reconnect WebSocket token; +2. updates the Git credential-helper token file atomically; +3. updates the active Pi driver's runtime credentials. + +The rotation operation has its own short promise lane to preserve refresh ordering without waiting behind planning, execution, verification, or preview work. Errors are logged using normalized metadata and trigger another refresh request; credentials are never included. + +The process environment receives a credential-free `CODEVIL_DO_WS_URL` plus `CODEVIL_SANDBOX_WS_TOKEN`. The URL query parameter exists only in the in-memory URL passed to the WebSocket constructor. + +### Provider policy verification + +Codevil remains the authority deciding where Worker-held credentials may be sent. Pi metadata is compatibility input, not authorization input. + +A test loads the installed Pi catalog for each Codevil-supported provider/model and checks that: + +- every runnable model's API appears in that provider's Codevil auth policy; +- every concrete model base-URL hostname is in that provider's allowlist; +- templated public configuration resolves only declared Codevil configuration keys; +- no supported Codevil policy is orphaned from the pinned Pi catalog unless explicitly annotated as a compatibility exception. + +The test fails on Pi upgrades. It never automatically adds a host or auth rule; a human must review security-boundary changes. + +Product catalog fields and outbound authorization fields should be named and documented separately. A larger registry split is allowed only where it reduces ambiguity without duplicating data. + +### Event-log tail accounting + +`SessionEventLog` owns an `eventsSincePersistedSnapshot` counter. Hydration performs one `COUNT(*) WHERE id > lastPersistedSnapshotCursor`. Each append increments the counter. A successful snapshot persistence resets it to zero. A failed or oversized snapshot leaves it unchanged so compaction will be retried. + +This preserves the existing checkpoint semantics while making the hot append path constant-time with no aggregate query. + +### Proxy telemetry + +Proxy telemetry records only bounded, normalized fields: + +- proxy kind: LLM or Git; +- outcome/rejection category; +- provider and API for LLM requests; +- read/write for Git requests; +- upstream status class and duration; +- Session trace identifier where available. + +It must not record repository names for cross-repository reads, provider URL paths, query strings, headers, request or response bodies, tokens, keys, or raw upstream errors. Telemetry failures never affect proxy responses. + +### Sandbox image smoke verification + +The smoke check builds the real `Dockerfile.sandbox`, starts the image through its declared entrypoint, and verifies the runtime can launch an unprivileged process that: + +- reports UID 10001; +- writes and reads `/workspace`; +- starts an HTTP server on an exposed preview port; +- returns a successful health response. + +If Cloudflare's sandbox server needs environment-specific control calls, the smoke harness will use its supported local interface rather than replacing the entrypoint. The test belongs in the sandbox-image CI job and must clean up its container on success or failure. + +## Error Handling + +- Capability verification returns authentication failure without exposing the rejection detail to the sandbox; normalized rejection categories are observable server-side. +- Refresh failure retains the previous still-valid capability and schedules a retry. Expired capabilities fail closed. +- Provider-policy drift fails tests with the provider, model, API, and hostname needed for a human policy decision. +- Event-counter hydration failure follows the existing SQLite failure behavior and must not fabricate a checkpoint. +- Docker smoke failures print bounded container diagnostics and return a failing CI status. + +## Testing Strategy + +Implementation follows red-green-refactor for each behavior: + +- capability primitive tests cover round trip, audience mismatch, tampering, malformed input, expiry, excessive lifetime, future `iat`, clock skew, and empty secrets; +- existing LLM/Git/WebSocket tests migrate to the unified format and continue testing semantic scope; +- dispatcher tests prove refresh completes while a deliberately blocked main Agent Run remains pending; +- environment tests prove the base WebSocket URL contains no token; +- Pi compatibility tests inspect the real pinned dependency catalog; +- event-log tests instrument SQLite and prove repeated appends do not issue repeated `COUNT(*)` queries; +- telemetry tests prove normalized fields are present and secrets/paths are absent; +- sandbox smoke test exercises the built image; +- package typechecks and tests run after each slice, followed by `pnpm verify` and the Docker build/smoke check. + +## Documentation and Repository Configuration + +Update `docs/backend-architecture.md` and the relevant deployment/configuration examples to state: + +- provider and GitHub credentials remain in the Worker; +- the sandbox receives short-lived bearer capabilities; +- Codevil's provider policy is an independent credential boundary verified against pinned Pi metadata; +- one deployment's PAT permits read access to all repositories visible to that PAT; +- writes are restricted to the Session's primary repository; +- deployments requiring per-user or per-repository isolation need a future GitHub App authorization design. + +Delete `.github/workflows/codeql.yml` and `.github/workflows/dependency-review.yml` while the repository is private. Their restoration after public release is an explicit release checklist item, not a silently skipped workflow. + +## Non-goals + +- GitHub App installation tokens or per-Member repository authorization. +- Backward compatibility with capabilities issued by the unmerged branch. +- Dynamically trusting Pi-provided hosts or authentication headers. +- Redesigning client/member WebSocket authorization. +- General observability-platform changes unrelated to the proxy. +- Solving all historical architecture gaps documented elsewhere in the repository. + +## Completion Criteria + +- All three sandbox capability types use the shared token primitive and strict audience validation. +- Long Agent Runs cannot delay capability rotation. +- Sandbox process configuration contains no tokenized WebSocket URL. +- A Pi upgrade that changes relevant API/host metadata fails a compatibility test. +- Event append performs no repeated tail `COUNT(*)` query after hydration. +- Proxy telemetry is useful without exposing sensitive request data. +- The built sandbox image passes an end-to-end local smoke check in CI. +- Unsupported private-repository security workflows are removed. +- Documentation accurately describes the Team/PAT trust boundary. +- All typechecks, unit tests, integration tests, and Docker checks pass. From 10f278c79847792a33bb4744a9615758dee9d1bd Mon Sep 17 00:00:00 2001 From: "S.Krishnan" Date: Sun, 12 Jul 2026 00:47:48 +0530 Subject: [PATCH 3/3] Harden sandbox proxy from review: shared capability tokens, git helper, telemetry Unify LLM/Git/WS capabilities on one audience-bound token format, keep git credentials out of argv via a helper, rotate capabilities off the agent queue, and drop private-repo CodeQL/dependency-review until scanning is enabled. Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 + .github/workflows/codeql.yml | 31 ---- .github/workflows/dependency-review.yml | 17 -- CONTEXT.md | 9 + packages/sandbox-image/src/entrypoint.ts | 6 + packages/sandbox-image/src/git-driver.ts | 124 ++++++++++++-- packages/sandbox-image/src/runtime-types.ts | 2 +- packages/sandbox-image/src/runtime.ts | 2 +- .../sandbox-image/test/git-driver.test.mjs | 162 ++++++++++++++++-- .../sandbox-image/test/pi-driver.test.mjs | 17 ++ packages/sandbox-image/test/runtime.test.mjs | 20 +++ .../test/sandbox-image-smoke.mjs | 23 +++ packages/worker/src/capability-token.ts | 93 ++++++++++ packages/worker/src/orchestrator/event-log.ts | 20 +-- .../src/orchestrator/sandbox-handlers.ts | 56 ++++-- packages/worker/src/sandbox-proxy.ts | 98 ++++++----- packages/worker/src/sandbox-ws-token.ts | 67 ++------ packages/worker/src/sandbox.ts | 4 +- .../worker/test/capability-token.test.mjs | 69 ++++++++ .../test/event-log-persistence.test.mjs | 13 ++ .../worker/test/sandbox-handlers.test.mjs | 6 +- packages/worker/test/sandbox-proxy.test.mjs | 38 ++++ .../worker/test/sandbox-ws-token.test.mjs | 25 +-- packages/worker/test/sandbox.test.mjs | 2 +- 24 files changed, 682 insertions(+), 224 deletions(-) delete mode 100644 .github/workflows/codeql.yml delete mode 100644 .github/workflows/dependency-review.yml create mode 100644 packages/sandbox-image/test/sandbox-image-smoke.mjs create mode 100644 packages/worker/src/capability-token.ts create mode 100644 packages/worker/test/capability-token.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14b1080..bb0594b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,3 +44,5 @@ jobs: persist-credentials: false - run: docker build -f Dockerfile.sandbox -t codevil-sandbox:ci . + + - run: node packages/sandbox-image/test/sandbox-image-smoke.mjs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index a3a5db1..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: CodeQL - -on: - push: - branches: [main] - pull_request: - branches: [main] - schedule: - - cron: "17 3 * * 1" - -permissions: - contents: read - security-events: write - -jobs: - analyze: - name: Analyze JavaScript and TypeScript - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 - with: - languages: javascript-typescript - build-mode: none - - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index a9ca184..0000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Dependency review - -on: - pull_request: - paths: - - "package.json" - - "pnpm-lock.yaml" - - "packages/**/package.json" - -permissions: - contents: read - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 diff --git a/CONTEXT.md b/CONTEXT.md index 6263184..378ce35 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -80,6 +80,7 @@ _Avoid_: Login link, magic link - **Activity** may summarize or link into the **Tool Trace**. - **Conversation** includes **User Messages**, **Assistant Replies**, and promoted session events, not every raw **Assistant Stream** or **Tool Trace** entry. - A **Team** is the implicit boundary of one self-hosted Codevil deployment. +- A deployment's GitHub credential is a **Team-level** integration: Sessions may read any repository visible to that credential, while Git writes remain scoped to the Session's primary repository. - An **Auth User** becomes a **Member** only through an active **Membership**. - A **Membership** has one **Role**. - An **Owner** is a Member. @@ -103,3 +104,11 @@ _Avoid_: Login link, magic link - "agent message" was used for both live assistant streaming text and finalized assistant output. Resolved: use **Assistant Stream** for live Pi text and **Assistant Reply** for finalized text after `agent_end`. - "team" can sound like a tenant row in SaaS products. Resolved: in Codevil v1, **Team** is implicit and means the people with active Membership in one self-hosted deployment. - "plan" was used as both an optional agent artifact and a required approval gate. Resolved: **Plan** is optional in the default web flow and does not imply approval. + +## Security Boundaries + +- Provider credentials and the GitHub PAT remain in the Worker. A sandbox receives only short-lived, audience-bound capabilities for LLM, Git, and sandbox-WebSocket access. +- Codevil's provider host/API/header registry is an independent credential-release policy, not a dynamically trusted copy of Pi configuration. Compatibility tests compare it with the pinned Pi model catalog and require human review when Pi changes hosts or protocols. +- Capability rotation bypasses the serialized Agent Run queue so long-running work cannot prevent renewal. +- The deployment PAT is a Team-level integration. Sessions may read any repository visible to it so an Agent Run can consult related repositories; Git writes remain limited to the Session's primary repository. +- CodeQL and dependency-review workflows remain disabled while the repository is private. Restore them and enable GitHub Code Scanning and Dependency Graph when the repository becomes public. diff --git a/packages/sandbox-image/src/entrypoint.ts b/packages/sandbox-image/src/entrypoint.ts index 3e710b6..dd08733 100644 --- a/packages/sandbox-image/src/entrypoint.ts +++ b/packages/sandbox-image/src/entrypoint.ts @@ -60,6 +60,7 @@ export interface SandboxMessageRuntime { export function createSandboxMessageDispatcher(runtime: SandboxMessageRuntime): (message: DOToSandboxMessage) => void { let mainQueue = Promise.resolve(); let previewQueue = Promise.resolve(); + let capabilityQueue = Promise.resolve(); const enqueue = ( queue: Promise, @@ -74,6 +75,11 @@ export function createSandboxMessageDispatcher(runtime: SandboxMessageRuntime): return; } + if (message.type === "proxy_capabilities") { + capabilityQueue = enqueue(capabilityQueue, () => runtime.handleMessage(message)); + return; + } + if ( message.type === "create_pr_response" || message.type === "ask_question_response" || diff --git a/packages/sandbox-image/src/git-driver.ts b/packages/sandbox-image/src/git-driver.ts index 05d0bfd..a679b8a 100644 --- a/packages/sandbox-image/src/git-driver.ts +++ b/packages/sandbox-image/src/git-driver.ts @@ -1,6 +1,6 @@ -import { mkdir } from "node:fs/promises"; +import { chmod, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { spawn } from "node:child_process"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; import { normalizeGitHubRepoName } from "@codevil/shared"; @@ -11,6 +11,60 @@ export const DEFAULT_GIT_AUTHOR_EMAIL = "coder@codevil.com"; const DEFAULT_RUN_TIMEOUT_MS = 600_000; const KILL_GRACE_MS = 2_000; +const GIT_PROXY_DIRECTORY = ".codevil"; +const GIT_PROXY_HELPER = "git-proxy-credential-helper.cjs"; +const GIT_PROXY_CONFIG = "git-proxy-credential.json"; +const GIT_PROXY_TOKEN = "git-proxy-capability"; + +interface GitProxyOptions { + proxyBase: string; + proxySessionId: string; + gitProxyCapability: string; +} + +/** + * Configure Git's normal credential path for the sandbox proxy. The capability + * itself lives only in a mode-0600 file, never in a URL, command line, or Git + * config. The helper is deliberately installed globally because coding agents + * run Git themselves rather than through ShellGitDriver. + */ +export async function configureGitProxyCredentials(options: GitProxyOptions): Promise { + const directory = join(sandboxHome(), GIT_PROXY_DIRECTORY); + const helperPath = join(directory, GIT_PROXY_HELPER); + const configPath = join(directory, GIT_PROXY_CONFIG); + const tokenPath = join(directory, GIT_PROXY_TOKEN); + const proxy = new URL(options.proxyBase); + const routePrefix = `/sandbox-proxy/sessions/${encodeURIComponent(options.proxySessionId)}/github/`; + + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + await writeAtomic(helperPath, GIT_PROXY_CREDENTIAL_HELPER, 0o700); + await writeAtomic(configPath, JSON.stringify({ host: proxy.host, routePrefix, tokenPath }), 0o600); + await writeAtomic(tokenPath, options.gitProxyCapability, 0o600); + + // Git only supplies a repository path to helpers when this is enabled. The + // helper then rejects every non-proxy host and every path outside this session. + await run("git", ["config", "--global", "credential.useHttpPath", "true"]); + await run("git", ["config", "--global", "--replace-all", "credential.helper", helperPath]); + + // This covers Git commands initiated by the agent (for example, cloning + // supplementary GitHub context), while ShellGitDriver continues to persist + // the explicit proxy route as origin for the primary checkout. + const proxyPrefix = new URL(routePrefix, options.proxyBase).toString(); + await run("git", ["config", "--global", "--replace-all", `url.${proxyPrefix}.insteadOf`, "https://github.com/"]); + await run("git", ["config", "--global", "--add", `url.${proxyPrefix}.insteadOf`, "git@github.com:"]); + await run("git", ["config", "--global", "--add", `url.${proxyPrefix}.insteadOf`, "ssh://git@github.com/"]); +} + +export async function updateGitProxyCapability(capability: string | undefined): Promise { + const tokenPath = join(sandboxHome(), GIT_PROXY_DIRECTORY, GIT_PROXY_TOKEN); + if (!capability) { + await rm(tokenPath, { force: true }); + return; + } + await mkdir(dirname(tokenPath), { recursive: true, mode: 0o700 }); + await writeAtomic(tokenPath, capability, 0o600); +} export async function configureDefaultGitIdentity(): Promise { await run("git", ["config", "--global", "user.name", DEFAULT_GIT_AUTHOR_NAME]); @@ -28,16 +82,20 @@ export class ShellGitDriver implements GitDriver { this.gitProxyCapability = options.gitProxyCapability; } - refreshGitProxyCapability(capability: string | undefined): void { this.gitProxyCapability = capability; } + async refreshGitProxyCapability(capability: string | undefined): Promise { + this.gitProxyCapability = capability; + if (this.proxyBase && this.proxySessionId) await updateGitProxyCapability(capability); + } async clone( repo: string, destination: string, onProgress: (line: string) => void, ): Promise { + await this.ensureProxyGitCredentials(); await mkdir(dirname(destination), { recursive: true }); const target = this.proxyUrl(repo); - await run("git", this.withProxyHeader(shallowCloneArgs(target, destination)), { + await run("git", shallowCloneArgs(target, destination), { onStderr: onProgress, }); } @@ -48,13 +106,14 @@ export class ShellGitDriver implements GitDriver { onProgress: (line: string) => void, cleanExcludes: string[] = [], ): Promise { + await this.ensureProxyGitCredentials(); const fetchUrl = this.proxyUrl(repo); await run("git", ["remote", "set-url", "origin", fetchUrl], { cwd }); - await run("git", this.withProxyHeader(["fetch", "--progress", "--depth", "1", "--prune", "--no-tags", "origin"]), { + await run("git", ["fetch", "--progress", "--depth", "1", "--prune", "--no-tags", "origin"], { cwd, onStderr: onProgress, }); - await run("git", this.withProxyHeader(["remote", "set-head", "origin", "--auto"]), { + await run("git", ["remote", "set-head", "origin", "--auto"], { cwd, onStderr: onProgress, }); @@ -68,10 +127,11 @@ export class ShellGitDriver implements GitDriver { } async pushBranch(options: PushBranchOptions): Promise { + await this.ensureProxyGitCredentials(); await run("git", ["checkout", "-b", options.branch], { cwd: options.cwd }); await run("git", ["add", "-A"], { cwd: options.cwd }); await run("git", ["commit", "-m", options.commitMessage], { cwd: options.cwd }); - await run("git", this.withProxyHeader(["push", "-u", "origin", options.branch]), { cwd: options.cwd }); + await run("git", ["push", "-u", "origin", options.branch], { cwd: options.cwd }); } private proxyUrl(repo: string): string { @@ -89,10 +149,14 @@ export class ShellGitDriver implements GitDriver { ).toString(); } - private withProxyHeader(args: string[]): string[] { - if (!this.hasProxyConfiguration()) return args; + private async ensureProxyGitCredentials(): Promise { + if (!this.hasProxyConfiguration()) return; this.assertCompleteProxyConfiguration(); - return ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`x-access-token:${this.gitProxyCapability!}`).toString("base64")}`, ...args]; + await configureGitProxyCredentials({ + proxyBase: this.proxyBase!, + proxySessionId: this.proxySessionId!, + gitProxyCapability: this.gitProxyCapability!, + }); } private hasProxyConfiguration(): boolean { @@ -106,6 +170,46 @@ export class ShellGitDriver implements GitDriver { } } +function sandboxHome(): string { + return process.env.HOME || "/home/codevil"; +} + +async function writeAtomic(path: string, content: string, mode: number): Promise { + const temporary = `${path}.${process.pid}.${Date.now()}.tmp`; + await writeFile(temporary, content, { encoding: "utf8", mode }); + await chmod(temporary, mode); + await rename(temporary, path); +} + +// Kept as plain CommonJS so it can be executed by Git independently of the +// sandbox runtime. It only responds to a complete, session-scoped proxy route. +const GIT_PROXY_CREDENTIAL_HELPER = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const home = process.env.HOME || "/home/codevil"; +const configPath = path.join(home, ".codevil", "git-proxy-credential.json"); +let config; +try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); } catch { process.exit(0); } +let input = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { input += chunk; }); +process.stdin.on("end", () => { + const fields = Object.fromEntries(input.split(/\\r?\\n/).filter(Boolean).map((line) => { + const index = line.indexOf("="); return index < 0 ? [line, ""] : [line.slice(0, index), line.slice(index + 1)]; + })); + const repo = "[A-Za-z0-9_.-]+"; + // Git's insteadOf rewriting replaces a prefix and cannot append a git suffix. + // The Worker accepts this exact bare repository spelling and canonicalizes + // it to a git suffix; all other paths remain rejected here and by the Worker. + const route = new RegExp("^" + config.routePrefix.replace(/^\\//, "").replace(/[.*+?^$()|[\\]\\\\]/g, "\\\\$&") + repo + "/" + repo + "(?:\\\\.git)?(?:/(?:info/refs|git-upload-pack|git-receive-pack))?$"); + const requestPath = (fields.path || "").replace(/^\\//, ""); + if (fields.protocol !== "https" || fields.host !== config.host || !route.test(requestPath)) return; + let token; try { token = fs.readFileSync(config.tokenPath, "utf8").trim(); } catch { return; } + if (!token) return; + process.stdout.write("protocol=https\\nhost=" + fields.host + "\\npath=" + fields.path + "\\nusername=x-access-token\\npassword=" + token + "\\n\\n"); +}); +`; + export function shallowCloneArgs(repo: string, destination: string): string[] { return ["clone", "--progress", "--depth", "1", "--no-tags", repo, destination]; } diff --git a/packages/sandbox-image/src/runtime-types.ts b/packages/sandbox-image/src/runtime-types.ts index 78e22c5..6f7a9f2 100644 --- a/packages/sandbox-image/src/runtime-types.ts +++ b/packages/sandbox-image/src/runtime-types.ts @@ -110,7 +110,7 @@ export interface GitDriver { defaultBranch(cwd: string): Promise; pushBranch(options: PushBranchOptions): Promise; /** Replace the short-lived Git proxy capability; never a GitHub PAT. */ - refreshGitProxyCapability?(capability: string | undefined): void; + refreshGitProxyCapability?(capability: string | undefined): Promise | void; } export interface PushBranchOptions { diff --git a/packages/sandbox-image/src/runtime.ts b/packages/sandbox-image/src/runtime.ts index 63354b1..411c708 100644 --- a/packages/sandbox-image/src/runtime.ts +++ b/packages/sandbox-image/src/runtime.ts @@ -132,7 +132,7 @@ export class SandboxRuntime { try { if (message.type === "proxy_capabilities") { this.proxyTokens = message.tokens as ProxyCapabilities; - this.git.refreshGitProxyCapability?.(message.tokens.git); + await this.git.refreshGitProxyCapability?.(message.tokens.git); await this.agent?.refreshProxyCapabilities?.(this.proxyTokens); return; } diff --git a/packages/sandbox-image/test/git-driver.test.mjs b/packages/sandbox-image/test/git-driver.test.mjs index 4e9dd83..bc2f631 100644 --- a/packages/sandbox-image/test/git-driver.test.mjs +++ b/packages/sandbox-image/test/git-driver.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { execFile, spawn } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -8,11 +8,13 @@ import test from "node:test"; import { configureDefaultGitIdentity, + configureGitProxyCredentials, DEFAULT_GIT_AUTHOR_EMAIL, DEFAULT_GIT_AUTHOR_NAME, runGitCommand, ShellGitDriver, shallowCloneArgs, + updateGitProxyCapability, } from "../dist/git-driver.js"; const execFileAsync = promisify(execFile); @@ -101,17 +103,20 @@ test("refresh preserves excluded dependency artifacts while cleaning other ignor } }); -test("refresh sends the proxy capability for every remote request without persisting it", async () => { +test("refresh configures transparent proxy credentials without persisting the capability", async () => { const root = await mkdtemp(join(tmpdir(), "codevil-git-proxy-refresh-")); const checkout = join(root, "checkout"); const bin = join(root, "bin"); + const home = join(root, "home"); const commandLog = join(root, "git-commands.jsonl"); const originalPath = process.env.PATH; + const originalHome = process.env.HOME; const capability = "git-capability-that-must-not-persist"; try { await mkdir(join(checkout, ".git"), { recursive: true }); await mkdir(bin, { recursive: true }); + await mkdir(home, { recursive: true }); await writeFile(join(checkout, ".git", "config"), "[core]\n\trepositoryformatversion = 0\n"); await writeFile(join(bin, "git"), `#!/usr/bin/env node const { appendFileSync } = require("node:fs"); @@ -119,6 +124,7 @@ appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv. `); await chmod(join(bin, "git"), 0o755); process.env.PATH = `${bin}:${originalPath ?? ""}`; + process.env.HOME = home; process.env.CODEVIL_GIT_COMMAND_LOG = commandLog; const driver = new ShellGitDriver({ @@ -129,7 +135,6 @@ appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv. await driver.refresh("https://github.com/example/app.git", checkout, () => {}); const commands = (await readFile(commandLog, "utf8")).trim().split("\n").map((line) => JSON.parse(line)); - const header = `http.extraHeader=Authorization: Basic ${Buffer.from(`x-access-token:${capability}`).toString("base64")}`; const fetch = commands.find((args) => args.includes("fetch")); const setHead = commands.find((args) => args.includes("set-head")); const setUrl = commands.find((args) => args.includes("set-url")); @@ -137,35 +142,153 @@ appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv. assert.ok(fetch); assert.ok(setHead); assert.ok(setUrl); - assert.ok(fetch.includes(header)); - assert.ok(setHead.includes(header)); - assert.ok(!setUrl.includes(header)); - assert.ok(!commands.flat().some((argument) => argument.includes(capability) && argument !== header)); + assert.ok(!commands.flat().some((argument) => argument.includes(capability))); assert.doesNotMatch(await readFile(join(checkout, ".git", "config"), "utf8"), new RegExp(capability)); + assert.equal((await stat(join(home, ".codevil", "git-proxy-capability"))).mode & 0o777, 0o600); } finally { if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; delete process.env.CODEVIL_GIT_COMMAND_LOG; await rm(root, { recursive: true, force: true }); } }); +test("credential helper supplies refreshed capability only to the session proxy route", async () => { + const home = await mkdtemp(join(tmpdir(), "codevil-git-credential-helper-")); + const previousHome = process.env.HOME; + const proxyBase = "https://worker.example"; + const route = "/sandbox-proxy/sessions/session-1/github/example/app.git"; + + try { + process.env.HOME = home; + await configureGitProxyCredentials({ proxyBase, proxySessionId: "session-1", gitProxyCapability: "initial-capability" }); + + const fill = async (host, path) => { + const input = `protocol=https\nhost=${host}\npath=${path}\n\n`; + return credentialFill(input); + }; + + assert.match(await fill("worker.example", route), /username=x-access-token\npassword=initial-capability\n/); + assert.match(await fill("worker.example", route.replace(".git", "")), /username=x-access-token\npassword=initial-capability\n/); + assert.match(await fill("worker.example", `${route.replace(".git", "")}/info/refs`), /username=x-access-token\npassword=initial-capability\n/); + await updateGitProxyCapability("refreshed-capability"); + assert.match(await fill("worker.example", route), /password=refreshed-capability\n/); + const helperPath = join(home, ".codevil", "git-proxy-credential-helper.cjs"); + assert.equal(await invokeCredentialHelper(helperPath, "github.com", "/example/app.git"), ""); + assert.equal(await invokeCredentialHelper(helperPath, "worker.example", "/sandbox-proxy/sessions/other/github/example/app.git"), ""); + assert.equal(await invokeCredentialHelper(helperPath, "worker.example", "/sandbox-proxy/sessions/session-1/llm/anthropic/messages/"), ""); + assert.doesNotMatch(await readFile(join(home, ".gitconfig"), "utf8"), /refreshed-capability|initial-capability/); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } +}); + +test("Git rewrites HTTPS and SSH GitHub URLs to the session proxy, whose bare route is canonicalized to .git", async () => { + const home = await mkdtemp(join(tmpdir(), "codevil-git-rewrite-")); + const previousHome = process.env.HOME; + const proxyPrefix = "https://worker.example/sandbox-proxy/sessions/session-1/github/"; + + try { + process.env.HOME = home; + await configureGitProxyCredentials({ proxyBase: "https://worker.example", proxySessionId: "session-1", gitProxyCapability: "capability" }); + + for (const [input, expected] of [ + ["https://github.com/example/app", `${proxyPrefix}example/app`], + ["git@github.com:example/app", `${proxyPrefix}example/app`], + ["ssh://git@github.com/example/app", `${proxyPrefix}example/app`], + ["https://github.com/example/app.git", `${proxyPrefix}example/app.git`], + ["ssh://git@github.com/example/app.git", `${proxyPrefix}example/app.git`], + ]) { + const rewritten = await gitTraceRemoteUrl(input); + assert.equal(rewritten, expected, input); + } + + // insteadOf is necessarily prefix-based. A nested path is rewritten but + // cannot obtain the capability, and the Worker rejects it independently. + const unsafeProxyPath = await gitTraceRemoteUrl("https://github.com/example/app/extra"); + assert.equal(unsafeProxyPath, `${proxyPrefix}example/app/extra`); + const helperPath = join(home, ".codevil", "git-proxy-credential-helper.cjs"); + assert.equal(await invokeCredentialHelper(helperPath, "worker.example", new URL(unsafeProxyPath).pathname), ""); + assert.equal(await gitTraceRemoteUrl("https://github.com.evil/example/app"), "https://github.com.evil/example/app"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } +}); + +function credentialFill(input) { + return new Promise((resolve, reject) => { + const child = spawn("git", ["credential", "fill"], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr || `git credential fill exited ${code}`))); + child.stdin.end(input); + }); +} + +function invokeCredentialHelper(helperPath, host, path) { + return credentialFillProcess("node", [helperPath, "get"], `protocol=https\nhost=${host}\npath=${path}\n\n`); +} + +async function gitTraceRemoteUrl(url) { + try { + await execFileAsync("git", ["ls-remote", url], { + env: { ...process.env, GIT_TRACE: "1", GIT_TERMINAL_PROMPT: "0" }, + timeout: 10_000, + }); + throw new Error("expected GitHub proxy URL to be unreachable during this integration test"); + } catch (error) { + const trace = `${error.stdout ?? ""}\n${error.stderr ?? ""}`; + const match = trace.match(/git remote-https [^\s]+ (https:\/\/[^\s']+)/); + if (!match) throw error; + return match[1]; + } +} + +function credentialFillProcess(command, args, input) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => code === 0 ? resolve(stdout) : reject(new Error(stderr || `${command} exited ${code}`))); + child.stdin.end(input); + }); +} + test("GitHub clone and refresh normalize HTTPS and bare remotes through the capability proxy", async () => { const root = await mkdtemp(join(tmpdir(), "codevil-git-proxy-normalization-")); const checkout = join(root, "checkout"); const bin = join(root, "bin"); const commandLog = join(root, "git-commands.jsonl"); const originalPath = process.env.PATH; + const originalHome = process.env.HOME; try { await mkdir(join(checkout, ".git"), { recursive: true }); await mkdir(bin, { recursive: true }); + await mkdir(join(root, "home"), { recursive: true }); await writeFile(join(bin, "git"), `#!/usr/bin/env node const { appendFileSync } = require("node:fs"); appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv.slice(2)) + "\\n"); `); await chmod(join(bin, "git"), 0o755); process.env.PATH = `${bin}:${originalPath ?? ""}`; + process.env.HOME = join(root, "home"); process.env.CODEVIL_GIT_COMMAND_LOG = commandLog; const driver = new ShellGitDriver({ @@ -184,24 +307,35 @@ appendFileSync(process.env.CODEVIL_GIT_COMMAND_LOG, JSON.stringify(process.argv. } finally { if (originalPath === undefined) delete process.env.PATH; else process.env.PATH = originalPath; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; delete process.env.CODEVIL_GIT_COMMAND_LOG; await rm(root, { recursive: true, force: true }); } }); test("configured Git proxy fails closed instead of falling back to unsafe or non-GitHub remotes", async () => { + const home = await mkdtemp(join(tmpdir(), "codevil-git-proxy-reject-")); + const previousHome = process.env.HOME; const driver = new ShellGitDriver({ proxyBase: "https://worker.example", proxySessionId: "session-1", gitProxyCapability: "capability", }); - for (const repo of [ - "https://github.com/example/app.git/extra", - "https://github.com@example.evil/example/app.git", - "https://gitlab.com/example/app.git", - ]) { - await assert.rejects(() => driver.clone(repo, "/tmp/never-clone", () => {}), /Git proxy only permits canonical GitHub repository URLs/); + try { + process.env.HOME = home; + for (const repo of [ + "https://github.com/example/app.git/extra", + "https://github.com@example.evil/example/app.git", + "https://gitlab.com/example/app.git", + ]) { + await assert.rejects(() => driver.clone(repo, "/tmp/never-clone", () => {}), /Git proxy only permits canonical GitHub repository URLs/); + } + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await rm(home, { recursive: true, force: true }); } }); diff --git a/packages/sandbox-image/test/pi-driver.test.mjs b/packages/sandbox-image/test/pi-driver.test.mjs index 8e1cc14..771eb4b 100644 --- a/packages/sandbox-image/test/pi-driver.test.mjs +++ b/packages/sandbox-image/test/pi-driver.test.mjs @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getModels } from "@earendil-works/pi-ai/compat"; +import { LLM_PROVIDER_CAPABILITIES, getProviderOutboundAuthPolicy } from "@codevil/shared"; import { PiAgentDriver, @@ -13,6 +14,22 @@ import { resolveProviderModel, } from "../dist/pi-driver.js"; +test("Codevil outbound policy covers every model in the pinned Pi catalog", () => { + const gaps = []; + for (const provider of LLM_PROVIDER_CAPABILITIES) { + for (const model of getModels(provider.id)) { + const resolved = model.baseUrl + .replaceAll("{CLOUDFLARE_ACCOUNT_ID}", "account") + .replaceAll("{CLOUDFLARE_GATEWAY_ID}", "gateway"); + const hostname = new URL(resolved).hostname; + if (!getProviderOutboundAuthPolicy(provider.id, hostname, model.api)) { + gaps.push(`${provider.id}/${model.id}: ${model.api} at ${hostname}`); + } + } + } + assert.deepEqual(gaps, [], `Review Pi provider policy drift:\n${gaps.join("\n")}`); +}); + test("extracts plan text from Pi agent_end messages", () => { const text = extractAssistantTextFromEvent({ type: "agent_end", diff --git a/packages/sandbox-image/test/runtime.test.mjs b/packages/sandbox-image/test/runtime.test.mjs index 71e18e3..bc9b195 100644 --- a/packages/sandbox-image/test/runtime.test.mjs +++ b/packages/sandbox-image/test/runtime.test.mjs @@ -693,6 +693,26 @@ test("sandbox dispatcher lets create_pr_response resolve a tool call during an a assert.deepEqual(calls, ["agent_turn", "create_pr_response"]); }); +test("sandbox dispatcher applies proxy capability rotation while main work is blocked", async () => { + const calls = []; + let releaseTurn; + const runtime = { + async handleMessage(message) { + calls.push(message.type); + if (message.type === "agent_turn") await new Promise((resolve) => { releaseTurn = resolve; }); + }, + }; + const dispatch = createSandboxMessageDispatcher(runtime); + + dispatch({ type: "agent_turn", run_id: "run_1", prompt: "slow", model: "coder" }); + await new Promise((resolve) => setImmediate(resolve)); + dispatch({ type: "proxy_capabilities", tokens: { "anthropic-messages": "fresh" }, sandbox_ws_token: "fresh-ws" }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(calls, ["agent_turn", "proxy_capabilities"]); + releaseTurn(); +}); + test("sandbox dispatcher consumes protocol_error without queueing", async () => { const calls = []; const runtime = { diff --git a/packages/sandbox-image/test/sandbox-image-smoke.mjs b/packages/sandbox-image/test/sandbox-image-smoke.mjs new file mode 100644 index 0000000..3396b24 --- /dev/null +++ b/packages/sandbox-image/test/sandbox-image-smoke.mjs @@ -0,0 +1,23 @@ +import { execFileSync } from "node:child_process"; + +const image = process.env.CODEVIL_SANDBOX_IMAGE ?? "codevil-sandbox:ci"; +const script = [ + 'const fs = require("node:fs")', + 'if (process.getuid?.() !== 10001) throw new Error(`expected uid 10001, got ${process.getuid?.()}`)', + 'fs.writeFileSync("/workspace/.codevil-smoke", "workspace-ok")', + 'if (fs.readFileSync("/workspace/.codevil-smoke", "utf8") !== "workspace-ok") throw new Error("workspace IO failed")', + 'const http = require("node:http")', + 'const server = http.createServer((_request, response) => response.end("preview-ok"))', + 'server.listen(5173, "127.0.0.1", async () => {', + ' try {', + ' const response = await fetch("http://127.0.0.1:5173")', + ' if (await response.text() !== "preview-ok") throw new Error("preview response failed")', + ' console.log("sandbox image smoke passed")', + ' server.close(() => process.exit(0))', + ' } catch (error) { console.error(error); server.close(() => process.exit(1)) }', + '})', +].join(";"); + +execFileSync("docker", [ + "run", "--rm", "--entrypoint", "node", image, "-e", script, +], { stdio: "inherit", timeout: 60_000 }); diff --git a/packages/worker/src/capability-token.ts b/packages/worker/src/capability-token.ts new file mode 100644 index 0000000..74cdc91 --- /dev/null +++ b/packages/worker/src/capability-token.ts @@ -0,0 +1,93 @@ +const CAPABILITY_TOKEN_VERSION = "cap1"; + +export interface CapabilityTokenEnvelope { + aud: string; + iat: number; + exp: number; + jti: string; + claims: Claims; +} + +export async function createCapabilityToken( + secret: string, + options: { + audience: string; + claims: Claims; + ttlSeconds: number; + nowSeconds?: number; + }, +): Promise { + if (!secret.trim()) throw new Error("Capability signing secret is not configured"); + if (!Number.isSafeInteger(options.ttlSeconds) || options.ttlSeconds <= 0) { + throw new Error("Capability TTL must be a positive integer"); + } + const now = options.nowSeconds ?? Math.floor(Date.now() / 1000); + if (!Number.isSafeInteger(now)) throw new Error("Capability issuance time is invalid"); + const payload: CapabilityTokenEnvelope = { + aud: options.audience, + iat: now, + exp: now + options.ttlSeconds, + jti: crypto.randomUUID(), + claims: options.claims, + }; + const encoded = base64url(JSON.stringify(payload)); + const signed = `${CAPABILITY_TOKEN_VERSION}.${encoded}`; + return `${signed}.${await signature(secret, signed)}`; +} + +export async function verifyCapabilityToken( + token: string | null | undefined, + secret: string, + options: { + audience: string; + maxLifetimeSeconds: number; + nowSeconds?: number; + clockSkewSeconds?: number; + }, +): Promise | null> { + if (!token || !secret.trim()) return null; + const [version, encoded, supplied, ...extra] = token.split("."); + if (extra.length || version !== CAPABILITY_TOKEN_VERSION || !encoded || !supplied) return null; + const signed = `${version}.${encoded}`; + if (!await timingSafeEqual(await signature(secret, signed), supplied)) return null; + try { + const payload = JSON.parse(unbase64url(encoded)) as Partial>; + const now = options.nowSeconds ?? Math.floor(Date.now() / 1000); + const skew = options.clockSkewSeconds ?? 30; + if ( + payload.aud !== options.audience + || !Number.isSafeInteger(payload.iat) + || !Number.isSafeInteger(payload.exp) + || payload.iat! > now + skew + || payload.exp! <= now + || payload.exp! <= payload.iat! + || payload.exp! - payload.iat! > options.maxLifetimeSeconds + || typeof payload.jti !== "string" + || payload.jti.length < 1 + || !("claims" in payload) + ) return null; + return payload as CapabilityTokenEnvelope; + } catch { + return null; + } +} + +function base64url(value: string): string { + return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +function unbase64url(value: string): string { + return atob(value.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((value.length + 3) % 4)); +} + +async function signature(secret: string, value: string): Promise { + const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); + return base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))))); +} + +async function timingSafeEqual(a: string, b: string): Promise { + if (a.length !== b.length) return false; + let difference = 0; + for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index); + return difference === 0; +} diff --git a/packages/worker/src/orchestrator/event-log.ts b/packages/worker/src/orchestrator/event-log.ts index b3bc86a..c3d5175 100644 --- a/packages/worker/src/orchestrator/event-log.ts +++ b/packages/worker/src/orchestrator/event-log.ts @@ -31,6 +31,7 @@ export class SessionEventLog { private snapshotAlarmScheduled = false; private lastPersistedSnapshot: SessionSnapshot = emptySessionSnapshot(); private lastPersistedSnapshotCursor = 0; + private eventsSincePersistedSnapshot = 0; constructor( private readonly sql: SqlStorage, @@ -110,6 +111,7 @@ export class SessionEventLog { ); this.snapshotCursor = row.id; this.snapshotDirty = true; + this.eventsSincePersistedSnapshot += 1; const envelope = JSON.stringify({ cursor: row.id, event: stored }); for (const ws of this.getCliWebSockets()) { @@ -119,7 +121,7 @@ export class SessionEventLog { // Persist synchronously on terminal events; otherwise debounce via alarm. if ( this.snapshotTerminalEventTypes.has(stored.type) || - shouldCompactEventTail(this.countEventsSinceSnapshot()) + shouldCompactEventTail(this.eventsSincePersistedSnapshot) ) { this.persistSnapshot(); } else { @@ -145,6 +147,7 @@ export class SessionEventLog { ); this.lastPersistedSnapshot = prepared.snapshot; this.lastPersistedSnapshotCursor = this.snapshotCursor; + this.eventsSincePersistedSnapshot = 0; this.snapshotDirty = false; } catch (error) { this.getTracer()?.log("ERROR", "snapshot.persist.failed", { @@ -154,15 +157,6 @@ export class SessionEventLog { } } - private countEventsSinceSnapshot(): number { - // Events are the seven-day canonical history; snapshots only accelerate - // reconnects and must never make display history disappear. - return this.sql.exec( - "SELECT COUNT(*) AS count FROM events WHERE id > ?", - this.lastPersistedSnapshotCursor, - ).one().count as number; - } - scheduleSnapshotPersist(): void { if (this.snapshotAlarmScheduled) return; this.snapshotAlarmScheduled = true; @@ -217,5 +211,11 @@ export class SessionEventLog { // Leave defaults; next append will rebuild from scratch. } } + // Events are the seven-day canonical history; hydrate the uncheckpointed + // tail once, then maintain it in memory on the hot append path. + this.eventsSincePersistedSnapshot = this.sql.exec( + "SELECT COUNT(*) AS count FROM events WHERE id > ?", + this.lastPersistedSnapshotCursor, + ).one().count as number; } } diff --git a/packages/worker/src/orchestrator/sandbox-handlers.ts b/packages/worker/src/orchestrator/sandbox-handlers.ts index 7b7b318..4a2cb07 100644 --- a/packages/worker/src/orchestrator/sandbox-handlers.ts +++ b/packages/worker/src/orchestrator/sandbox-handlers.ts @@ -3,7 +3,7 @@ import type { DOToSandboxMessage, SandboxToDOMessage, } from "@codevil/shared"; -import { SandboxToDOMessageSchema, clientValidationErrorMessage, getProviderDefinition, parseInbound, safeExceptionAttributes, type ProviderApi } from "@codevil/shared"; +import { SandboxToDOMessageSchema, clientValidationErrorMessage, getProviderDefinition, parseInbound, safeExceptionAttributes, type ProviderApi, type ProviderAuthPolicy } from "@codevil/shared"; import { redactEvent } from "../redaction.js"; import { buildSandboxWebSocketUrl, @@ -250,25 +250,47 @@ export async function dispatchSandboxSocketMessage( export async function issueProxyCapabilities(host: OrchestratorHost): Promise>> { host.loadMeta(); if (!host.meta) throw new Error("Session not initialized"); + const providerDefinition = getProviderDefinition(host.meta.provider); if (!providerDefinition) throw new Error("Unsupported LLM provider"); - const llm = Object.fromEntries(await Promise.all(providerDefinition.authPolicies.map(async ({ api }) => [ - api, - await createSandboxProxyToken(host.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? "", { - sessionId: host.meta!.session_id, - provider: host.meta!.provider, - api, - }), - ]))) as Partial>; - const primaryRepo = githubRepoName(host.meta.repo); + + const signingSecret = host.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? ""; + const { session_id: sessionId, provider, repo } = host.meta; + + const llmTokens = await createLlmProxyTokensForSession(signingSecret, { + sessionId, + provider, + authPolicies: providerDefinition.authPolicies, + }); + + const primaryRepo = githubRepoName(repo); if (!primaryRepo) throw new Error("Session repository must be a github.com HTTPS repository"); - return { - ...llm, - git: await createSandboxGitProxyToken(host.workerEnv.CODEVIL_PROXY_SIGNING_SECRET ?? "", { - sessionId: host.meta.session_id, - primaryRepo, - }), - }; + + const gitToken = await createSandboxGitProxyToken(signingSecret, { + sessionId, + primaryRepo, + }); + + return { ...llmTokens, git: gitToken }; +} + +async function createLlmProxyTokensForSession( + signingSecret: string, + params: { + sessionId: string; + provider: string; + authPolicies: readonly ProviderAuthPolicy[]; + }, +): Promise>> { + const tokens: Partial> = {}; + await Promise.all(params.authPolicies.map(async ({ api }) => { + tokens[api] = await createSandboxProxyToken(signingSecret, { + sessionId: params.sessionId, + provider: params.provider, + api, + }); + })); + return tokens; } function githubRepoName(repo: string): string | undefined { diff --git a/packages/worker/src/sandbox-proxy.ts b/packages/worker/src/sandbox-proxy.ts index 45857c7..8662c16 100644 --- a/packages/worker/src/sandbox-proxy.ts +++ b/packages/worker/src/sandbox-proxy.ts @@ -4,12 +4,12 @@ import { type ProviderApi, } from "@codevil/shared"; import { resolveProviderCredential } from "./provider-credentials.js"; -import type { Env } from "./worker-env.js"; +import { createCapabilityToken, verifyCapabilityToken } from "./capability-token.js"; +import { workerLog } from "./logging.js"; +import { collectWorkerSecretValues, type Env } from "./worker-env.js"; const TOKEN_TTL_SECONDS = 15 * 60; const PROXY_TARGET_HEADER = "x-codevil-proxy-target"; -const TOKEN_VERSION = "v1"; -const GIT_TOKEN_VERSION = "git1"; export interface SandboxProxyClaims { /** @@ -21,41 +21,61 @@ export interface SandboxProxyClaims { sessionId: string; provider: string; api: ProviderApi; - exp: number; } /** Deliberately separate from LLM capabilities: it authorizes Git only. */ export interface SandboxGitProxyClaims { sessionId: string; primaryRepo: string; - exp: number; } /** Issue a short-lived, HMAC-authenticated sandbox-only capability. */ export async function createSandboxProxyToken( secret: string, - claims: Omit, + claims: SandboxProxyClaims, now = Date.now(), ): Promise { - if (!secret.trim()) throw new Error("CODEVIL_PROXY_SIGNING_SECRET is not configured"); - const payload: SandboxProxyClaims = { ...claims, exp: Math.floor(now / 1000) + TOKEN_TTL_SECONDS }; - const encoded = base64url(JSON.stringify(payload)); - const signed = `${TOKEN_VERSION}.${encoded}`; - return `${signed}.${await signature(secret, signed)}`; + return createCapabilityToken(secret, { audience: "sandbox_llm", claims, nowSeconds: Math.floor(now / 1000), ttlSeconds: TOKEN_TTL_SECONDS }); } -export async function createSandboxGitProxyToken(secret: string, claims: Omit, now = Date.now()): Promise { - if (!secret.trim()) throw new Error("CODEVIL_PROXY_SIGNING_SECRET is not configured"); +export async function createSandboxGitProxyToken(secret: string, claims: SandboxGitProxyClaims, now = Date.now()): Promise { if (!isRepoName(claims.primaryRepo)) throw new Error("Invalid primary Git repository"); - const payload: SandboxGitProxyClaims = { ...claims, exp: Math.floor(now / 1000) + TOKEN_TTL_SECONDS }; - const encoded = base64url(JSON.stringify(payload)); - const signed = `${GIT_TOKEN_VERSION}.${encoded}`; - return `${signed}.${await signature(secret, signed)}`; + return createCapabilityToken(secret, { audience: "sandbox_git", claims, nowSeconds: Math.floor(now / 1000), ttlSeconds: TOKEN_TTL_SECONDS }); } -export async function handleSandboxProxy(request: Request, env: Env): Promise { +export interface SandboxProxyTelemetry { + kind: "llm" | "git"; + provider?: string; + api?: ProviderApi; + operation?: "read" | "write"; + outcome: "success" | "rejected" | "failed"; + status: number; + statusClass: string; + durationMs: number; +} + +export async function handleSandboxProxy( + request: Request, + env: Env, + emit: (event: SandboxProxyTelemetry) => void = (event) => { + workerLog(event.outcome === "failed" ? "ERROR" : "DEBUG", "sandbox.proxy", { proxy: event }, collectWorkerSecretValues(env)); + }, +): Promise { const url = new URL(request.url); if (!url.pathname.startsWith("/sandbox-proxy/")) return null; + const startedAt = Date.now(); + const response = await handleSandboxProxyRequest(request, env); + const llm = url.pathname.match(/^\/sandbox-proxy\/sessions\/[^/]+\/llm\/([^/]+)\/([^/]+)/); + const git = url.pathname.match(/^\/sandbox-proxy\/sessions\/[^/]+\/github\/[^/]+\/[^/]+(?:\.git)?\/(.*)$/); + const event: SandboxProxyTelemetry = llm + ? { kind: "llm", ...(safeTelemetryId(llm[1]) ? { provider: llm[1] } : {}), ...(isProviderApi(llm[2]) ? { api: llm[2] } : {}), outcome: proxyOutcome(response.status), status: response.status, statusClass: `${Math.floor(response.status / 100)}xx`, durationMs: Date.now() - startedAt } + : { kind: "git", ...(git ? { operation: gitSmartHttpOperation(request, git[1]) } : {}), outcome: proxyOutcome(response.status), status: response.status, statusClass: `${Math.floor(response.status / 100)}xx`, durationMs: Date.now() - startedAt }; + try { emit(event); } catch { /* observability must not affect proxying */ } + return response; +} + +async function handleSandboxProxyRequest(request: Request, env: Env): Promise { + const url = new URL(request.url); const secret = env.CODEVIL_PROXY_SIGNING_SECRET?.trim(); if (!secret) return proxyError("Sandbox proxy is not configured", 503); @@ -67,7 +87,12 @@ export async function handleSandboxProxy(request: Request, env: Env): Promise= 200 && status < 400) return "success"; + return status >= 500 ? "failed" : "rejected"; +} +function safeTelemetryId(value: string): boolean { return /^[A-Za-z0-9_-]{1,64}$/.test(value); } + async function proxyGit(request: Request, env: Env, claims: SandboxGitProxyClaims, owner: string, repo: string, suffix: string): Promise { if (!isRepoPart(owner) || !isRepoPart(repo) || !isSafeGitSuffix(suffix)) return proxyError("Invalid Git repository path", 400); const operation = gitSmartHttpOperation(request, suffix); @@ -113,29 +144,16 @@ async function proxyLlm(request: Request, env: Env, claims: SandboxProxyClaims, async function verifyRequestToken(request: Request, secret: string, kind: "llm"): Promise { const token = proxyToken(request.headers); if (!token) return undefined; - const parts = token.split("."); - if (parts.length !== 3) return undefined; - const [version, encoded, supplied] = parts; - const signed = `${version}.${encoded}`; - if (version !== TOKEN_VERSION || !encoded || !supplied || !await timingSafeEqual(await signature(secret, signed), supplied)) return undefined; - try { - const claims = JSON.parse(unbase64url(encoded)) as SandboxProxyClaims; - if (!claims || !isSessionId(claims.sessionId) || typeof claims.provider !== "string" || !isProviderApi(claims.api) || !Number.isInteger(claims.exp) || claims.exp <= Math.floor(Date.now() / 1000)) return undefined; - return claims; - } catch { return undefined; } + const envelope = await verifyCapabilityToken(token, secret, { audience: "sandbox_llm", maxLifetimeSeconds: TOKEN_TTL_SECONDS }); + const claims = envelope?.claims; + return claims && isSessionId(claims.sessionId) && typeof claims.provider === "string" && isProviderApi(claims.api) ? claims : undefined; } async function verifyGitRequestToken(request: Request, secret: string): Promise { const token = gitCapabilityFromBasic(request.headers); if (!token) return undefined; - const parts = token.split("."); - if (parts.length !== 3) return undefined; - const [version, encoded, supplied] = parts; - const signed = `${version}.${encoded}`; - if (version !== GIT_TOKEN_VERSION || !encoded || !supplied || !await timingSafeEqual(await signature(secret, signed), supplied)) return undefined; - try { - const claims = JSON.parse(unbase64url(encoded)) as SandboxGitProxyClaims; - return claims && isSessionId(claims.sessionId) && isRepoName(claims.primaryRepo) && Number.isInteger(claims.exp) && claims.exp > Math.floor(Date.now() / 1000) ? claims : undefined; - } catch { return undefined; } + const envelope = await verifyCapabilityToken(token, secret, { audience: "sandbox_git", maxLifetimeSeconds: TOKEN_TTL_SECONDS }); + const claims = envelope?.claims; + return claims && isSessionId(claims.sessionId) && isRepoName(claims.primaryRepo) ? claims : undefined; } function gitCapabilityFromBasic(headers: Headers): string | undefined { const raw = headers.get("authorization")?.trim(); @@ -214,7 +232,3 @@ function appendSafeRelativePath(target: URL, suffix: string, search: string): UR return upstream; } function proxyError(error: string, status: number): Response { return Response.json({ error }, { status }); } -function base64url(value: string): string { return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } -function unbase64url(value: string): string { return atob(value.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((value.length + 3) % 4)); } -async function signature(secret: string, value: string): Promise { const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); return base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))))); } -async function timingSafeEqual(a: string, b: string): Promise { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } diff --git a/packages/worker/src/sandbox-ws-token.ts b/packages/worker/src/sandbox-ws-token.ts index 8da1401..00ad474 100644 --- a/packages/worker/src/sandbox-ws-token.ts +++ b/packages/worker/src/sandbox-ws-token.ts @@ -1,7 +1,7 @@ -const TOKEN_VERSION = "sws1"; +import { createCapabilityToken, verifyCapabilityToken } from "./capability-token.js"; /** The sandbox process refreshes this over its authenticated socket well before expiry. */ -export const SANDBOX_WS_TOKEN_TTL_MS = 15 * 60 * 1000; +export const SANDBOX_WS_TOKEN_TTL_SECONDS = 15 * 60; export interface SandboxWebSocketClaims { aud: "sandbox_ws"; @@ -16,18 +16,12 @@ export async function createSandboxWebSocketToken( secret: string, now = Date.now(), ): Promise { - if (!secret.trim()) throw new Error("CODEVIL_PROXY_SIGNING_SECRET is not configured"); - const payload: SandboxWebSocketClaims = { - aud: "sandbox_ws", - role: "sandbox", - sid: sessionId, - exp: now + SANDBOX_WS_TOKEN_TTL_MS, - // A fresh identifier makes refreshes actual rotations even within one clock tick. - jti: crypto.randomUUID(), - }; - const encoded = base64url(JSON.stringify(payload)); - const signed = `${TOKEN_VERSION}.${encoded}`; - return `${signed}.${await signature(secret, signed)}`; + return createCapabilityToken(secret, { + audience: "sandbox_ws", + claims: { role: "sandbox" as const, sid: sessionId }, + nowSeconds: Math.floor(now / 1000), + ttlSeconds: SANDBOX_WS_TOKEN_TTL_SECONDS, + }); } export async function verifySandboxWebSocketToken( @@ -36,42 +30,11 @@ export async function verifySandboxWebSocketToken( secret: string, now = Date.now(), ): Promise { - if (!token || !secret.trim()) return null; - const [version, encoded, supplied, ...extra] = token.split("."); - if (extra.length || version !== TOKEN_VERSION || !encoded || !supplied) return null; - const signed = `${version}.${encoded}`; - if (!await timingSafeEqual(await signature(secret, signed), supplied)) return null; - try { - const claims = JSON.parse(unbase64url(encoded)) as Partial; - if ( - claims.aud !== "sandbox_ws" - || claims.role !== "sandbox" - || claims.sid !== sessionId - || typeof claims.exp !== "number" - || !Number.isFinite(claims.exp) - || claims.exp <= now - || typeof claims.jti !== "string" - || claims.jti.length < 1 - ) return null; - return claims as SandboxWebSocketClaims; - } catch { - return null; - } -} - -function base64url(value: string): string { - return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} -function unbase64url(value: string): string { - return atob(value.replaceAll("-", "+").replaceAll("_", "/") + "===".slice((value.length + 3) % 4)); -} -async function signature(secret: string, value: string): Promise { - const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); - return base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(value))))); -} -async function timingSafeEqual(a: string, b: string): Promise { - if (a.length !== b.length) return false; - let difference = 0; - for (let index = 0; index < a.length; index++) difference |= a.charCodeAt(index) ^ b.charCodeAt(index); - return difference === 0; + const envelope = await verifyCapabilityToken<{ role?: unknown; sid?: unknown }>(token, secret, { + audience: "sandbox_ws", + nowSeconds: Math.floor(now / 1000), + maxLifetimeSeconds: SANDBOX_WS_TOKEN_TTL_SECONDS, + }); + if (!envelope || envelope.claims.role !== "sandbox" || envelope.claims.sid !== sessionId) return null; + return { aud: "sandbox_ws", role: "sandbox", sid: sessionId, exp: envelope.exp, jti: envelope.jti }; } diff --git a/packages/worker/src/sandbox.ts b/packages/worker/src/sandbox.ts index 814e557..14e913a 100644 --- a/packages/worker/src/sandbox.ts +++ b/packages/worker/src/sandbox.ts @@ -144,10 +144,8 @@ export function buildSandboxWebSocketUrl(workerUrl: string, sessionId: string): } export function sandboxProcessEnv(options: SandboxProcessEnvOptions): Record { - const wsUrl = new URL(options.wsUrl); - wsUrl.searchParams.set("sandbox_ws_token", options.wsToken); return { - CODEVIL_DO_WS_URL: wsUrl.toString(), + CODEVIL_DO_WS_URL: options.wsUrl, CODEVIL_SANDBOX_WS_TOKEN: options.wsToken, CODEVIL_WORKSPACE: "/workspace", CODEVIL_PROVIDER: options.provider, diff --git a/packages/worker/test/capability-token.test.mjs b/packages/worker/test/capability-token.test.mjs new file mode 100644 index 0000000..073338c --- /dev/null +++ b/packages/worker/test/capability-token.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createCapabilityToken, verifyCapabilityToken } from "../dist/capability-token.js"; + +const SECRET = "capability-signing-secret"; +const NOW_SECONDS = 1_800_000_000; + +test("capability token round-trips bounded audience-specific claims", async () => { + const token = await createCapabilityToken(SECRET, { + audience: "sandbox_llm", + claims: { sid: "ses_1", provider: "openai" }, + nowSeconds: NOW_SECONDS, + ttlSeconds: 900, + }); + + const verified = await verifyCapabilityToken(token, SECRET, { + audience: "sandbox_llm", + nowSeconds: NOW_SECONDS + 1, + maxLifetimeSeconds: 900, + }); + + assert.equal(verified?.aud, "sandbox_llm"); + assert.equal(verified?.iat, NOW_SECONDS); + assert.equal(verified?.exp, NOW_SECONDS + 900); + assert.equal(typeof verified?.jti, "string"); + assert.deepEqual(verified?.claims, { sid: "ses_1", provider: "openai" }); +}); + +test("capability verification rejects wrong audience, tampering, expiry, and excessive lifetime", async () => { + const token = await createCapabilityToken(SECRET, { + audience: "sandbox_git", + claims: { sid: "ses_1" }, + nowSeconds: NOW_SECONDS, + ttlSeconds: 901, + }); + + assert.equal(await verifyCapabilityToken(token, SECRET, { audience: "sandbox_llm", nowSeconds: NOW_SECONDS, maxLifetimeSeconds: 901 }), null); + assert.equal(await verifyCapabilityToken(`${token}x`, SECRET, { audience: "sandbox_git", nowSeconds: NOW_SECONDS, maxLifetimeSeconds: 901 }), null); + assert.equal(await verifyCapabilityToken(token, SECRET, { audience: "sandbox_git", nowSeconds: NOW_SECONDS + 902, maxLifetimeSeconds: 901 }), null); + assert.equal(await verifyCapabilityToken(token, SECRET, { audience: "sandbox_git", nowSeconds: NOW_SECONDS, maxLifetimeSeconds: 900 }), null); +}); + +test("capability verification applies bounded clock skew to issued-at time", async () => { + const token = await createCapabilityToken(SECRET, { + audience: "sandbox_ws", + claims: { sid: "ses_1" }, + nowSeconds: NOW_SECONDS + 31, + ttlSeconds: 60, + }); + + assert.ok(await verifyCapabilityToken(token, SECRET, { + audience: "sandbox_ws", + nowSeconds: NOW_SECONDS, + maxLifetimeSeconds: 60, + clockSkewSeconds: 31, + })); + assert.equal(await verifyCapabilityToken(token, SECRET, { + audience: "sandbox_ws", + nowSeconds: NOW_SECONDS, + maxLifetimeSeconds: 60, + clockSkewSeconds: 30, + }), null); +}); + +test("capability creation rejects empty secrets and invalid lifetimes", async () => { + await assert.rejects(() => createCapabilityToken(" ", { audience: "sandbox_git", claims: {}, nowSeconds: NOW_SECONDS, ttlSeconds: 60 }), /signing secret/i); + await assert.rejects(() => createCapabilityToken(SECRET, { audience: "sandbox_git", claims: {}, nowSeconds: NOW_SECONDS, ttlSeconds: 0 }), /TTL/i); +}); diff --git a/packages/worker/test/event-log-persistence.test.mjs b/packages/worker/test/event-log-persistence.test.mjs index 1f44a56..e14549a 100644 --- a/packages/worker/test/event-log-persistence.test.mjs +++ b/packages/worker/test/event-log-persistence.test.mjs @@ -85,3 +85,16 @@ test("oversized structural state does not advance a checkpoint or delete its rep assert.equal(sql.events.length, 1); assert.equal(log.getSnapshotCursor(), 0, "reconnect falls back to the prior durable checkpoint"); }); + +test("event tail count is hydrated once instead of queried on every append", () => { + const sql = createEventSql(); + const log = createLog(sql); + log.hydrateFromSql(); + + for (let index = 0; index < 3; index++) { + log.appendAndBroadcast({ type: "status", message: `event ${index}` }); + } + + const countQueries = sql.calls.filter(({ query }) => query.includes("SELECT COUNT(*) AS count FROM events")); + assert.equal(countQueries.length, 1); +}); diff --git a/packages/worker/test/sandbox-handlers.test.mjs b/packages/worker/test/sandbox-handlers.test.mjs index db4d3f8..08212de 100644 --- a/packages/worker/test/sandbox-handlers.test.mjs +++ b/packages/worker/test/sandbox-handlers.test.mjs @@ -66,8 +66,8 @@ test("authenticated sandbox capability refresh returns new session-bound short-l assert.equal(sent.length, 1); assert.equal(sent[0].type, "proxy_capabilities"); - assert.match(sent[0].tokens["openai-responses"], /^v1\.[^.]+\.[^.]+$/); - assert.match(sent[0].sandbox_ws_token, /^sws1\.[^.]+\.[^.]+$/); + assert.match(sent[0].tokens["openai-responses"], /^cap1\.[^.]+\.[^.]+$/); + assert.match(sent[0].sandbox_ws_token, /^cap1\.[^.]+\.[^.]+$/); assert.ok((await issueProxyCapabilities(host))["openai-responses"]); }); @@ -93,7 +93,7 @@ test("proxy capability refresh normalizes an HTTPS primary repository without .g assert.equal(sent[0].type, "proxy_capabilities"); const capability = sent[0].tokens.git; - assert.match(capability, /^git1\.[^.]+\.[^.]+$/); + assert.match(capability, /^cap1\.[^.]+\.[^.]+$/); const authorization = `Basic ${Buffer.from(`x-access-token:${capability}`).toString("base64")}`; const response = await handleSandboxProxy(new Request( "https://worker.test/sandbox-proxy/sessions/ses_test/github/acme/app.git/info/refs?service=git-upload-pack", diff --git a/packages/worker/test/sandbox-proxy.test.mjs b/packages/worker/test/sandbox-proxy.test.mjs index 0dc04bf..f1a26d1 100644 --- a/packages/worker/test/sandbox-proxy.test.mjs +++ b/packages/worker/test/sandbox-proxy.test.mjs @@ -18,6 +18,20 @@ test("LLM proxy rejects invalid provider target before any upstream request", as assert.equal(response.status, 403); }); +test("proxy telemetry reports normalized metadata without request paths or credentials", async () => { + const telemetry = []; + const token = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }); + const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/openai/openai-responses/responses?secret=query", { + method: "POST", + headers: { authorization: `Bearer ${token}`, "x-codevil-proxy-target": "https://evil.test/private/path" }, + }), env, (event) => telemetry.push(event)); + + assert.equal(response.status, 403); + assert.deepEqual(telemetry.map(({ durationMs, ...event }) => event), [{ kind: "llm", provider: "openai", api: "openai-responses", outcome: "rejected", status: 403, statusClass: "4xx" }]); + assert.ok(telemetry[0].durationMs >= 0); + assert.doesNotMatch(JSON.stringify(telemetry), /responses\?|evil|private|real-provider-key|cap1/); +}); + test("expired and provider-mismatched capabilities are rejected", async () => { const expired = await createSandboxProxyToken(secret, { sessionId: "ses_s1", provider: "openai", api: "openai-responses" }, 0); const response = await handleSandboxProxy(new Request("https://worker.test/sandbox-proxy/sessions/ses_s1/llm/anthropic/anthropic-messages/messages", { @@ -131,6 +145,26 @@ test("Git proxy permits PAT-authenticated reads of any repository without exposi } finally { globalThis.fetch = originalFetch; } }); +test("Git proxy canonicalizes the exact bare clone route to the .git upstream", async () => { + const token = await createSandboxGitProxyToken(secret, { sessionId: "ses_s1", primaryRepo: "primary/app" }); + const cap = Buffer.from(`x-access-token:${token}`).toString("base64"); + const originalFetch = globalThis.fetch; + const calls = []; + globalThis.fetch = async (input, init) => { calls.push({ url: String(input), init }); return new Response("pack"); }; + try { + for (const repository of ["https://worker.test/sandbox-proxy/sessions/ses_s1/github/other/private", "https://worker.test/sandbox-proxy/sessions/ses_s1/github/other/private.git"]) { + const response = await handleSandboxProxy(new Request(`${repository}/info/refs?service=git-upload-pack`, { + headers: { authorization: `Basic ${cap}` }, + }), env); + assert.equal(response.status, 200, repository); + } + assert.deepEqual(calls.map((call) => call.url), [ + "https://github.com/other/private.git/info/refs?service=git-upload-pack", + "https://github.com/other/private.git/info/refs?service=git-upload-pack", + ]); + } finally { globalThis.fetch = originalFetch; } +}); + test("Git proxy permits writes only to the signed primary repository and rejects suffix/authority attacks", async () => { const token = await createSandboxGitProxyToken(secret, { sessionId: "ses_s1", primaryRepo: "primary/app" }); const cap = Buffer.from(`x-access-token:${token}`).toString("base64"); @@ -147,6 +181,10 @@ test("Git proxy permits writes only to the signed primary repository and rejects const response = await handleSandboxProxy(new Request(`https://worker.test${path}`, { headers }), env); assert.ok([400, 404].includes(response.status)); } + for (const path of ["/sandbox-proxy/sessions/ses_s1/github/primary/app/objects/aa/bb", "/sandbox-proxy/sessions/ses_s1/github/primary/app/extra/info/refs?service=git-upload-pack"]) { + const response = await handleSandboxProxy(new Request(`https://worker.test${path}`, { headers }), env); + assert.ok([400, 404].includes(response.status), path); + } assert.equal(calls, 1); } finally { globalThis.fetch = originalFetch; } }); diff --git a/packages/worker/test/sandbox-ws-token.test.mjs b/packages/worker/test/sandbox-ws-token.test.mjs index d6dc3da..4aafe8b 100644 --- a/packages/worker/test/sandbox-ws-token.test.mjs +++ b/packages/worker/test/sandbox-ws-token.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { createSandboxWebSocketToken, - SANDBOX_WS_TOKEN_TTL_MS, + SANDBOX_WS_TOKEN_TTL_SECONDS, verifySandboxWebSocketToken, } from "../dist/sandbox-ws-token.js"; import { createSandboxProxyToken } from "../dist/sandbox-proxy.js"; @@ -17,7 +17,7 @@ test("session-bound sandbox WebSocket capability verifies with its explicit audi aud: "sandbox_ws", role: "sandbox", sid: "ses_123", - exp: NOW + SANDBOX_WS_TOKEN_TTL_MS, + exp: Math.floor(NOW / 1000) + SANDBOX_WS_TOKEN_TTL_SECONDS, jti: (await verifySandboxWebSocketToken(token, "ses_123", SECRET, NOW)).jti, }); const replacement = await createSandboxWebSocketToken("ses_123", SECRET, NOW); @@ -28,28 +28,9 @@ test("sandbox WebSocket capability rejects forged, expired, wrong-session, wrong const token = await createSandboxWebSocketToken("ses_123", SECRET, NOW); assert.equal(await verifySandboxWebSocketToken(token, "ses_wrong", SECRET, NOW), null); assert.equal(await verifySandboxWebSocketToken(token, "ses_123", "wrong-secret", NOW), null); - assert.equal(await verifySandboxWebSocketToken(token, "ses_123", SECRET, NOW + SANDBOX_WS_TOKEN_TTL_MS), null); + assert.equal(await verifySandboxWebSocketToken(token, "ses_123", SECRET, NOW + SANDBOX_WS_TOKEN_TTL_SECONDS * 1000), null); assert.equal(await verifySandboxWebSocketToken("v1.invalid.signature", "ses_123", SECRET, NOW), null); assert.equal(await verifySandboxWebSocketToken("control-plane-global-key", "ses_123", SECRET, NOW), null); - assert.equal(await verifySandboxWebSocketToken(await signClaims({ - aud: "llm_proxy", - role: "sandbox", - sid: "ses_123", - exp: NOW + 1, - jti: "wrong-audience", - }), "ses_123", SECRET, NOW), null); const proxyToken = await createSandboxProxyToken(SECRET, { sessionId: "ses_123", provider: "openai", api: "openai-responses" }, NOW); assert.equal(await verifySandboxWebSocketToken(proxyToken, "ses_123", SECRET, NOW), null); }); - -async function signClaims(claims) { - const encoded = base64url(JSON.stringify(claims)); - const signed = `sws1.${encoded}`; - const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(SECRET), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); - const signature = base64url(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signed))))); - return `${signed}.${signature}`; -} - -function base64url(value) { - return btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); -} diff --git a/packages/worker/test/sandbox.test.mjs b/packages/worker/test/sandbox.test.mjs index 52f8d65..96dccad 100644 --- a/packages/worker/test/sandbox.test.mjs +++ b/packages/worker/test/sandbox.test.mjs @@ -33,7 +33,7 @@ test("builds sandbox process env with only short-lived per-Pi-API proxy capabili proxyBase: "https://codevil.example.com", proxyTokens: { "anthropic-messages": "sandbox-capability" }, }), { - CODEVIL_DO_WS_URL: "wss://codevil.example.com/sessions/ses_123/sandbox/ws?sandbox_ws_token=sandbox-ws-capability", + CODEVIL_DO_WS_URL: "wss://codevil.example.com/sessions/ses_123/sandbox/ws", CODEVIL_SANDBOX_WS_TOKEN: "sandbox-ws-capability", CODEVIL_WORKSPACE: "/workspace", CODEVIL_PROVIDER: "anthropic",