@coreplane/switchboard 1.265.1 → 1.266.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/.env.example +6 -0
  2. package/dist/assets/deploy/cloudflare/depotCi.ts +246 -0
  3. package/dist/assets/deploy/cloudflare/worker.ts +23 -1
  4. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +5 -1
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +48 -3
  6. package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +43 -0
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +102 -141
  8. package/dist/assets/deploy/secrets.manifest.json +13 -1
  9. package/dist/assets/package-lock.json +3 -3
  10. package/dist/assets/package.json +1 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/channels/githubDoorPaths.ts +36 -0
  13. package/dist/assets/src/core/coordinator/contract.ts +2 -0
  14. package/dist/assets/src/core/depotCi.ts +72 -0
  15. package/dist/assets/src/core/refusal.ts +2 -0
  16. package/dist/assets/src/core/runEvents.ts +1 -0
  17. package/dist/assets/src/core/runRecord.ts +41 -0
  18. package/dist/assets/src/execution/residentReadonly.ts +9 -8
  19. package/dist/assets/src/execution/residentSteps.ts +2 -1
  20. package/dist/assets/src/execution/seedPlan.ts +2 -2
  21. package/dist/assets/web/dist/.vite/manifest.json +50 -50
  22. package/dist/assets/web/dist/assets/{HomePage-CBQpcnaW.js → HomePage-BfpIrKDX.js} +1 -1
  23. package/dist/assets/web/dist/assets/{PendingTurnRow-DIC86uWA.js → PendingTurnRow-C6hroa00.js} +1 -1
  24. package/dist/assets/web/dist/assets/{PlanePage-CfrbMh8t.js → PlanePage-C2Cb1mkT.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentDetailPage-C-SgOUTl.js → ResidentDetailPage-Bd9_Q87l.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CiDhvXok.js → ResidentsIndexPage-C8eS7hxp.js} +1 -1
  27. package/dist/assets/web/dist/assets/{RunFoldRow-DZ8huwUU.js → RunFoldRow-DrfFauYJ.js} +1 -1
  28. package/dist/assets/web/dist/assets/{RunRoutePage-Cp6wicYc.js → RunRoutePage-B_eXfNAF.js} +3 -3
  29. package/dist/assets/web/dist/assets/{RunsIndexPage-BPXtfwwx.js → RunsIndexPage-BpQu36Dm.js} +1 -1
  30. package/dist/assets/web/dist/assets/{ScheduledPage-DbvP48HI.js → ScheduledPage-DGHcjcSt.js} +1 -1
  31. package/dist/assets/web/dist/assets/{SettingsPage-B87lZaiN.js → SettingsPage-IfQ71QjN.js} +1 -1
  32. package/dist/assets/web/dist/assets/{SilentTurn-CPED_XCV.js → SilentTurn-D9ON15a8.js} +1 -1
  33. package/dist/assets/web/dist/assets/{StatusDot-CFLn-DZ2.js → StatusDot-DyFjGg4V.js} +1 -1
  34. package/dist/assets/web/dist/assets/{Tooltip-Dx9YBVEx.js → Tooltip-BvPkV4kZ.js} +1 -1
  35. package/dist/assets/web/dist/assets/{UnitRoutePage-d4A2X_o7.js → UnitRoutePage-BNe3nBIG.js} +1 -1
  36. package/dist/assets/web/dist/assets/{dist-Cn51vWpw.js → dist-v3Py3_QR.js} +1 -1
  37. package/dist/assets/web/dist/assets/main-DPB0UO1-.css +1 -0
  38. package/dist/assets/web/dist/assets/main-ks3kBojV.js +28 -0
  39. package/dist/assets/web/dist/assets/{sseReplay-DGRDRLua.js → sseReplay-DuALaBPq.js} +1 -1
  40. package/dist/cli.js +2950 -1260
  41. package/package.json +1 -1
  42. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +0 -1
  43. package/dist/assets/web/dist/assets/main-DYwRHh0F.js +0 -28
@@ -33,3 +33,9 @@ E2B_API_KEY=e2b_...
33
33
  # GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
34
34
  # (b) Fallback: a fine-grained PAT scoped to only the repos the bot may touch.
35
35
  # GH_TOKEN=github_pat_...
