@coreplane/switchboard 1.265.0 → 1.266.0
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 +16 -0
- 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/core/depotCi.ts +72 -0
- package/dist/assets/src/core/publicationPush.ts +173 -0
- package/dist/assets/src/core/refusal.ts +1 -0
- package/dist/assets/src/core/runEvents.ts +11 -0
- package/dist/assets/web/dist/.vite/manifest.json +30 -30
- package/dist/assets/web/dist/assets/{HomePage-CBQpcnaW.js → HomePage-B2Y-Fa9V.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentDetailPage-C-SgOUTl.js → ResidentDetailPage-DRNwx2--.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-CiDhvXok.js → ResidentsIndexPage-EHKMtX69.js} +1 -1
- package/dist/assets/web/dist/assets/{RunFoldRow-DZ8huwUU.js → RunFoldRow-CAdpX4Gn.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-Cp6wicYc.js → RunRoutePage-DppLhu79.js} +3 -3
- package/dist/assets/web/dist/assets/{RunsIndexPage-BPXtfwwx.js → RunsIndexPage-C7hx5O1X.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-DbvP48HI.js → ScheduledPage-Dk2kMnYT.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-B87lZaiN.js → SettingsPage-DAf6R383.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CFLn-DZ2.js → StatusDot-IkfsHGXg.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-Dx9YBVEx.js → Tooltip-CmQVfAl2.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-d4A2X_o7.js → UnitRoutePage-BjfuZVWr.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-Cn51vWpw.js → dist-Dg6HTzpQ.js} +1 -1
- package/dist/assets/web/dist/assets/main-Bk1L-bNk.js +28 -0
- package/dist/assets/web/dist/assets/main-DPB0UO1-.css +1 -0
- package/dist/cli.js +1396 -679
- 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,5 @@
|
|
|
1
|
+
import { depotCiAuthorization, handleDepotCi } from "./depotCi.ts";
|
|
2
|
+
import { DEPOT_CI_AUTHORIZATION_PATH, DEPOT_CI_PATH } from "../../src/core/depotCi.ts";
|
|
1
3
|
// Cloudflare Containers shim: runs the unchanged Switchboard image as a single
|
|
2
4
|
// always-on container instance — the shape of any long-lived server on
|
|
3
5
|
// Cloudflare Containers (singleton DO, cron keep-alive;
|
|
@@ -83,6 +85,8 @@ export interface Env {
|
|
|
83
85
|
GITHUB_APP_ID?: string;
|
|
84
86
|
GITHUB_APP_INSTALLATION_ID?: string;
|
|
85
87
|
GITHUB_APP_PRIVATE_KEY?: string;
|
|
88
|
+
DEPOT_API_TOKEN?: string; // Worker-only organization credential: NEVER forwarded to any container
|
|
89
|
+
DEPOT_CI_BRIDGE_TOKEN?: string; // bot → Worker, narrow repo-bound CI operations only
|
|
86
90
|
PUBLIC_BASE_URL?: string; // live-view: base for /runs/<id>?t=… links on the status card
|
|
87
91
|
ACCESS_TEAM_DOMAIN?: string; // live-view SSO gate: Cloudflare Access team domain (JWKS + iss)
|
|
88
92
|
ACCESS_AUD?: string; // live-view SSO gate: Cloudflare Access application AUD tag
|
|
@@ -124,6 +128,7 @@ const FORWARDED_OPTIONAL = [
|
|
|
124
128
|
"GITHUB_APP_ID",
|
|
125
129
|
"GITHUB_APP_INSTALLATION_ID",
|
|
126
130
|
"GITHUB_APP_PRIVATE_KEY",
|
|
131
|
+
"DEPOT_CI_BRIDGE_TOKEN",
|
|
127
132
|
"PUBLIC_BASE_URL",
|
|
128
133
|
"ACCESS_TEAM_DOMAIN",
|
|
129
134
|
"ACCESS_AUD",
|
|
@@ -458,6 +463,17 @@ const withLength = (res: Response): Response => withKnownLength(res, (size) => n
|
|
|
458
463
|
export default {
|
|
459
464
|
async fetch(request: Request, env: Env): Promise<Response> {
|
|
460
465
|
const pathname = new URL(request.url).pathname;
|
|
466
|
+
// Permits can only be consumed through the Worker's fixed container
|
|
467
|
+
// binding. The public edge must never proxy this callback path.
|
|
468
|
+
if (pathname === DEPOT_CI_AUTHORIZATION_PATH) return new Response("not found", { status: 404 });
|
|
469
|
+
if (pathname === DEPOT_CI_PATH)
|
|
470
|
+
return handleDepotCi(request, {
|
|
471
|
+
depotToken: env.DEPOT_API_TOKEN,
|
|
472
|
+
bridgeToken: env.DEPOT_CI_BRIDGE_TOKEN,
|
|
473
|
+
authorize: (ticket, signal) =>
|
|
474
|
+
depotCiAuthorization(ticket, signal, (req) => getContainer(env.SWITCHBOARD, INSTANCE).fetch(req)),
|
|
475
|
+
fetch: (input, init) => fetch(input, init),
|
|
476
|
+
});
|
|
461
477
|
// Only deliberately published copies are public. The existing run reader
|
|
462
478
|
// and the bucket stay private; the write endpoint checks its own bearer.
|
|
463
479
|
if (pathname.startsWith("/pr-images/")) return withLength(await handlePrImage(request, env.ARTIFACTS));
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
"$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 <NAME> files, `~/.secrets/switchboard` by default, or a 1Password item `op://Vault/Item` with one field per name); `deploy secrets <worker>` (`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.",
|
|
2
|
+
"$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 <NAME> files, `~/.secrets/switchboard` by default, or a 1Password item `op://Vault/Item` with one field per name); `deploy secrets <worker>` (`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.",
|
|
3
3
|
"secrets": [
|
|
4
|
+
{
|
|
5
|
+
"name": "DEPOT_API_TOKEN",
|
|
6
|
+
"workers": ["bot"],
|
|
7
|
+
"optional": true,
|
|
8
|
+
"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."
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"name": "DEPOT_CI_BRIDGE_TOKEN",
|
|
12
|
+
"workers": ["bot"],
|
|
13
|
+
"optional": true,
|
|
14
|
+
"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."
|
|
15
|
+
},
|
|
4
16
|
{
|
|
5
17
|
"name": "SLACK_BOT_TOKEN",
|
|
6
18
|
"workers": ["bot"],
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.266.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.266.0",
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
11
|
"workspaces": [
|
|
12
12
|
"web",
|
|
@@ -21060,7 +21060,7 @@
|
|
|
21060
21060
|
},
|
|
21061
21061
|
"packages/switchboard": {
|
|
21062
21062
|
"name": "@coreplane/switchboard",
|
|
21063
|
-
"version": "1.
|
|
21063
|
+
"version": "1.266.0",
|
|
21064
21064
|
"license": "Apache-2.0",
|
|
21065
21065
|
"dependencies": {
|
|
21066
21066
|
"@earendil-works/pi-ai": "0.85.1",
|
package/dist/assets/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "switchboard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.266.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
|
|
6
6
|
"license": "Apache-2.0",
|
package/dist/assets/source.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The node-free contract shared by the bot's repo-bound client and the edge.
|
|
2
|
+
// No generic RPC, arbitrary URL or credential can be supplied by a child.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const DEPOT_CI_PATH = "/internal/depot-ci";
|
|
6
|
+
export const DEPOT_CI_AUTHORIZATION_PATH = "/internal/depot-ci/authorization";
|
|
7
|
+
export const depotCiTicketSchema = z.object({ ticket: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
8
|
+
export const DEPOT_CI_MAX_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
export const depotId = z.string().regex(/^[A-Za-z0-9_-]{1,160}$/);
|
|
10
|
+
const common = { workflowId: depotId };
|
|
11
|
+
export const depotOperationSchema = z.discriminatedUnion("operation", [
|
|
12
|
+
z.object({ ...common, operation: z.literal("inspect") }).strict(),
|
|
13
|
+
z
|
|
14
|
+
.object({
|
|
15
|
+
...common,
|
|
16
|
+
operation: z.literal("logs"),
|
|
17
|
+
jobId: depotId,
|
|
18
|
+
attemptId: depotId.optional(),
|
|
19
|
+
lines: z.number().int().min(1).max(2000).optional(),
|
|
20
|
+
})
|
|
21
|
+
.strict(),
|
|
22
|
+
z
|
|
23
|
+
.object({ ...common, operation: z.literal("retry_failed"), expectedHead: z.string().regex(/^[a-f0-9]{40}$/i) })
|
|
24
|
+
.strict(),
|
|
25
|
+
]);
|
|
26
|
+
export type DepotOperation = z.infer<typeof depotOperationSchema>;
|
|
27
|
+
/** Read only from the fixed bot callback, never from the bridge request. */
|
|
28
|
+
export const depotCiGrantSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
runId: depotId,
|
|
31
|
+
repo: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}\/[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/),
|
|
32
|
+
operation: depotOperationSchema,
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
35
|
+
export type DepotCiGrant = z.infer<typeof depotCiGrantSchema>;
|
|
36
|
+
export interface DepotCi {
|
|
37
|
+
call(operation: DepotOperation, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fixed messages shared across the bridge; upstream bodies are never errors. */
|
|
41
|
+
export const DEPOT_ERRORS = {
|
|
42
|
+
invalid: "Depot CI: invalid operation.",
|
|
43
|
+
unauthorized: "Depot CI: run authorization expired or refused.",
|
|
44
|
+
unavailable: "Depot CI unavailable: DEPOT_API_TOKEN is not configured at the Worker.",
|
|
45
|
+
notFound: "Depot CI: workflow, job or attempt not found in the bound repository.",
|
|
46
|
+
stale: "Depot CI: head mismatch or workflow is not failed/cancelled; inspect before retry.",
|
|
47
|
+
unknown: "Depot retry outcome unknown; inspect before another request.",
|
|
48
|
+
failed: "Depot CI upstream response invalid or unavailable.",
|
|
49
|
+
} as const;
|
|
50
|
+
|
|
51
|
+
/** Read through a byte ceiling, including chunked responses. A partial JSON
|
|
52
|
+
* response is never parsed or returned. Callers supply the operation deadline. */
|
|
53
|
+
export async function depotJson(response: Response, limit = DEPOT_CI_MAX_BYTES): Promise<unknown> {
|
|
54
|
+
if (!response.body) throw new Error("empty response");
|
|
55
|
+
const reader = response.body.getReader();
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
let text = "";
|
|
59
|
+
try {
|
|
60
|
+
for (;;) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
bytes += value.byteLength;
|
|
64
|
+
if (bytes > limit) throw new Error("response too large");
|
|
65
|
+
text += decoder.decode(value, { stream: true });
|
|
66
|
+
}
|
|
67
|
+
return JSON.parse(text + decoder.decode()) as unknown;
|
|
68
|
+
} finally {
|
|
69
|
+
await reader.cancel().catch(() => {});
|
|
70
|
+
reader.releaseLock();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ExistingPrPublicationBinding } from "./coordinator/contract.js";
|
|
2
|
+
import { COMMAND_CAP, TOOL_OUTPUT_CAP, type RunEvent } from "./runEvents.js";
|
|
3
|
+
|
|
4
|
+
export interface PublicationPushReceipt {
|
|
5
|
+
callId: string;
|
|
6
|
+
previousHeadSha: string;
|
|
7
|
+
repo: string;
|
|
8
|
+
pr: number;
|
|
9
|
+
owner: ExistingPrPublicationBinding["owner"];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type PushEvent = Extract<RunEvent, { type: "pushed_head" }>;
|
|
13
|
+
const fullSha = (s: string): boolean => /^[a-f0-9]{40}$/.test(s);
|
|
14
|
+
const refName = (s: string): boolean =>
|
|
15
|
+
/^[a-zA-Z0-9_/-]+(?:\.[a-zA-Z0-9_/-]+)*$/.test(s) && !s.includes("..") && !s.startsWith("-");
|
|
16
|
+
const shortRef = (s: string): string => s.replace(/^refs\/heads\//, "");
|
|
17
|
+
|
|
18
|
+
/** Deliberately not a shell interpreter. Only a standalone literal push can
|
|
19
|
+
* attest its result: pipelines, scripts, substitutions and compound commands
|
|
20
|
+
* cannot prove which operation produced stdout or the successful exit. */
|
|
21
|
+
export function leasedPushCommand(command: string): { ref: string; expectedHeadSha: string } | undefined {
|
|
22
|
+
if (command.length >= COMMAND_CAP) return;
|
|
23
|
+
const literal = command.replace(/[ \t]+2>&1[ \t]*$/, "");
|
|
24
|
+
if (!/^git push\s/.test(literal) || /[\n\r'"`$;|<>\\&]/.test(literal)) return;
|
|
25
|
+
const words = literal.trim().split(/\s+/).slice(2);
|
|
26
|
+
const leases = words.filter((w) => w.startsWith("--force-with-lease="));
|
|
27
|
+
if (leases.length !== 1) return;
|
|
28
|
+
const lease = /^--force-with-lease=refs\/heads\/([^:]+):([a-f0-9]{40})$/.exec(leases[0]!);
|
|
29
|
+
if (!lease || !refName(lease[1]!)) return;
|
|
30
|
+
const args = words.filter((w) => w !== leases[0] && w !== "-u" && w !== "--set-upstream");
|
|
31
|
+
if (args.length !== 2 || args[0] !== "origin") return;
|
|
32
|
+
const refs = args[1]!.split(":");
|
|
33
|
+
if (refs.length > 2 || shortRef(refs[0]!) !== lease[1] || shortRef(refs[1] ?? refs[0]!) !== lease[1]) return;
|
|
34
|
+
return { ref: lease[1]!, expectedHeadSha: lease[2]! };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A legacy record needs the complete pair, never its display summary, an
|
|
38
|
+
* assistant's prose or the remote head alone. More than one attempted push is
|
|
39
|
+
* intentionally ambiguous; historical reconciliation does not choose one. */
|
|
40
|
+
export function pairedPublicationPush(
|
|
41
|
+
events: readonly RunEvent[],
|
|
42
|
+
binding: ExistingPrPublicationBinding,
|
|
43
|
+
head: string,
|
|
44
|
+
callId?: string,
|
|
45
|
+
): PushEvent | undefined {
|
|
46
|
+
if (!fullSha(head) || !fullSha(binding.expectedHeadSha) || head === binding.expectedHeadSha) return;
|
|
47
|
+
const calls = events.filter(
|
|
48
|
+
(e): e is Extract<RunEvent, { type: "tool_call" }> =>
|
|
49
|
+
e.type === "tool_call" &&
|
|
50
|
+
e.tool === "bash" &&
|
|
51
|
+
(callId === undefined ? /\bgit\s+push\b/.test(e.command ?? e.summary) : e.callId === callId),
|
|
52
|
+
);
|
|
53
|
+
if (calls.length !== 1) return;
|
|
54
|
+
const call = calls[0]!;
|
|
55
|
+
if (
|
|
56
|
+
!call.callId ||
|
|
57
|
+
!call.command ||
|
|
58
|
+
events.filter((e) => e.type === "tool_call" && e.callId === call.callId).length !== 1
|
|
59
|
+
)
|
|
60
|
+
return;
|
|
61
|
+
const command = leasedPushCommand(call.command);
|
|
62
|
+
if (command?.ref !== binding.publicationRef || command.expectedHeadSha !== binding.expectedHeadSha) return;
|
|
63
|
+
const results = events.filter(
|
|
64
|
+
(e): e is Extract<RunEvent, { type: "tool_result" }> => e.type === "tool_result" && e.callId === call.callId,
|
|
65
|
+
);
|
|
66
|
+
if (results.length !== 1) return;
|
|
67
|
+
const result = results[0]!;
|
|
68
|
+
if (
|
|
69
|
+
events.indexOf(result) <= events.indexOf(call) ||
|
|
70
|
+
result.tool !== "bash" ||
|
|
71
|
+
!result.ok ||
|
|
72
|
+
result.exitCode !== 0 ||
|
|
73
|
+
result.cut ||
|
|
74
|
+
!result.output ||
|
|
75
|
+
result.output.length >= TOOL_OUTPUT_CAP
|
|
76
|
+
)
|
|
77
|
+
return;
|
|
78
|
+
let lines = result.output.trim().split(/\r?\n/);
|
|
79
|
+
const tracking = `branch '${binding.publicationRef}' set up to track 'origin/${binding.publicationRef}'.`;
|
|
80
|
+
if (
|
|
81
|
+
/(?:^|\s)(?:-u|--set-upstream)(?:\s|$)/.test(call.command) &&
|
|
82
|
+
lines.filter((line) => line === tracking).length === 1
|
|
83
|
+
)
|
|
84
|
+
lines = lines.filter((line) => line !== tracking);
|
|
85
|
+
if (lines.length !== 2) return;
|
|
86
|
+
const remote = /^To (?:https:\/\/github\.com\/|git@github\.com:|github\.com:)([^\s]+?)(?:\.git)?$/.exec(lines[0]!);
|
|
87
|
+
const update = /^\s*\+?\s*([a-f0-9]{7,40})(\.{2,3})([a-f0-9]{7,40})\s+(\S+) -> (\S+)( \(forced update\))?$/.exec(
|
|
88
|
+
lines[1]!,
|
|
89
|
+
);
|
|
90
|
+
if (
|
|
91
|
+
!remote ||
|
|
92
|
+
remote[1]!.toLowerCase() !== binding.repo.toLowerCase() ||
|
|
93
|
+
!update ||
|
|
94
|
+
!binding.expectedHeadSha.startsWith(update[1]!) ||
|
|
95
|
+
!head.startsWith(update[3]!) ||
|
|
96
|
+
update[1] === update[3] ||
|
|
97
|
+
shortRef(update[4]!) !== binding.publicationRef ||
|
|
98
|
+
shortRef(update[5]!) !== binding.publicationRef
|
|
99
|
+
)
|
|
100
|
+
return;
|
|
101
|
+
return {
|
|
102
|
+
type: "pushed_head",
|
|
103
|
+
ref: binding.publicationRef,
|
|
104
|
+
sha: head,
|
|
105
|
+
by: "push",
|
|
106
|
+
receipt: {
|
|
107
|
+
callId: call.callId,
|
|
108
|
+
previousHeadSha: binding.expectedHeadSha,
|
|
109
|
+
repo: binding.repo,
|
|
110
|
+
pr: binding.pr,
|
|
111
|
+
owner: { ...binding.owner },
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** State is stored JSON, not an unchecked cast back into publication authority. */
|
|
117
|
+
export function publicationReceiptsFromState(value: unknown): PushEvent[] {
|
|
118
|
+
if (!Array.isArray(value)) return [];
|
|
119
|
+
const valid = value.every((v) => {
|
|
120
|
+
if (typeof v !== "object" || v === null) return false;
|
|
121
|
+
const r = v.receipt;
|
|
122
|
+
return (
|
|
123
|
+
v.type === "pushed_head" &&
|
|
124
|
+
v.by === "push" &&
|
|
125
|
+
typeof v.ref === "string" &&
|
|
126
|
+
refName(v.ref) &&
|
|
127
|
+
typeof v.sha === "string" &&
|
|
128
|
+
fullSha(v.sha) &&
|
|
129
|
+
typeof r === "object" &&
|
|
130
|
+
r !== null &&
|
|
131
|
+
typeof r.callId === "string" &&
|
|
132
|
+
r.callId.length > 0 &&
|
|
133
|
+
typeof r.previousHeadSha === "string" &&
|
|
134
|
+
fullSha(r.previousHeadSha) &&
|
|
135
|
+
typeof r.repo === "string" &&
|
|
136
|
+
Number.isSafeInteger(r.pr) &&
|
|
137
|
+
r.pr > 0 &&
|
|
138
|
+
typeof r.owner === "object" &&
|
|
139
|
+
r.owner !== null &&
|
|
140
|
+
typeof r.owner.instanceId === "string" &&
|
|
141
|
+
typeof r.owner.unit === "string"
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
return valid ? ([...value] as PushEvent[]) : [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Only the run's typed, contiguous leased transitions can move its restart
|
|
148
|
+
* fence. The caller still verifies the fresh PR and attached workspace. */
|
|
149
|
+
export function restoredPublicationHead(
|
|
150
|
+
events: readonly RunEvent[],
|
|
151
|
+
binding: ExistingPrPublicationBinding,
|
|
152
|
+
): string | undefined {
|
|
153
|
+
let head = binding.expectedHeadSha;
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
for (const e of events) {
|
|
156
|
+
if (e.type !== "pushed_head" || e.receipt === undefined) continue;
|
|
157
|
+
const r = e.receipt;
|
|
158
|
+
if (
|
|
159
|
+
e.ref !== binding.publicationRef ||
|
|
160
|
+
r.repo !== binding.repo ||
|
|
161
|
+
r.pr !== binding.pr ||
|
|
162
|
+
r.owner.instanceId !== binding.owner.instanceId ||
|
|
163
|
+
r.owner.unit !== binding.owner.unit ||
|
|
164
|
+
r.previousHeadSha !== head ||
|
|
165
|
+
!fullSha(e.sha) ||
|
|
166
|
+
seen.has(r.callId)
|
|
167
|
+
)
|
|
168
|
+
return;
|
|
169
|
+
seen.add(r.callId);
|
|
170
|
+
head = e.sha;
|
|
171
|
+
}
|
|
172
|
+
return head === binding.expectedHeadSha ? undefined : head;
|
|
173
|
+
}
|
|
@@ -944,12 +944,23 @@ export type RunEvent =
|
|
|
944
944
|
ref: string;
|
|
945
945
|
sha: string;
|
|
946
946
|
by: "push" | "salvage";
|
|
947
|
+
/** Exact leased tool result, retained before any post-step can refuse. */
|
|
948
|
+
receipt?: import("./publicationPush.js").PublicationPushReceipt;
|
|
947
949
|
/** No uncommitted or unpushed work at the push (record 0064): the fact
|
|
948
950
|
* the plane's soft stop reads. Absent where the measure was missing. */
|
|
949
951
|
clean?: boolean;
|
|
950
952
|
seq?: number;
|
|
951
953
|
at?: number;
|
|
952
954
|
}
|
|
955
|
+
/** The server gate admitted this exact leased push, not a child's claim. */
|
|
956
|
+
| {
|
|
957
|
+
type: "publication_push_authorized";
|
|
958
|
+
callId: string;
|
|
959
|
+
ref: string;
|
|
960
|
+
expectedHeadSha: string;
|
|
961
|
+
seq?: number;
|
|
962
|
+
at?: number;
|
|
963
|
+
}
|
|
953
964
|
/** The coordinator tag as a fact of the run (docs/reference/specs/run-history.md
|
|
954
965
|
* item 48a): the instance the run is a child of, the unit its idempotency
|
|
955
966
|
* key named, and the base branch its pull request targets — published by
|