@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.
- package/dist/assets/.env.example +6 -0
- package/dist/assets/deploy/cloudflare/depotCi.ts +246 -0
- package/dist/assets/deploy/cloudflare/worker.ts +23 -1
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +5 -1
- package/dist/assets/deploy/cloudflare-memory/worker.ts +48 -3
- package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +43 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +102 -141
- package/dist/assets/deploy/secrets.manifest.json +13 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/channels/githubDoorPaths.ts +36 -0
- package/dist/assets/src/core/coordinator/contract.ts +2 -0
- package/dist/assets/src/core/depotCi.ts +72 -0
- package/dist/assets/src/core/refusal.ts +2 -0
- package/dist/assets/src/core/runEvents.ts +1 -0
- package/dist/assets/src/core/runRecord.ts +41 -0
- package/dist/assets/src/execution/residentReadonly.ts +9 -8
- package/dist/assets/src/execution/residentSteps.ts +2 -1
- package/dist/assets/src/execution/seedPlan.ts +2 -2
- package/dist/assets/web/dist/.vite/manifest.json +50 -50
- package/dist/assets/web/dist/assets/{HomePage-CBQpcnaW.js → HomePage-BfpIrKDX.js} +1 -1
- package/dist/assets/web/dist/assets/{PendingTurnRow-DIC86uWA.js → PendingTurnRow-C6hroa00.js} +1 -1
- package/dist/assets/web/dist/assets/{PlanePage-CfrbMh8t.js → PlanePage-C2Cb1mkT.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-C-SgOUTl.js → ResidentDetailPage-Bd9_Q87l.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CiDhvXok.js → ResidentsIndexPage-C8eS7hxp.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-DZ8huwUU.js → RunFoldRow-DrfFauYJ.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-Cp6wicYc.js → RunRoutePage-B_eXfNAF.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-BPXtfwwx.js → RunsIndexPage-BpQu36Dm.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-DbvP48HI.js → ScheduledPage-DGHcjcSt.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-B87lZaiN.js → SettingsPage-IfQ71QjN.js} +1 -1
- package/dist/assets/web/dist/assets/{SilentTurn-CPED_XCV.js → SilentTurn-D9ON15a8.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CFLn-DZ2.js → StatusDot-DyFjGg4V.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-Dx9YBVEx.js → Tooltip-BvPkV4kZ.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-d4A2X_o7.js → UnitRoutePage-BNe3nBIG.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-Cn51vWpw.js → dist-v3Py3_QR.js} +1 -1
- package/dist/assets/web/dist/assets/main-DPB0UO1-.css +1 -0
- package/dist/assets/web/dist/assets/main-ks3kBojV.js +28 -0
- package/dist/assets/web/dist/assets/{sseReplay-DGRDRLua.js → sseReplay-DuALaBPq.js} +1 -1
- package/dist/cli.js +2950 -1260
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/main-DGC6WFqS.css +0 -1
- package/dist/assets/web/dist/assets/main-DYwRHh0F.js +0 -28
package/dist/assets/.env.example
CHANGED
|
@@ -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
|
|
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": [
|
|
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(
|
|
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 } } = {
|
|
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
|
+
}
|