36
+
37
+ # Depot CI bridge (optional; bot only). Depot's organization token must NOT be
38
+ # in this file or any container: provision DEPOT_API_TOKEN only on the Worker.
39
+ # Provision a separate internal bearer on that Worker; it forwards this one to
40
+ # the bot. PUBLIC_BASE_URL must be the Worker's HTTPS origin. No Depot CLI needed.
41
+ # DEPOT_CI_BRIDGE_TOKEN=...
@@ -0,0 +1,246 @@
1
+ // Depot's organization token belongs to this Worker alone. The bot sends a
2
+ // repo-bound, typed operation; this adapter resolves ownership before spending
3
+ // that credential on logs or a mutation. Nothing here executes repository code.
4
+ import { z } from "zod";
5
+ import { MINUTE_MS } from "../../src/core/budgets.ts";
6
+ import {
7
+ DEPOT_CI_AUTHORIZATION_PATH,
8
+ DEPOT_CI_MAX_BYTES,
9
+ DEPOT_ERRORS,
10
+ depotCiGrantSchema,
11
+ depotCiTicketSchema,
12
+ depotId,
13
+ depotJson,
14
+ } from "../../src/core/depotCi.ts";
15
+ import { redactSecrets, stripAnsi } from "../../src/core/redact.ts";
16
+ import { sameToken } from "./artifactsCopy.ts";
17
+ import { INTERNAL } from "./shared.ts";
18
+
19
+ const attemptSchema = z.object({ attemptId: depotId, attempt: z.number().int().positive(), status: z.string() });
20
+ const workflowSchema = z.object({
21
+ orgId: depotId,
22
+ runId: depotId,
23
+ repo: z.string(),
24
+ workflowId: depotId,
25
+ headSha: z.string().default(""),
26
+ sha: z.string().default(""),
27
+ workflowStatus: z.string(),
28
+ workflowName: z.string().default(""),
29
+ workflowPath: z.string().default(""),
30
+ workflowErrorMessage: z.string().default(""),
31
+ jobs: z
32
+ .array(
33
+ z.object({
34
+ jobId: depotId,
35
+ jobKey: z.string(),
36
+ jobDisplayName: z.string().default(""),
37
+ status: z.string(),
38
+ attempts: z.array(attemptSchema).default([]),
39
+ }),
40
+ )
41
+ .default([]),
42
+ });
43
+ const logsSchema = z.object({
44
+ lines: z.array(z.object({ body: z.string(), stepName: z.string().default("") })).default([]),
45
+ nextPageToken: z.string().max(8192).default(""),
46
+ });
47
+ const retrySchema = z.object({
48
+ workflowId: depotId,
49
+ jobIds: z.array(depotId),
50
+ jobCount: z.number().int().nonnegative(),
51
+ });
52
+ /** The callback has no caller-controlled origin or path. Worker wiring supplies
53
+ * the singleton container binding, NOT an Internet fetch or a redirect. */
54
+ export async function depotCiAuthorization(
55
+ ticket: string,
56
+ signal: AbortSignal,
57
+ botFetch: (request: Request) => Promise<Response>,
58
+ ): Promise<unknown> {
59
+ const res = await botFetch(
60
+ new Request(`${INTERNAL}${DEPOT_CI_AUTHORIZATION_PATH}`, {
61
+ method: "POST",
62
+ redirect: "error",
63
+ signal,
64
+ headers: { authorization: `Bearer ${ticket}` },
65
+ }),
66
+ );
67
+ if (res.status !== 200) {
68
+ await res.body?.cancel();
69
+ return undefined;
70
+ }
71
+ return depotJson(res, 16 * 1024);
72
+ }
73
+ const json = (status: number, body: unknown) =>
74
+ new Response(JSON.stringify(body), {
75
+ status,
76
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
77
+ });
78
+
79
+ export async function handleDepotCi(
80
+ request: Request,
81
+ deps: {
82
+ depotToken?: string;
83
+ bridgeToken?: string;
84
+ authorize?: (ticket: string, signal: AbortSignal) => Promise<unknown>;
85
+ fetch: typeof fetch;
86
+ },
87
+ ): Promise<Response> {
88
+ const bearer = /^Bearer\s+(\S+)$/.exec(request.headers.get("authorization") ?? "")?.[1];
89
+ if (!deps.bridgeToken || !bearer || !sameToken(bearer, deps.bridgeToken))
90
+ return json(401, { error: "Depot CI bridge bearer refused." });
91
+ if (request.method !== "POST") return json(405, { error: "Depot CI requires POST." });
92
+ let raw: unknown;
93
+ try {
94
+ raw = await depotJson(new Response(request.body), 16 * 1024);
95
+ } catch {
96
+ return json(400, { error: DEPOT_ERRORS.invalid });
97
+ }
98
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return json(400, { error: DEPOT_ERRORS.invalid });
99
+ const parsed = depotCiTicketSchema.safeParse(raw);
100
+ if (!parsed.success) return json(400, { error: DEPOT_ERRORS.invalid });
101
+ const signal = AbortSignal.any([request.signal, AbortSignal.timeout(MINUTE_MS)]);
102
+ let grant;
103
+ try {
104
+ signal.throwIfAborted();
105
+ grant = depotCiGrantSchema.safeParse(await deps.authorize?.(parsed.data.ticket, signal));
106
+ } catch {
107
+ return json(403, { error: DEPOT_ERRORS.unauthorized });
108
+ }
109
+ if (!grant.success) return json(403, { error: DEPOT_ERRORS.unauthorized });
110
+ if (!deps.depotToken) return json(503, { error: DEPOT_ERRORS.unavailable });
111
+ // Both repo and operation came from a one-use permit the authenticated run's
112
+ // tool call registered in the bot. Possessing the bridge bearer cannot mint
113
+ // one, choose another repo, change its head, or replay it.
114
+ const { repo, operation: op } = grant.data;
115
+ // Credentials are redacted as exact values too: not every vendor token has a
116
+ // recognizable prefix. Strip control sequences before either redaction pass.
117
+ const sanitize = (text: string) =>
118
+ redactSecrets(
119
+ stripAnsi(text)
120
+ .split(deps.depotToken!)
121
+ .join("[redacted]")
122
+ .split(deps.bridgeToken!)
123
+ .join("[redacted]")
124
+ .split(parsed.data.ticket)
125
+ .join("[redacted]"),
126
+ );
127
+ const safeJson = (value: unknown): unknown => {
128
+ if (typeof value === "string") return sanitize(value);
129
+ if (Array.isArray(value)) return value.map(safeJson);
130
+ if (value && typeof value === "object")
131
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, safeJson(entry)]));
132
+ return value;
133
+ };
134
+ let mutationStarted = false;
135
+ let upstreamStatus: number | undefined;
136
+ const rpc = async (method: "GetWorkflow" | "GetJobAttemptLogs" | "RetryFailedJobs", body: object) => {
137
+ signal.throwIfAborted();
138
+ if (method === "RetryFailedJobs") mutationStarted = true;
139
+ const res = await deps.fetch(`https://api.depot.dev/depot.ci.v1.CIService/${method}`, {
140
+ method: "POST",
141
+ redirect: "error",
142
+ signal,
143
+ headers: {
144
+ authorization: `Bearer ${deps.depotToken}`,
145
+ "content-type": "application/json",
146
+ "connect-protocol-version": "1",
147
+ },
148
+ body: JSON.stringify(body),
149
+ });
150
+ if (!res.ok) {
151
+ upstreamStatus = res.status;
152
+ await res.body?.cancel();
153
+ throw new Error("upstream refused");
154
+ }
155
+ return depotJson(res);
156
+ };
157
+ try {
158
+ const workflow = workflowSchema.parse(await rpc("GetWorkflow", { workflowId: op.workflowId }));
159
+ if (workflow.repo.toLowerCase() !== repo.toLowerCase() || workflow.workflowId !== op.workflowId)
160
+ return json(404, { error: DEPOT_ERRORS.notFound });
161
+ if (op.operation === "inspect") return json(200, safeJson(workflow));
162
+ if (op.operation === "retry_failed") {
163
+ const head = workflow.headSha || workflow.sha;
164
+ if (
165
+ head.toLowerCase() !== op.expectedHead.toLowerCase() ||
166
+ !["failed", "cancelled"].includes(workflow.workflowStatus)
167
+ )
168
+ return json(409, { error: DEPOT_ERRORS.stale });
169
+ const answer = retrySchema.parse(await rpc("RetryFailedJobs", { workflowId: op.workflowId }));
170
+ if (answer.workflowId !== op.workflowId || answer.jobCount !== answer.jobIds.length)
171
+ throw new Error("inconsistent retry response");
172
+ return json(200, safeJson(answer));
173
+ }
174
+ const jobs = workflow.jobs.filter((j) => j.jobId === op.jobId);
175
+ if (jobs.length !== 1) return json(404, { error: DEPOT_ERRORS.notFound });
176
+ const attempts = jobs[0].attempts;
177
+ const latest = Math.max(...attempts.map((a) => a.attempt));
178
+ const selected = attempts.filter((a) => (op.attemptId ? a.attemptId === op.attemptId : a.attempt === latest));
179
+ if (selected.length !== 1) return json(404, { error: DEPOT_ERRORS.notFound });
180
+ const attemptId = selected[0].attemptId;
181
+ let pageToken: string | undefined;
182
+ const seen = new Set<string>();
183
+ const records: string[] = [];
184
+ let capturedChars = 0;
185
+ let complete = false;
186
+ let truncated = false;
187
+ const lineLimit = op.lines ?? 200;
188
+ for (let page = 0; page < 20; page++) {
189
+ const result = logsSchema.parse(
190
+ await rpc("GetJobAttemptLogs", { attemptId, ...(pageToken ? { pageToken } : {}) }),
191
+ );
192
+ // Keep the finite raw capture until all boundaries can be sanitized.
193
+ // A separate aggregate ceiling bounds Worker memory, not just each page.
194
+ const chars = result.lines.reduce((sum, line) => sum + line.body.length + 1, 0);
195
+ if (capturedChars + chars > 2 * DEPOT_CI_MAX_BYTES) break;
196
+ for (const line of result.lines) records.push(line.body);
197
+ capturedChars += chars;
198
+ if (!result.nextPageToken) {
199
+ complete = true;
200
+ break;
201
+ }
202
+ if (seen.has(result.nextPageToken)) break;
203
+ seen.add(result.nextPageToken);
204
+ pageToken = result.nextPageToken;
205
+ }
206
+ // Depot records/pages may split a credential. Compare both interpretations
207
+ // of their separators BEFORE clipping: if removing newlines changes what is
208
+ // secret, withhold the capture rather than guessing which fragment is safe.
209
+ // Partial captures are withheld too: a later page could finish a credential
210
+ // or a PEM block. This fails closed without retaining unbounded raw logs.
211
+ const rawText = complete ? records.join("\n") : "";
212
+ const sanitized = sanitize(rawText);
213
+ const withheld = !complete
214
+ ? "incomplete capture"
215
+ : sanitized.replace(/\n/g, "") !== sanitize(rawText.replace(/\n/g, ""))
216
+ ? "credential spans log records"
217
+ : undefined;
218
+ let text = "";
219
+ if (!withheld) {
220
+ const lines = sanitized.split("\n");
221
+ truncated = lines.length > lineLimit;
222
+ text = lines.slice(-lineLimit).join("\n");
223
+ if (text.length > 50_000) {
224
+ text = text.slice(-50_000);
225
+ truncated = true;
226
+ }
227
+ }
228
+ return json(200, {
229
+ workflowId: op.workflowId,
230
+ jobId: op.jobId,
231
+ attemptId,
232
+ complete,
233
+ truncated: truncated || !!withheld,
234
+ ...(withheld ? { withheld } : {}),
235
+ text,
236
+ });
237
+ } catch {
238
+ return json(502, {
239
+ error: mutationStarted
240
+ ? DEPOT_ERRORS.unknown
241
+ : upstreamStatus
242
+ ? `Depot CI upstream refused (HTTP ${upstreamStatus}).`
243
+ : DEPOT_ERRORS.failed,
244
+ });
245
+ }
246
+ }
@@ -1,3 +1,6 @@
1
+ import { depotCiAuthorization, handleDepotCi } from "./depotCi.ts";
2
+ import { DEPOT_CI_AUTHORIZATION_PATH, DEPOT_CI_PATH } from "../../src/core/depotCi.ts";
3
+ import { githubDoorEdgeRoute } from "../../src/channels/githubDoorPaths.ts";
1
4
  // Cloudflare Containers shim: runs the unchanged Switchboard image as a single
