From e77494436fccbf475afa8dfda905fcb9c8138ba1 Mon Sep 17 00:00:00 2001 From: justinhelmer <1403438+justinhelmer@users.noreply.github.com> Date: Sat, 26 Sep 2026 04:00:41 +0000 Subject: [PATCH] feat(coding): inspect and retry Depot CI through the edge Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com> --- .env.example | 6 + deploy/cloudflare/depotCi.test.ts | 511 +++++++++++++++++++++ deploy/cloudflare/depotCi.ts | 246 ++++++++++ deploy/cloudflare/vitest.config.mjs | 1 + deploy/cloudflare/worker.ts | 16 + deploy/secrets.manifest.json | 14 +- docs/reference/specs/README.md | 1 + docs/reference/specs/depot-ci.md | 37 ++ docs/reference/specs/release-and-deploy.md | 4 +- src/core/depotCi.ts | 72 +++ src/core/dispatch/runLoop.test.ts | 64 +++ src/core/dispatch/runLoop.ts | 13 + src/core/secretsManifest.test.ts | 14 +- src/execution/depotCi.test.ts | 201 ++++++++ src/execution/depotCi.ts | 133 ++++++ src/execution/depotCiAuthorization.test.ts | 76 +++ src/execution/depotCiAuthorization.ts | 57 +++ src/index.ts | 6 + src/tools/depotCi.test.ts | 72 +++ src/tools/depotCi.ts | 84 ++++ src/tools/runnableTool.ts | 3 + src/tools/toolsets.ts | 2 + 22 files changed, 1628 insertions(+), 5 deletions(-) create mode 100644 deploy/cloudflare/depotCi.test.ts create mode 100644 deploy/cloudflare/depotCi.ts create mode 100644 docs/reference/specs/depot-ci.md create mode 100644 src/core/depotCi.ts create mode 100644 src/execution/depotCi.test.ts create mode 100644 src/execution/depotCi.ts create mode 100644 src/execution/depotCiAuthorization.test.ts create mode 100644 src/execution/depotCiAuthorization.ts create mode 100644 src/tools/depotCi.test.ts create mode 100644 src/tools/depotCi.ts diff --git a/.env.example b/.env.example index 98d68aab9..301fe8769 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,9 @@ E2B_API_KEY=e2b_... # GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" # (b) Fallback: a fine-grained PAT scoped to only the repos the bot may touch. # GH_TOKEN=github_pat_... + +# Depot CI bridge (optional; bot only). Depot's organization token must NOT be +# in this file or any container: provision DEPOT_API_TOKEN only on the Worker. +# Provision a separate internal bearer on that Worker; it forwards this one to +# the bot. PUBLIC_BASE_URL must be the Worker's HTTPS origin. No Depot CLI needed. +# DEPOT_CI_BRIDGE_TOKEN=... diff --git a/deploy/cloudflare/depotCi.test.ts b/deploy/cloudflare/depotCi.test.ts new file mode 100644 index 000000000..efc0547b6 --- /dev/null +++ b/deploy/cloudflare/depotCi.test.ts @@ -0,0 +1,511 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { depotCiAuthorization, handleDepotCi } from "./depotCi.js"; +import { depotCiGrantSchema } from "../../src/core/depotCi.js"; +import { DepotCiAuthorizations } from "../../src/execution/depotCiAuthorization.js"; +import { WorkerDepotCi } from "../../src/execution/depotCi.js"; +import { Secret } from "../../src/secrets.js"; + +const HEAD = "a".repeat(40); +const depotToken = "organization-credential-never-forward"; +const bridgeToken = "internal-bridge-credential"; +const ticket = "a".repeat(64); +const workflow = () => ({ + orgId: "org-one", + runId: "run-one", + repo: "acme/api", + workflowId: "workflow-one", + headSha: HEAD, + sha: "b".repeat(40), + workflowStatus: "failed", + workflowName: "ci", + workflowPath: ".depot/workflows/ci.yml", + workflowErrorMessage: "tests failed", + jobs: [ + { + jobId: "test", + jobKey: "test", + status: "failed", + attempts: [ + { attemptId: "attempt-new", attempt: 2, status: "failed" }, + { attemptId: "attempt-old", attempt: 1, status: "failed" }, + ], + }, + { + jobId: "lint", + jobKey: "lint", + status: "finished", + attempts: [{ attemptId: "lint-one", attempt: 1, status: "finished" }], + }, + ], +}); +function request(body: unknown, token = bridgeToken, method = "POST") { + return new Request("https://edge.example/internal/depot-ci", { + method, + headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, + ...(method === "POST" ? { body: JSON.stringify(body) } : {}), + }); +} +function harness(responses: unknown[] = [workflow()]) { + const calls: { url: string; init: RequestInit; body: Record }[] = []; + const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init!, body: JSON.parse(String(init?.body)) }); + const answer = responses.shift(); + if (answer instanceof Error) throw answer; + if (answer instanceof Response) return answer; + return Response.json(answer); + }) as unknown as typeof fetch; + const invoke = (body: unknown, token?: string, method?: string) => { + const { repo, ...operation } = body as Record; + return handleDepotCi(request({ ticket }, token, method), { + depotToken, + bridgeToken, + authorize: async () => ({ runId: "coding-run", repo, operation }), + fetch: fetcher, + }); + }; + return { calls, invoke, fetcher }; +} +const inspect = { operation: "inspect", repo: "acme/api", workflowId: "workflow-one" }; +const logs = { ...inspect, operation: "logs", jobId: "test" }; +const retry = { ...inspect, operation: "retry_failed", expectedHead: HEAD }; + +describe("Depot CI edge", () => { + it("authenticates before parsing and refuses missing credentials, methods and broad operations", async () => { + const h = harness(); + expect((await h.invoke(inspect, "wrong")).status).toBe(401); + expect((await h.invoke(inspect, undefined, "GET")).status).toBe(405); + for (const body of [ + { ...inspect, operation: "Run" }, + { ...inspect, url: "https://evil.example" }, + { ...retry, headers: {} }, + { ...inspect, repo: "../x" }, + ]) + expect((await handleDepotCi(request(body), { depotToken, bridgeToken, fetch: h.fetcher })).status).toBe(400); + expect( + ( + await handleDepotCi(request({ ticket }), { + bridgeToken, + authorize: async () => ({ + runId: "coding-run", + repo: "acme/api", + operation: { operation: "inspect", workflowId: "workflow-one" }, + }), + fetch: h.fetcher, + }) + ).status, + ).toBe(503); + expect(h.calls).toHaveLength(0); + }); + + it("refuses a bridge bearer with caller-chosen run and repo before using the organization token", async () => { + const h = harness(); + const res = await handleDepotCi(request(inspect), { depotToken, bridgeToken, fetch: h.fetcher }); + expect(res.status).toBe(400); + expect(h.calls).toHaveLength(0); + }); + + it("requires a valid live permit callback and never substitutes caller run, repo or operation", async () => { + for (const answer of [undefined, {}, { runId: "coding-run", repo: "acme/api", operation: { operation: "Run" } }]) { + const h = harness(); + const authorize = vi.fn(async () => answer); + const res = await handleDepotCi(request({ ticket }), { depotToken, bridgeToken, authorize, fetch: h.fetcher }); + expect(res.status).toBe(403); + expect(h.calls).toHaveLength(0); + expect(authorize).toHaveBeenCalledOnce(); + for (const injection of [{ repo: "other/private" }, { runId: "other-run" }, { operation: "retry_failed" }]) { + expect( + ( + await handleDepotCi(request({ ticket, ...injection }), { + depotToken, + bridgeToken, + authorize, + fetch: h.fetcher, + }) + ).status, + ).toBe(400); + } + expect(authorize).toHaveBeenCalledOnce(); + } + const h = harness(); + const res = await handleDepotCi(request({ ticket }), { + depotToken, + bridgeToken, + fetch: h.fetcher, + authorize: async () => { + throw new Error(depotToken); + }, + }); + expect(res.status).toBe(403); + expect(await res.text()).not.toContain(depotToken); + expect(h.calls).toHaveLength(0); + }); + + it("uses the fixed private bot callback and refuses redirects, error bodies and malformed grants", async () => { + const botFetch = vi.fn(async (req: Request) => { + expect(req.url).toBe("https://switchboard-keepalive.internal/internal/depot-ci/authorization"); + expect(req.headers.get("authorization")).toBe(`Bearer ${ticket}`); + expect(req.redirect).toBe("error"); + return Response.json({ + runId: "coding-run", + repo: "acme/api", + operation: { operation: "inspect", workflowId: "workflow-one" }, + }); + }); + const grant = await depotCiAuthorization(ticket, new AbortController().signal, botFetch); + expect(depotCiGrantSchema.safeParse(grant).success).toBe(true); + for (const status of [302, 403, 500]) { + expect( + await depotCiAuthorization( + ticket, + new AbortController().signal, + async () => new Response(depotToken, { status }), + ), + ).toBeUndefined(); + } + await expect( + depotCiAuthorization(ticket, new AbortController().signal, async () => new Response("x".repeat(17 * 1024))), + ).rejects.toThrow(); + }); + + it("integrates the bot-issued run permit with the edge and refuses concurrent replay before a second retry", async () => { + const permits = new DepotCiAuthorizations(); + const h = harness([workflow(), { workflowId: "workflow-one", jobIds: ["test"], jobCount: 1 }]); + let replay: Request | undefined; + const edge = (req: Request) => + handleDepotCi(req, { + depotToken, + bridgeToken, + fetch: h.fetcher, + authorize: async (key) => permits.consume(key), + }); + const client = new WorkerDepotCi({ + runId: "coding-run", + repo: "acme/api", + canUseRepo: () => true, + baseUrl: "https://edge.example", + token: new Secret(bridgeToken, "DEPOT_CI_BRIDGE_TOKEN"), + authorizations: permits, + fetch: async (url, init) => { + const req = new Request(url, init); + replay = req.clone(); + const concurrent = req.clone(); + const [first, second] = await Promise.all([edge(req), edge(concurrent)]); + expect(second.status).toBe(403); + return first; + }, + }); + expect(await client.call({ operation: "retry_failed", workflowId: "workflow-one", expectedHead: HEAD })).toEqual({ + workflowId: "workflow-one", + jobIds: ["test"], + jobCount: 1, + }); + expect((await edge(replay!)).status).toBe(403); + expect(h.calls.map((c) => c.url.split("/").at(-1))).toEqual(["GetWorkflow", "RetryFailedJobs"]); + }); + + it("withholds credentials split across page boundaries before any tail clipping", async () => { + for (const secret of [depotToken, bridgeToken, "ghp_" + "a".repeat(30), "GH_TOKEN=some-private-value"]) { + const cut = Math.floor(secret.length / 2); + const h = harness([ + workflow(), + { lines: [{ body: secret.slice(0, cut) }], nextPageToken: "next" }, + { lines: [{ body: secret.slice(cut) }, { body: "FAIL parser assertion" }] }, + ]); + const res = await h.invoke({ ...logs, lines: 2 }); + expect(res.status).toBe(200); + const out = (await res.json()) as { text: string }; + expect(out.text).not.toContain(secret.slice(0, cut)); + expect(out.text).not.toContain(secret.slice(cut)); + } + }); + + it("follows empty pages with continuation tokens instead of declaring a partial credential complete", async () => { + const h = harness([ + workflow(), + { lines: [{ body: depotToken.slice(0, 10) }], nextPageToken: "two" }, + { lines: [], nextPageToken: "three" }, + { lines: [{ body: depotToken.slice(10) }] }, + ]); + expect(await (await h.invoke(logs)).json()).toMatchObject({ + complete: true, + text: "", + withheld: "credential spans log records", + }); + expect(h.calls).toHaveLength(4); + }); + + it("redacts a multi-page private key and withholds multi-page or ANSI-split credentials", async () => { + const pem = harness([ + workflow(), + { lines: [{ body: "FAIL parser assertion" }, { body: "-----BEGIN PRIVATE KEY-----" }], nextPageToken: "two" }, + { lines: [{ body: "private-key-material" }], nextPageToken: "three" }, + { lines: [{ body: "-----END PRIVATE KEY-----" }] }, + ]); + const pemOut = (await (await pem.invoke(logs)).json()) as { text: string }; + expect(pemOut.text).toContain("FAIL parser assertion"); + expect(pemOut.text).not.toContain("private-key-material"); + for (const pieces of [ + [depotToken.slice(0, 4), depotToken.slice(4, 12), depotToken.slice(12)], + [depotToken.slice(0, 8) + "\u001b[", "31m" + depotToken.slice(8)], + ]) { + const h = harness([ + workflow(), + ...pieces.map((body, i) => ({ + lines: [{ body }], + ...(i < pieces.length - 1 ? { nextPageToken: `page-${i}` } : {}), + })), + ]); + expect(await (await h.invoke({ ...logs, lines: 1 })).json()).toMatchObject({ + text: "", + withheld: "credential spans log records", + }); + } + }); + + it("withholds incomplete or aggregate-limited captures instead of leaking a trailing secret fragment", async () => { + const incomplete = harness([ + workflow(), + ...Array.from({ length: 20 }, (_, i) => ({ + lines: [{ body: depotToken.slice(0, 10) }], + nextPageToken: `page-${i}`, + })), + ]); + expect(await (await incomplete.invoke(logs)).json()).toMatchObject({ + complete: false, + text: "", + withheld: "incomplete capture", + }); + const tooLarge = harness([ + workflow(), + ...Array.from({ length: 6 }, (_, i) => ({ + lines: [{ body: "x".repeat(1_000_000) }], + nextPageToken: `page-${i}`, + })), + ]); + expect(await (await tooLarge.invoke(logs)).json()).toMatchObject({ + complete: false, + text: "", + withheld: "incomplete capture", + }); + expect(tooLarge.calls).toHaveLength(6); + }); + + it("reads a workflow over fixed Connect JSON and returns only bounded sanitized evidence", async () => { + const h = harness([ + { + ...workflow(), + workflowErrorMessage: `\u001b[31m${depotToken} ${bridgeToken} GH_TOKEN=super-secret-value`, + privateField: depotToken, + }, + ]); + const res = await h.invoke(inspect); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain(HEAD); + expect(text).toContain("attempt-new"); + for (const secret of [depotToken, bridgeToken, "super-secret-value", "privateField", "\u001b"]) + expect(text).not.toContain(secret); + expect(h.calls[0].url).toBe("https://api.depot.dev/depot.ci.v1.CIService/GetWorkflow"); + expect(h.calls[0].init).toMatchObject({ + method: "POST", + redirect: "error", + headers: { + authorization: `Bearer ${depotToken}`, + "content-type": "application/json", + "connect-protocol-version": "1", + }, + }); + expect(h.calls[0].body).toEqual({ workflowId: "workflow-one" }); + }); + + it("refuses foreign or inconsistent workflow identities before disclosure, logs or retry", async () => { + for (const operation of [inspect, logs, retry]) { + for (const foreign of [ + { ...workflow(), repo: "other/private" }, + { ...workflow(), workflowId: "different" }, + ]) { + const h = harness([foreign]); + const res = await h.invoke(operation); + expect(res.status).toBe(404); + expect(await res.text()).not.toContain("other/private"); + expect(h.calls).toHaveLength(1); + } + } + }); + + it("selects the latest numbered attempt, paginates pinned logs and returns a redacted tail", async () => { + const h = harness([ + workflow(), + { lines: [{ body: "old line" }], nextPageToken: "page-two" }, + { + lines: [ + { body: `\u001b[31mFAIL test: expected true got false ${depotToken}` }, + { body: "GH_TOKEN=never-output-this" }, + ], + nextPageToken: "end", + }, + { lines: [] }, + ]); + const res = await h.invoke({ ...logs, lines: 2 }); + expect(res.status).toBe(200); + const out = (await res.json()) as { text: string; complete: boolean; attemptId: string }; + expect(out.attemptId).toBe("attempt-new"); + expect(out.complete).toBe(true); + expect(out.text).toContain("FAIL test: expected true got false"); + for (const hidden of ["old line", depotToken, "never-output-this", "\u001b"]) + expect(out.text).not.toContain(hidden); + expect(h.calls.slice(1).map((c) => c.body)).toEqual([ + { attemptId: "attempt-new" }, + { attemptId: "attempt-new", pageToken: "page-two" }, + { attemptId: "attempt-new", pageToken: "end" }, + ]); + }); + + it("accepts only attempts belonging to the selected job and refuses unknown or ambiguous jobs", async () => { + for (const input of [ + { ...logs, jobId: "foreign" }, + { ...logs, attemptId: "foreign" }, + { ...logs, attemptId: "lint-one" }, + ]) { + const h = harness(); + expect((await h.invoke(input)).status).toBe(404); + expect(h.calls).toHaveLength(1); + } + const h = harness([workflow(), { lines: [] }]); + expect((await h.invoke({ ...logs, attemptId: "attempt-old" })).status).toBe(200); + expect(h.calls[1].body).toEqual({ attemptId: "attempt-old" }); + }); + + it("reports incomplete pagination and caps text without silently dropping evidence", async () => { + const pages = Array.from({ length: 20 }, (_, i) => ({ + lines: [{ body: "x".repeat(60_000) }], + nextPageToken: `page-${i}`, + })); + const h = harness([workflow(), ...pages]); + const out = (await (await h.invoke(logs)).json()) as { complete: boolean; truncated: boolean; text: string }; + expect(out.complete).toBe(false); + expect(out.truncated).toBe(true); + expect(out.text.length).toBeLessThanOrEqual(50_000); + expect(h.calls).toHaveLength(21); + }); + + it("keeps the requested line limit after a character-clipped page", async () => { + const h = harness([ + workflow(), + { lines: [{ body: "x".repeat(50_000) }, { body: "second line" }], nextPageToken: "next" }, + { lines: [{ body: "last line" }] }, + ]); + const out = (await (await h.invoke({ ...logs, lines: 2 })).json()) as { text: string; truncated: boolean }; + expect(out.text).toBe("second line\nlast line"); + expect(out.truncated).toBe(true); + }); + + it("does not dispatch a retry after the child stops during the ownership read", async () => { + const controller = new AbortController(); + const fetcher = vi.fn(async () => { + controller.abort(); + return Response.json(workflow()); + }); + const req = new Request(request({ ticket }), { signal: controller.signal }); + const { repo, ...operation } = retry; + const res = await handleDepotCi(req, { + depotToken, + bridgeToken, + fetch: fetcher, + authorize: async () => ({ runId: "coding-run", repo, operation }), + }); + expect(res.status).toBe(502); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(await res.text()).not.toContain("outcome unknown"); + }); + + it("retries failed jobs only at the expected head without a full rerun", async () => { + const h = harness([workflow(), { workflowId: "workflow-one", jobIds: ["test", "downstream"], jobCount: 2 }]); + const res = await h.invoke(retry); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ workflowId: "workflow-one", jobIds: ["test", "downstream"], jobCount: 2 }); + expect(h.calls.map((c) => c.url.split("/").at(-1))).toEqual(["GetWorkflow", "RetryFailedJobs"]); + expect(h.calls[1].body).toEqual({ workflowId: "workflow-one" }); + }); + + it("treats incomplete upstream retry acknowledgements as unknown outcomes", async () => { + for (const answer of [ + { workflowId: "workflow-one", jobCount: 0 }, + { workflowId: "workflow-one", jobIds: [] }, + ]) { + const h = harness([workflow(), answer]); + const res = await h.invoke(retry); + expect(res.status).toBe(502); + expect(await res.text()).toContain("outcome unknown"); + expect(h.calls.map((c) => c.url.split("/").at(-1))).toEqual(["GetWorkflow", "RetryFailedJobs"]); + } + }); + + it("refuses a mismatched head or nonfailed workflow without a mutation", async () => { + for (const changed of [ + { ...workflow(), headSha: "c".repeat(40) }, + { ...workflow(), workflowStatus: "running" }, + { ...workflow(), workflowStatus: "finished" }, + ]) { + const h = harness([changed]); + expect((await h.invoke(retry)).status).toBe(409); + expect(h.calls).toHaveLength(1); + } + const h = harness([ + { ...workflow(), headSha: "", sha: HEAD, workflowStatus: "cancelled" }, + { workflowId: "workflow-one", jobIds: ["test"], jobCount: 1 }, + ]); + expect((await h.invoke(retry)).status).toBe(200); + }); + + it("never echoes errors or retries an uncertain mutation", async () => { + for (const failure of [ + new Error(depotToken), + new Response(depotToken, { status: 503 }), + Response.json({ wrong: depotToken }), + ]) { + const h = harness([workflow(), failure]); + const res = await h.invoke(retry); + expect(res.status).toBe(502); + const text = await res.text(); + expect(text).toContain("outcome unknown"); + expect(text).not.toContain(depotToken); + expect(h.calls).toHaveLength(2); + } + const h = harness([new Response(`Bearer ${depotToken}`, { status: 403 })]); + const res = await h.invoke(inspect); + expect(res.status).toBe(502); + expect(await res.text()).toContain("HTTP 403"); + }); + + it("cancels oversized responses and malformed metadata before any mutation", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ + start(c) { + c.enqueue(new Uint8Array(2 * 1024 * 1024 + 1)); + }, + cancel, + }); + const h = harness([new Response(body)]); + expect((await h.invoke(retry)).status).toBe(502); + expect(cancel).toHaveBeenCalled(); + expect(h.calls).toHaveLength(1); + const malformed = harness([{ workflowId: "workflow-one", repo: "acme/api" }]); + expect((await malformed.invoke(retry)).status).toBe(502); + expect(malformed.calls).toHaveLength(1); + }); + + it("routes at the Worker without forwarding the organization credential to the bot", () => { + const source = readFileSync(new URL("./worker.ts", import.meta.url), "utf8"); + expect(source).toContain("handleDepotCi(request,"); + expect(source).toContain("depotToken: env.DEPOT_API_TOKEN"); + expect(source).toContain( + 'if (pathname === DEPOT_CI_AUTHORIZATION_PATH) return new Response("not found", { status: 404 })', + ); + expect(source).toContain("getContainer(env.SWITCHBOARD, INSTANCE).fetch(req)"); + const forwarded = /const FORWARDED_OPTIONAL = \[([\s\S]*?)\]/.exec(source)![1]; + expect(forwarded).toContain('"DEPOT_CI_BRIDGE_TOKEN"'); + expect(forwarded).not.toContain('"DEPOT_API_TOKEN"'); + }); +}); diff --git a/deploy/cloudflare/depotCi.ts b/deploy/cloudflare/depotCi.ts new file mode 100644 index 000000000..968b48d38 --- /dev/null +++ b/deploy/cloudflare/depotCi.ts @@ -0,0 +1,246 @@ +// Depot's organization token belongs to this Worker alone. The bot sends a +// repo-bound, typed operation; this adapter resolves ownership before spending +// that credential on logs or a mutation. Nothing here executes repository code. +import { z } from "zod"; +import { MINUTE_MS } from "../../src/core/budgets.ts"; +import { + DEPOT_CI_AUTHORIZATION_PATH, + DEPOT_CI_MAX_BYTES, + DEPOT_ERRORS, + depotCiGrantSchema, + depotCiTicketSchema, + depotId, + depotJson, +} from "../../src/core/depotCi.ts"; +import { redactSecrets, stripAnsi } from "../../src/core/redact.ts"; +import { sameToken } from "./artifactsCopy.ts"; +import { INTERNAL } from "./shared.ts"; + +const attemptSchema = z.object({ attemptId: depotId, attempt: z.number().int().positive(), status: z.string() }); +const workflowSchema = z.object({ + orgId: depotId, + runId: depotId, + repo: z.string(), + workflowId: depotId, + headSha: z.string().default(""), + sha: z.string().default(""), + workflowStatus: z.string(), + workflowName: z.string().default(""), + workflowPath: z.string().default(""), + workflowErrorMessage: z.string().default(""), + jobs: z + .array( + z.object({ + jobId: depotId, + jobKey: z.string(), + jobDisplayName: z.string().default(""), + status: z.string(), + attempts: z.array(attemptSchema).default([]), + }), + ) + .default([]), +}); +const logsSchema = z.object({ + lines: z.array(z.object({ body: z.string(), stepName: z.string().default("") })).default([]), + nextPageToken: z.string().max(8192).default(""), +}); +const retrySchema = z.object({ + workflowId: depotId, + jobIds: z.array(depotId), + jobCount: z.number().int().nonnegative(), +}); +/** The callback has no caller-controlled origin or path. Worker wiring supplies + * the singleton container binding, NOT an Internet fetch or a redirect. */ +export async function depotCiAuthorization( + ticket: string, + signal: AbortSignal, + botFetch: (request: Request) => Promise, +): Promise { + const res = await botFetch( + new Request(`${INTERNAL}${DEPOT_CI_AUTHORIZATION_PATH}`, { + method: "POST", + redirect: "error", + signal, + headers: { authorization: `Bearer ${ticket}` }, + }), + ); + if (res.status !== 200) { + await res.body?.cancel(); + return undefined; + } + return depotJson(res, 16 * 1024); +} +const json = (status: number, body: unknown) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", "cache-control": "no-store" }, + }); + +export async function handleDepotCi( + request: Request, + deps: { + depotToken?: string; + bridgeToken?: string; + authorize?: (ticket: string, signal: AbortSignal) => Promise; + fetch: typeof fetch; + }, +): Promise { + const bearer = /^Bearer\s+(\S+)$/.exec(request.headers.get("authorization") ?? "")?.[1]; + if (!deps.bridgeToken || !bearer || !sameToken(bearer, deps.bridgeToken)) + return json(401, { error: "Depot CI bridge bearer refused." }); + if (request.method !== "POST") return json(405, { error: "Depot CI requires POST." }); + let raw: unknown; + try { + raw = await depotJson(new Response(request.body), 16 * 1024); + } catch { + return json(400, { error: DEPOT_ERRORS.invalid }); + } + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return json(400, { error: DEPOT_ERRORS.invalid }); + const parsed = depotCiTicketSchema.safeParse(raw); + if (!parsed.success) return json(400, { error: DEPOT_ERRORS.invalid }); + const signal = AbortSignal.any([request.signal, AbortSignal.timeout(MINUTE_MS)]); + let grant; + try { + signal.throwIfAborted(); + grant = depotCiGrantSchema.safeParse(await deps.authorize?.(parsed.data.ticket, signal)); + } catch { + return json(403, { error: DEPOT_ERRORS.unauthorized }); + } + if (!grant.success) return json(403, { error: DEPOT_ERRORS.unauthorized }); + if (!deps.depotToken) return json(503, { error: DEPOT_ERRORS.unavailable }); + // Both repo and operation came from a one-use permit the authenticated run's + // tool call registered in the bot. Possessing the bridge bearer cannot mint + // one, choose another repo, change its head, or replay it. + const { repo, operation: op } = grant.data; + // Credentials are redacted as exact values too: not every vendor token has a + // recognizable prefix. Strip control sequences before either redaction pass. + const sanitize = (text: string) => + redactSecrets( + stripAnsi(text) + .split(deps.depotToken!) + .join("[redacted]") + .split(deps.bridgeToken!) + .join("[redacted]") + .split(parsed.data.ticket) + .join("[redacted]"), + ); + const safeJson = (value: unknown): unknown => { + if (typeof value === "string") return sanitize(value); + if (Array.isArray(value)) return value.map(safeJson); + if (value && typeof value === "object") + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, safeJson(entry)])); + return value; + }; + let mutationStarted = false; + let upstreamStatus: number | undefined; + const rpc = async (method: "GetWorkflow" | "GetJobAttemptLogs" | "RetryFailedJobs", body: object) => { + signal.throwIfAborted(); + if (method === "RetryFailedJobs") mutationStarted = true; + const res = await deps.fetch(`https://api.depot.dev/depot.ci.v1.CIService/${method}`, { + method: "POST", + redirect: "error", + signal, + headers: { + authorization: `Bearer ${deps.depotToken}`, + "content-type": "application/json", + "connect-protocol-version": "1", + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + upstreamStatus = res.status; + await res.body?.cancel(); + throw new Error("upstream refused"); + } + return depotJson(res); + }; + try { + const workflow = workflowSchema.parse(await rpc("GetWorkflow", { workflowId: op.workflowId })); + if (workflow.repo.toLowerCase() !== repo.toLowerCase() || workflow.workflowId !== op.workflowId) + return json(404, { error: DEPOT_ERRORS.notFound }); + if (op.operation === "inspect") return json(200, safeJson(workflow)); + if (op.operation === "retry_failed") { + const head = workflow.headSha || workflow.sha; + if ( + head.toLowerCase() !== op.expectedHead.toLowerCase() || + !["failed", "cancelled"].includes(workflow.workflowStatus) + ) + return json(409, { error: DEPOT_ERRORS.stale }); + const answer = retrySchema.parse(await rpc("RetryFailedJobs", { workflowId: op.workflowId })); + if (answer.workflowId !== op.workflowId || answer.jobCount !== answer.jobIds.length) + throw new Error("inconsistent retry response"); + return json(200, safeJson(answer)); + } + const jobs = workflow.jobs.filter((j) => j.jobId === op.jobId); + if (jobs.length !== 1) return json(404, { error: DEPOT_ERRORS.notFound }); + const attempts = jobs[0].attempts; + const latest = Math.max(...attempts.map((a) => a.attempt)); + const selected = attempts.filter((a) => (op.attemptId ? a.attemptId === op.attemptId : a.attempt === latest)); + if (selected.length !== 1) return json(404, { error: DEPOT_ERRORS.notFound }); + const attemptId = selected[0].attemptId; + let pageToken: string | undefined; + const seen = new Set(); + const records: string[] = []; + let capturedChars = 0; + let complete = false; + let truncated = false; + const lineLimit = op.lines ?? 200; + for (let page = 0; page < 20; page++) { + const result = logsSchema.parse( + await rpc("GetJobAttemptLogs", { attemptId, ...(pageToken ? { pageToken } : {}) }), + ); + // Keep the finite raw capture until all boundaries can be sanitized. + // A separate aggregate ceiling bounds Worker memory, not just each page. + const chars = result.lines.reduce((sum, line) => sum + line.body.length + 1, 0); + if (capturedChars + chars > 2 * DEPOT_CI_MAX_BYTES) break; + for (const line of result.lines) records.push(line.body); + capturedChars += chars; + if (!result.nextPageToken) { + complete = true; + break; + } + if (seen.has(result.nextPageToken)) break; + seen.add(result.nextPageToken); + pageToken = result.nextPageToken; + } + // Depot records/pages may split a credential. Compare both interpretations + // of their separators BEFORE clipping: if removing newlines changes what is + // secret, withhold the capture rather than guessing which fragment is safe. + // Partial captures are withheld too: a later page could finish a credential + // or a PEM block. This fails closed without retaining unbounded raw logs. + const rawText = complete ? records.join("\n") : ""; + const sanitized = sanitize(rawText); + const withheld = !complete + ? "incomplete capture" + : sanitized.replace(/\n/g, "") !== sanitize(rawText.replace(/\n/g, "")) + ? "credential spans log records" + : undefined; + let text = ""; + if (!withheld) { + const lines = sanitized.split("\n"); + truncated = lines.length > lineLimit; + text = lines.slice(-lineLimit).join("\n"); + if (text.length > 50_000) { + text = text.slice(-50_000); + truncated = true; + } + } + return json(200, { + workflowId: op.workflowId, + jobId: op.jobId, + attemptId, + complete, + truncated: truncated || !!withheld, + ...(withheld ? { withheld } : {}), + text, + }); + } catch { + return json(502, { + error: mutationStarted + ? DEPOT_ERRORS.unknown + : upstreamStatus + ? `Depot CI upstream refused (HTTP ${upstreamStatus}).` + : DEPOT_ERRORS.failed, + }); + } +} diff --git a/deploy/cloudflare/vitest.config.mjs b/deploy/cloudflare/vitest.config.mjs index fff59288b..e198f1b0e 100644 --- a/deploy/cloudflare/vitest.config.mjs +++ b/deploy/cloudflare/vitest.config.mjs @@ -16,6 +16,7 @@ export default defineConfig({ "ensure-bucket.test.mjs", "artifactsCopy.test.ts", "prImages.test.ts", + "depotCi.test.ts", "knownLength.test.ts", "coordinator.test.ts", "modelProxyForwarding.test.ts", diff --git a/deploy/cloudflare/worker.ts b/deploy/cloudflare/worker.ts index d656d5594..ea42c8b04 100644 --- a/deploy/cloudflare/worker.ts +++ b/deploy/cloudflare/worker.ts @@ -1,3 +1,5 @@ +import { depotCiAuthorization, handleDepotCi } from "./depotCi.ts"; +import { DEPOT_CI_AUTHORIZATION_PATH, DEPOT_CI_PATH } from "../../src/core/depotCi.ts"; // Cloudflare Containers shim: runs the unchanged Switchboard image as a single // always-on container instance — the shape of any long-lived server on // Cloudflare Containers (singleton DO, cron keep-alive; @@ -83,6 +85,8 @@ export interface Env { GITHUB_APP_ID?: string; GITHUB_APP_INSTALLATION_ID?: string; GITHUB_APP_PRIVATE_KEY?: string; + DEPOT_API_TOKEN?: string; // Worker-only organization credential: NEVER forwarded to any container + DEPOT_CI_BRIDGE_TOKEN?: string; // bot → Worker, narrow repo-bound CI operations only PUBLIC_BASE_URL?: string; // live-view: base for /runs/?t=… links on the status card ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss) ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag @@ -124,6 +128,7 @@ const FORWARDED_OPTIONAL = [ "GITHUB_APP_ID", "GITHUB_APP_INSTALLATION_ID", "GITHUB_APP_PRIVATE_KEY", + "DEPOT_CI_BRIDGE_TOKEN", "PUBLIC_BASE_URL", "ACCESS_TEAM_DOMAIN", "ACCESS_AUD", @@ -458,6 +463,17 @@ const withLength = (res: Response): Response => withKnownLength(res, (size) => n export default { async fetch(request: Request, env: Env): Promise { const pathname = new URL(request.url).pathname; + // Permits can only be consumed through the Worker's fixed container + // binding. The public edge must never proxy this callback path. + if (pathname === DEPOT_CI_AUTHORIZATION_PATH) return new Response("not found", { status: 404 }); + if (pathname === DEPOT_CI_PATH) + return handleDepotCi(request, { + depotToken: env.DEPOT_API_TOKEN, + bridgeToken: env.DEPOT_CI_BRIDGE_TOKEN, + authorize: (ticket, signal) => + depotCiAuthorization(ticket, signal, (req) => getContainer(env.SWITCHBOARD, INSTANCE).fetch(req)), + fetch: (input, init) => fetch(input, init), + }); // Only deliberately published copies are public. The existing run reader // and the bucket stay private; the write endpoint checks its own bearer. if (pathname.startsWith("/pr-images/")) return withLength(await handlePrImage(request, env.ARTIFACTS)); diff --git a/deploy/secrets.manifest.json b/deploy/secrets.manifest.json index 9cbb5d509..ad64a175d 100644 --- a/deploy/secrets.manifest.json +++ b/deploy/secrets.manifest.json @@ -1,6 +1,18 @@ { - "$comment": "Every Cloudflare Worker secret Switchboard provisions: its name, which Worker(s) hold it, and whether a Worker may go without it (`optional: true` everywhere, or a list of the Workers it is optional on — a shared bearer is required on the Worker that serves the feature and optional on the bot until that feature is configured). This file is the contract — src/core/secretsManifest.test.ts keeps it equal to each worker.ts `Env` interface and to the bot container's forwarding list. WHERE the values live is the deployment profile's `secretsSource` (a directory of files, `~/.secrets/switchboard` by default, or a 1Password item `op://Vault/Item` with one field per name); `deploy secrets ` (`npm run secrets` in each deploy/cloudflare*/) puts them, refusing before any upload when a required value is absent. A shared bearer must carry ONE value on every Worker listed for it. Rotation: new value at the source, `deploy secrets` on EVERY listed Worker, then `deploy restart` for the bot (its running container keeps the env it started with). Public config (URLs, ids of no consequence) is a wrangler.jsonc var, not a secret.", + "$comment": "Every Cloudflare Worker secret Switchboard provisions: its name, which Worker(s) hold it, and whether a Worker may go without it (`optional: true` everywhere, or a list of the Workers it is optional on — a shared bearer is required on the Worker that serves the feature and optional on the bot until that feature is configured). This file is the contract — src/core/secretsManifest.test.ts keeps it equal to each worker.ts `Env` interface and to the bot container's forwarding list, except the explicitly Worker-only DEPOT_API_TOKEN (which must never be forwarded). WHERE the values live is the deployment profile's `secretsSource` (a directory of files, `~/.secrets/switchboard` by default, or a 1Password item `op://Vault/Item` with one field per name); `deploy secrets ` (`npm run secrets` in each deploy/cloudflare*/) puts them, refusing before any upload when a required value is absent. A shared bearer must carry ONE value on every Worker listed for it. Rotation: new value at the source, `deploy secrets` on EVERY listed Worker, then `deploy restart` for the bot (its running container keeps the env it started with). Public config (URLs, ids of no consequence) is a wrangler.jsonc var, not a secret.", "secrets": [ + { + "name": "DEPOT_API_TOKEN", + "workers": ["bot"], + "optional": true, + "note": "Depot organization token, held ONLY by the bot Worker edge. Explicitly excluded from bot container forwarding; never put in a bot env, coding container, resident or shell. Used only for repo-verified workflow inspection, logs and failed-only retry. Rotate in Depot organization settings, provision with deploy secrets bot." + }, + { + "name": "DEPOT_CI_BRIDGE_TOKEN", + "workers": ["bot"], + "optional": true, + "note": "Separate self-minted internal bearer: trusted bot → its Worker's narrow /internal/depot-ci operation. Forwarded to the bot only, never model containers. This is NOT a Depot credential. Absent disables CI tools; provision with deploy secrets bot and restart the bot." + }, { "name": "SLACK_BOT_TOKEN", "workers": ["bot"], diff --git a/docs/reference/specs/README.md b/docs/reference/specs/README.md index c5aebc062..37089be8f 100644 --- a/docs/reference/specs/README.md +++ b/docs/reference/specs/README.md @@ -54,6 +54,7 @@ Rename a test and the build is red until the spec changes with it. Adopting the | [resident-repos.md](resident-repos.md) | Resident repo environments: auth scopes, atomic cap, lifecycle engine, thread data plane, bot-side selection, `repo onboard/offboard/rebuild/list` chat commands (fail-closed gate, --dry-run plans) | | [agent-general.md](agent-general.md) | The default agent: fast model, `assistant` toolset — GitHub repo reads + issue writes, URL reading, no workspace; redirects code/PR/web-research asks | | [github-tools.md](github-tools.md) | The `github_*` tools: repo reads (repos, tree, file, code search), Actions run triage (a run, its jobs, a job's log) and issue read/write over the App credential from the bot process, per-repo write gate, toolset enablement (`assistant` for general, reads for research/review) | +| [depot-ci.md](depot-ci.md) | Coding-only, repo-bound Depot CI workflow/log inspection and failed-only retry; organization token held at the Worker edge, never the bot or coding container | | [web-tools.md](web-tools.md) | `web_fetch` (SSRF-hardened URL reading, binary links as model-visible blocks) + `web_search` (Brave/Null seam); the `research` agent | | [public-hygiene.md](public-hygiene.md) | The public tree carries no company, person, tracker, plan-id, platform-id or incident-date imprint; a ratchet (`hygiene:check`) counts every hit per file and class and only shrinks, with lines allowed by name | | [agent-review.md](agent-review.md) | Code review agent | diff --git a/docs/reference/specs/depot-ci.md b/docs/reference/specs/depot-ci.md new file mode 100644 index 000000000..14fd8cf3b --- /dev/null +++ b/docs/reference/specs/depot-ci.md @@ -0,0 +1,37 @@ +# Depot CI: repo-bound failure inspection and failed-only retry + +Coding children need the logs behind Depot check links without a person relaying them. The organization credential grants much more than CI reads: it must remain at the credential-holding Worker, outside both the bot host and model-controlled containers. + +- **Code**: `src/core/depotCi.ts`, `src/execution/depotCi.ts`, `src/execution/depotCiAuthorization.ts`, `src/index.ts`, `src/tools/depotCi.ts`, `src/tools/toolsets.ts`, `src/tools/runnableTool.ts`, `src/core/dispatch/runLoop.ts`, `deploy/cloudflare/depotCi.ts`, `deploy/cloudflare/worker.ts`, `deploy/secrets.manifest.json`. +- **Tests**: `src/execution/depotCi.test.ts`, `src/execution/depotCiAuthorization.test.ts`, `src/tools/depotCi.test.ts`, `deploy/cloudflare/depotCi.test.ts`, `src/core/secretsManifest.test.ts`, `src/core/dispatch/runLoop.test.ts`. + +## Behavior + +1. **One narrow bridge, not a CLI or token tool.** The bot Worker's `POST /internal/depot-ci` authenticates `DEPOT_CI_BRIDGE_TOKEN` and accepts only a one-use ticket for `inspect`, `logs`, or `retry_failed`. The bearer alone is insufficient authority. Only the Worker holds `DEPOT_API_TOKEN` (an organization token); it is explicitly excluded from container forwarding. The bridge bearer reaches the trusted bot, never the coding environment. No Depot CLI installation, arbitrary RPC name, URL, header, organization, workflow content, dispatch, cancel, secret access or full rerun is exposed. Unconfigured credentials fail closed by name. The existing harness relay authenticates the child and supplies its tool context; no new child-facing HTTP ingress is added. +2. **The repository is not a model argument.** The dispatcher binds the authenticated run ID, resolved repo and requester's `canUseRepo` predicate. All three tools are coding-only (`full`). Every call checks the live run and predicate before network access, then registers a random 256-bit one-use permit for that exact operation (including workflow, job/attempt and retry head). The bridge request carries only the ticket, never caller-supplied run/repo/operation authority. Before using the Depot credential, the Worker consumes the permit over its fixed singleton-container binding at `POST /internal/depot-ci/authorization`, validates the returned run/repo/operation and derives its upstream request solely from that grant. The callback rechecks live-run/repo authorization and consumes even a refused permit; wrong, revoked, aborted, expired, completed and replayed permits fail closed. The public Worker refuses the callback path; it exposes no mint/list endpoint. Permits expire with the one-minute operation, are removed on completion/abort, and deliberately vanish on bot restart: they are transient in-flight authorizations, not durable run state or permission to replay an unknown mutation. The Worker reads `GetWorkflow` and verifies its repository against the independently resolved binding before returning metadata, reading logs or writing. A foreign workflow is a generic refusal, not a metadata disclosure. `logs` also proves job and attempt membership from that workflow. Unknown or malformed identities refuse rather than guessing. A Depot check URL supplies only its workflow ID; the URL is never fetched. +3. **Finite, sanitized evidence.** `inspect` returns workflow/run identity, exact SHAs, status, errors, jobs and attempts. `logs` reads an explicitly selected attempt or the highest numbered attempt of the selected job, using `GetJobAttemptLogs`. Pagination stays pinned to that attempt; at most 20 pages of at most 2 MiB each are read under one one-minute operation deadline. Raw capture additionally stops at 4,194,304 characters including record separators, bounding Worker memory. Redaction processes the entire capture before any tail clipping, not each page separately. It compares newline-preserving and newline-free redaction so credentials, assignments, ANSI sequences and private-key blocks split across records/pages cannot leak a fragment. An ambiguous split withholds the whole capture with `withheld: "credential spans log records"`; hitting a page/aggregate ceiling or pagination loop withholds it with `withheld: "incomplete capture"`, since unread text could finish a secret. Withheld responses contain empty `text` and `truncated: true`; they are not evidence that a job produced no logs. Otherwise output keeps a bounded tail (default 200, at most 2000 lines and 50,000 characters), with explicit completeness/truncation markers. ANSI, recognized secrets, exact configured credentials and the request ticket are removed before output is clipped; arbitrary upstream error bodies and transport exceptions are never returned. The upstream host and RPC paths are fixed, redirects are refused, and response bodies are byte-bounded. Logs are untrusted data, never instructions. +4. **Failed-only has Depot's precise meaning.** `retry_failed` requires the full expected head SHA and a failed/cancelled workflow belonging to the bound repo. The head is `headSha` when present, otherwise `sha`. It calls only `RetryFailedJobs({workflowId})`, once, and reports returned job IDs/count. Depot retries failed/cancelled jobs plus their skipped dependants; successful jobs remain untouched. It does not call `RerunWorkflow`, GitHub check rerequest or Actions retry. No automatic retry follows any upstream failure; a lost/invalid mutation response reports an unknown outcome and requires inspection before another request. Even HTTP 200 from the bridge requires a complete normalized acknowledgement: the requested workflow ID, an array of valid job IDs, and a nonnegative integer count equal to its length, with no extra fields. A missing, malformed, inconsistent or foreign-workflow acknowledgement is an unknown outcome, never success. This is not an exactly-once mutation guarantee across separate tool calls. +5. **Auth and API contract.** Depot's [authentication matrix](https://depot.dev/docs/cli/authentication) allows user and organization tokens for CI, not project/pull tokens; project-scoped OIDC is not a CI credential. The [API authentication](https://depot.dev/docs/api/authentication) uses an organization bearer. The [CI API](https://depot.dev/docs/api/ci/reference) is Connect JSON at `https://api.depot.dev/depot.ci.v1.CIService/`, with `Content-Type: application/json` and `Connect-Protocol-Version: 1`. The [CLI equivalent](https://depot.dev/docs/cli/reference/depot-ci#depot-ci-retry) is `depot ci retry --failed --workflow `; `--failed` and `--job` are mutually exclusive. These are documentation-backed contracts, not evidence of successful live credential provisioning. + +## Provisioning and live acceptance + +Provision `DEPOT_API_TOKEN` and a separately minted `DEPOT_CI_BRIDGE_TOKEN` with the existing `deploy secrets bot --only` path. The former stays in the Worker; only the latter is forwarded to the bot. `PUBLIC_BASE_URL` identifies that Worker's HTTPS origin. Do not put the organization token in a bot `.env`, agent environment bootstrap, resident secret, container image or shell. Deploy the Worker and bot from the same reviewed release, then restart through the normal human-controlled release path. A mixed-version bridge fails closed: the old repo-bearing body is no longer accepted. Without this configuration the tools name the missing capability rather than suggesting a shell/token workaround. + +After deployment, start a coding child on a repo with a real failed Depot check. Record its exact Slack permalink, routed preset/card, Ship unit/ref, deployed commit, check link and head in the operator's tracker. In the child's own timeline, call `depot_ci_inspect` on the check URL, then `depot_ci_logs` on the failed job. The child must identify the actual failed test/step from returned log text, without human-pasted logs. Request `depot_ci_retry_failed` with the observed full head; observe new attempts for the returned jobs and unchanged successful attempts. Confirm a foreign-repo workflow, foreign job/attempt and mismatched SHA are refused. Record the receipt outside this repository; fixture tests cannot substitute for this deployment-gated proof. + +**Live proof status: unproven, human-gated.** No real-child diagnosis/retry receipt is supplied by the fixture tests. The gate stays open until the reviewed release is deployed with edge-only credentials and the procedure above produces the child's actual check/head, log diagnosis and observed failed-only retry result. Do not provision the organization credential in a bot or coding shell to bypass this gate. + +## Validation criteria + +| Criterion | Proof | +|---|---| +| Edge requires the bridge bearer plus an independently consumed run/repo/operation permit, refuses authority overrides/replay, binds workflow/job/attempt to repo, and never returns foreign metadata | `[unit]` `deploy/cloudflare/depotCi.test.ts::Depot CI edge::*` | +| Upstream uses fixed Connect paths, no redirects, bounded/redacted logs, exact-head failed-only retry, and honest unknown mutation outcomes | `[unit]` `deploy/cloudflare/depotCi.test.ts::Depot CI edge::*` | +| An HTTP 200 RetryFailedJobs acknowledgement missing jobIds or jobCount is an unknown outcome, with no replay | `[unit]` `deploy/cloudflare/depotCi.test.ts::Depot CI edge::treats incomplete upstream retry acknowledgements as unknown outcomes` | +| One-use permits preserve immutable bindings, recheck authority, disappear on abort/completion/restart, and cannot be minted over HTTP | `[unit]` `src/execution/depotCiAuthorization.test.ts::Depot CI one-use authorization::*` | +| Bot bridge binds repo outside model input, rechecks authorization, strips error detail, and never needs the Depot token | `[unit]` `src/execution/depotCi.test.ts::Depot CI bridge::*` | +| Malformed HTTP 200 retry acknowledgements report unknown outcome without retrying or retaining a permit | `[unit]` `src/execution/depotCi.test.ts::Depot CI bridge::treats malformed HTTP 200 retry acknowledgements as unknown without replay…` | +| A Ship coding child receives failure text through the authenticated harness relay with its resolved repository, no credential in the evidence | `[unit]` `src/core/dispatch/runLoop.test.ts::runLoop — the model turn and everything that rides on it::a coding child reads Depot failure evidence through its repo-bound Worker bridge` | +| Coding-only tools accept check links, refuse broad inputs, expose finite inspection/logs and failed-only retry | `[unit]` `src/tools/depotCi.test.ts::Depot CI tools::*` | +| Organization token is Worker-only; bridge bearer alone reaches the bot | `[unit]` `src/core/secretsManifest.test.ts::deploy/secrets.manifest.json::Depot credentials stay at the edge and only the bridge bearer reaches the bot` | +| A real coding child diagnoses a failed Depot check and retries only failed/cancelled jobs and skipped dependants | `[gap]` Follow the live acceptance procedure above; report deployment or credential blockers explicitly. **Unproven until a real receipt is recorded.** | diff --git a/docs/reference/specs/release-and-deploy.md b/docs/reference/specs/release-and-deploy.md index 5aaa31dc2..ba387b1d1 100644 --- a/docs/reference/specs/release-and-deploy.md +++ b/docs/reference/specs/release-and-deploy.md @@ -33,7 +33,7 @@ Runner validation `[agent]`: after a PR push, run `gh run list --branch 15. **`deploy all` pushes the bot's config to the state Worker; the image never holds it.** The profile's `configSource` is a path (the default), `github://owner/repo/path@ref` read from the contents API with `CONFIG_REPO_TOKEN`, or `op://Vault/Item/field` read with the `op` CLI under `OP_SERVICE_ACCOUNT_TOKEN`. When the bot is a step, the config is read and validated (`parseAppConfigText`) after the pre-checks and BEFORE any Worker deploys — a source that cannot be read (a missing file, a missing token, a 404, a missing CLI), a config that does not validate, or a missing `MEMORY_TOKEN` refuses the deploy naming the source, the problem, or the variable — and then pushed as the `base` document on the profile's state Worker (`src/configDocument.ts`; [routing-and-config.md](routing-and-config.md) item 14) right before the bot step, after the memory step has rolled; a push that fails stops the run at the bot step naming the Worker's answer. The bot Worker's container reads that document at start (`SWITCHBOARD_CONFIG=state://base`, with `STATE_WORKER_URL` and `MEMORY_TOKEN` forwarded), so the Dockerfile copies no `config/` and `config/` is inert to `--affected`. `deploy config [--source]` pushes alone — how a config change goes live without a release: push, then `deploy restart`. The plan carries `config: { source, document, stateWorkerUrl }` and prints it. A document read through the production `state://` profile has a stricter floor than a local file config: it must carry `runHistory.worker`, at least one provider and at least one grant. Missing or invalid is never a bare default: the process opens only a refusal probe, `/healthz` answers 503 with `config: missing base document` or the validator's sentence, and no Slack, ingress, command or model route starts; the previous generation therefore remains the only live one during a rollout. 16. **The sandbox is live when the Worker, the rollout and a probe agree.** A sandbox deploy is two artifacts — the Worker version `wrangler deploy` uploads at once and the container image Cloudflare rolls out afterwards, instance by instance — and in between the new Worker can be handed a container still on the previous image; a Durable Object created then runs on it until the rollout's wave replaces that instance. A run that starts in that window lands on a container from the previous image under the new Worker's SDK, and every exec fails — with the skew shape [execution.md](execution.md) item 6 names: an EMPTY error under the 0.12 SDK, the container server's `'utils.getRuntimeMetadata' is not a function.` under the 0.13 one — while the deploy has already said `deployed` and exited 0. So the sandbox step carries a live gate like the bot's (`WORKER_SPECS[].liveGate` is the static `kind: "sandbox"` spec — bearer env + container class — next to the bot's `kind: "bot"` application + exact-health gate; `workersFor(profile)` binds it to the profile's `/healthz` and to the Containers application wrangler names from the profile's script, `