2
5
  // always-on container instance — the shape of any long-lived server on
3
6
  // Cloudflare Containers (singleton DO, cron keep-alive;
@@ -83,7 +86,10 @@ export interface Env {
83
86
  GITHUB_APP_ID?: string;
84
87
  GITHUB_APP_INSTALLATION_ID?: string;
85
88
  GITHUB_APP_PRIVATE_KEY?: string;
89
+ DEPOT_API_TOKEN?: string; // Worker-only organization credential: NEVER forwarded to any container
90
+ DEPOT_CI_BRIDGE_TOKEN?: string; // bot → Worker, narrow repo-bound CI operations only
86
91
  PUBLIC_BASE_URL?: string; // live-view: base for /runs/<id>?t=… links on the status card
92
+ PUBLIC_GIT_BASE_URL?: string; // GitHub run-bearer door on a separate custom domain without dashboard Access
87
93
  ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
88
94
  ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
89
95
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
@@ -124,7 +130,9 @@ const FORWARDED_OPTIONAL = [
124
130
  "GITHUB_APP_ID",
125
131
  "GITHUB_APP_INSTALLATION_ID",
126
132
  "GITHUB_APP_PRIVATE_KEY",
133
+ "DEPOT_CI_BRIDGE_TOKEN",
127
134
  "PUBLIC_BASE_URL",
135
+ "PUBLIC_GIT_BASE_URL",
128
136
  "ACCESS_TEAM_DOMAIN",
129
137
  "ACCESS_AUD",
130
138
  "DASHBOARD_TOKEN",
@@ -457,7 +465,21 @@ const withLength = (res: Response): Response => withKnownLength(res, (size) => n
457
465
 
458
466
  export default {
459
467
  async fetch(request: Request, env: Env): Promise<Response> {
460
- const pathname = new URL(request.url).pathname;
468
+ const requestUrl = new URL(request.url);
469
+ const pathname = requestUrl.pathname;
470
+ if (githubDoorEdgeRoute(requestUrl, request.method, env.PUBLIC_GIT_BASE_URL) === "refuse")
471
+ return new Response("not found", { status: 404 });
472
+ // Permits can only be consumed through the Worker's fixed container
473
+ // binding. The public edge must never proxy this callback path.
474
+ if (pathname === DEPOT_CI_AUTHORIZATION_PATH) return new Response("not found", { status: 404 });
475
+ if (pathname === DEPOT_CI_PATH)
476
+ return handleDepotCi(request, {
477
+ depotToken: env.DEPOT_API_TOKEN,
478
+ bridgeToken: env.DEPOT_CI_BRIDGE_TOKEN,
479
+ authorize: (ticket, signal) =>
480
+ depotCiAuthorization(ticket, signal, (req) => getContainer(env.SWITCHBOARD, INSTANCE).fetch(req)),
481
+ fetch: (input, init) => fetch(input, init),
482
+ });
461
483
  // Only deliberately published copies are public. The existing run reader
462
484
  // and the bucket stay private; the write endpoint checks its own bearer.
463
485
  if (pathname.startsWith("/pr-images/")) return withLength(await handlePrImage(request, env.ARTIFACTS));
@@ -13,7 +13,10 @@
13
13
  // Cloudflare Access (SSO), and Access can only front a real zone domain —
14
14
  // leaving the workers.dev URL up would expose an un-SSO'd copy of /runs.
15
15
  "workers_dev": false,
16
- "routes": [{ "pattern": "{{hostname}}", "custom_domain": true }],
16
+ "routes": [
17
+ { "pattern": "{{hostname}}", "custom_domain": true },
18
+ { "pattern": "git.{{hostname}}", "custom_domain": true }
19
+ ],
17
20
  // Non-secret, deploy-constant config for the container. PUBLIC_BASE_URL: the
18
21
  // live-view link base — the dispatcher (in the container) reads
19
22
  // process.env.PUBLIC_BASE_URL to put the /runs/<id>?t=… link on the status
@@ -27,6 +30,7 @@
27
30
  // every dashboard request is denied.
28
31
  "vars": {
29
32
  "PUBLIC_BASE_URL": "{{urls.publicBaseUrl}}",
33
+ "PUBLIC_GIT_BASE_URL": "https://git.{{hostname}}",
30
34
  // {{#if access}}
31
35
  "ACCESS_TEAM_DOMAIN": "{{access.teamDomain}}",
32
36
  "ACCESS_AUD": "{{access.aud}}",
@@ -23,6 +23,7 @@ import {
23
23
  applyRetention,
24
24
  clampRetentionPolicy,
25
25
  isRunRecord,
26
+ isRecoveryEvidenceScope,
26
27
  isRunSession,
27
28
  isRunVisibilityFilter,
28
29
  normalizeStored,
@@ -4163,6 +4164,12 @@ export class RunHistoryDO extends DurableObject<Env> {
4163
4164
  * walked until the page fills; the kept set is the newest prefix of the
4164
4165
  * in-cutoff order, so the walk stops at the first row outside it.
4165
4166
  *
4167
+ * Recovery evidence additionally streams every retained summary through the
4168
+ * record parser before trusting identity filters: valid JSON can still be
4169
+ * unreadable evidence whose missing identity would hide a competing child.
4170
+ * This does not load the retained summaries into memory together or change
4171
+ * the relevant-row cap; ordinary list queries keep the fast path above.
4172
+ *
4166
4173
  * `visibleTo` — the caller's authorization predicate (authorization.md item
4167
4174
  * 6) — is compiled into the same WHERE clause (`visibilitySql`): its leaves
4168
4175
  * become `channel_id IN (…)`, `channel_visibility IN (…)`, `user_id = ?`,
@@ -4170,7 +4177,9 @@ export class RunHistoryDO extends DurableObject<Env> {
4170
4177
  * indexed filter on the page query, never a post-filter. `none` answers an
4171
4178
  * empty page without a query.
4172
4179
  */
4173
- async list(q: RunListOptions): Promise<{ items: RunListItem[]; nextBefore?: { finishedAt: number; id: string } }> {
4180
+ async list(
4181
+ q: RunListOptions,
4182
+ ): Promise<{ items: RunListItem[]; nextBefore?: { finishedAt: number; id: string }; evidenceComplete?: boolean }> {
4174
4183
  const now = systemClock();
4175
4184
  const limit = clampListLimit(q.limit);
4176
4185
  if (q.visibleTo?.kind === "none") return { items: [] };
@@ -4204,6 +4213,29 @@ export class RunHistoryDO extends DurableObject<Env> {
4204
4213
  where.push(`parent_run_id = ?`);
4205
4214
  params.push(q.parentRunId);
4206
4215
  }
4216
+ if (q.recoveryEvidence !== undefined) {
4217
+ // Identity comparisons cannot prove an unreadable record is unrelated.
4218
+ // Use the same parser as the result loop, not a weaker SQL shape check.
4219
+ for (const row of this.sql.exec<Pick<RunRow, "run_id" | "summary_json">>(
4220
+ `SELECT run_id, summary_json FROM runs WHERE finished_at >= ? ORDER BY finished_at DESC, run_id DESC`,
4221
+ cutoff,
4222
+ )) {
4223
+ if (kept !== null && !kept.has(row.run_id)) break; // only the retained newest-first prefix
4224
+ if (!parseSummary(row)) return { items: [], evidenceComplete: false };
4225
+ }
4226
+ const scope = q.recoveryEvidence;
4227
+ const unitKey = `${scope.instanceId}:${scope.unit}`;
4228
+ const prefix = `${unitKey}/`;
4229
+ // Literal prefix, not LIKE: instance ids can contain SQL wildcard chars.
4230
+ // Bare unit keys remain candidates, never proof of absence.
4231
+ where.push(`(CASE WHEN json_valid(summary_json) THEN (
4232
+ json_extract(summary_json, '$.parentInstanceId') = ? OR
4233
+ json_extract(summary_json, '$.idempotencyKey') = ? OR
4234
+ substr(json_extract(summary_json, '$.idempotencyKey'), 1, ?) = ? OR
4235
+ thread_key IN (${scope.threadKeys.map(() => "?").join(",")})
4236
+ ) ELSE 1 END)`);
4237
+ params.push(scope.instanceId, unitKey, prefix.length, prefix, ...scope.threadKeys);
4238
+ }
4207
4239
  if (q.pr !== undefined) {
4208
4240
  // `namesPullRequest` in SQL: the row's repository and the number it names.
4209
4241
  where.push(`repo = ?`, `pr_number = ?`);
@@ -4216,13 +4248,18 @@ export class RunHistoryDO extends DurableObject<Env> {
4216
4248
  // the walk stops early at the first evicted row) — never a full table load.
4217
4249
  const rows = this.sql.exec<RunRow>(`${select} LIMIT ?`, ...params, limit).toArray();
4218
4250
  const items: RunListItem[] = [];
4251
+ let malformed = false;
4219
4252
  for (const row of rows) {
4220
4253
  if (items.length >= limit) break;
4221
4254
  if (kept !== null && !kept.has(row.run_id)) break; // kept is a newest-first prefix: nothing older is kept either
4222
4255
  const summary = parseSummary(row);
4223
4256
  if (summary) items.push({ ...summary, bytes: row.bytes });
4257
+ else malformed = true;
4224
4258
  }
4225
- const out: { items: RunListItem[]; nextBefore?: { finishedAt: number; id: string } } = { items };
4259
+ const out: { items: RunListItem[]; nextBefore?: { finishedAt: number; id: string }; evidenceComplete?: boolean } = {
4260
+ items,
4261
+ };
4262
+ if (q.recoveryEvidence !== undefined) out.evidenceComplete = !malformed && rows.length < limit;
4226
4263
  if (items.length === limit) {
4227
4264
  const last = items[items.length - 1];
4228
4265
  out.nextBefore = { finishedAt: last.finishedAt, id: last.id };
@@ -4322,7 +4359,7 @@ function identityOfSummary(raw: string): { userName?: string; parentRunId?: stri
4322
4359
  }
4323
4360
  }
4324
4361
 
4325
- function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
4362
+ function parseSummary(row: Pick<RunRow, "summary_json">): Omit<RunRecord, "events"> | null {
4326
4363
  try {
4327
4364
  const parsed: unknown = JSON.parse(row.summary_json);
4328
4365
  return isRunRecord({ ...(parsed as object), events: [] })
@@ -4492,6 +4529,14 @@ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunLi
4492
4529
  return invalid("pr must be { repo: owner/name, number: a positive integer }");
4493
4530
  query.pr = { repo: pr.repo, number: pr.number };
4494
4531
  }
4532
+ if (b.recoveryEvidence !== undefined) {
4533
+ if (!isRecoveryEvidenceScope(b.recoveryEvidence))
4534
+ return invalid("recoveryEvidence must name an instance, unit and original threads");
4535
+ // No paged or narrowed response can attest complete recovery evidence.
4536
+ if (Object.keys(query).some((field) => field !== "limit") || b.visibleTo !== undefined)
4537
+ return invalid("recoveryEvidence cannot be combined with list filters or cursors");
4538
+ query.recoveryEvidence = b.recoveryEvidence;
4539
+ }
4495
4540
  if (b.visibleTo !== undefined) {
4496
4541
  // A malformed filter is a 400, never "all": the bot degrades to live rows
4497
4542
  // rather than the DO widening what an actor may see.
@@ -0,0 +1,43 @@
1
+ /** Root-side cleanup for credentials left by an earlier resident release.
2
+ * Open every directory component without following symlinks, then unlink
3
+ * only the named file relative to its pinned directory descriptor. A model
4
+ * owns the old tree and stage directory, so a pathname-based root `rm` can
5
+ * otherwise traverse a replacement symlink outside that tree. */
6
+ export const LEGACY_CREDENTIAL_SCRUB = `import os, sys
7
+
8
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
9
+
10
+ def open_dir(path):
11
+ fd = os.open('/', flags)
12
+ try:
13
+ for part in path.strip('/').split('/'):
14
+ if not part:
15
+ continue
16
+ child = os.open(part, flags, dir_fd=fd)
17
+ os.close(fd)
18
+ fd = child
19
+ return fd
20
+ except BaseException:
21
+ os.close(fd)
22
+ raise
23
+
24
+ def remove(parent, name):
25
+ try:
26
+ fd = open_dir(parent)
27
+ except FileNotFoundError:
28
+ return
29
+ try:
30
+ try:
31
+ os.unlink(name, dir_fd=fd)
32
+ except FileNotFoundError:
33
+ pass
34
+ finally:
35
+ os.close(fd)
36
+
37
+ remove(sys.argv[1], 'github-credentials')
38
+ remove(sys.argv[2], 'cred')
39
+ `;
40
+
41
+ export function legacyCredentialScrubCommand(worktreePath: string, stageDir: string): string[] {
42
+ return ["python3", "-c", LEGACY_CREDENTIAL_SCRUB, `${worktreePath}/.git`, stageDir];
43
+ }