@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
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
// with an honest `warning` when it is not.
|
|
35
35
|
//
|
|
36
36
|
// Operator data plane: POST /attach (per-thread worktree off the bare mirror
|
|
37
|
-
// + sticky ref binding + dep materialization +
|
|
37
|
+
// + sticky ref binding + dep materialization + Git door origin),
|
|
38
38
|
// POST /exec (privilege-dropped per-thread execution), POST /read and /write
|
|
39
39
|
// (thread-user file ops confined to the worktree), an in-DO mirror mutex
|
|
40
40
|
// serializing every mirror mutation, and an inactivity sweep that evicts idle
|
|
@@ -61,11 +61,11 @@
|
|
|
61
61
|
// here reads them and no request header is ever forwarded into a
|
|
62
62
|
// resident. The ONLY env the resident itself injects into a command is
|
|
63
63
|
// GIT_TERMINAL_PROMPT=0 (validated through validateEnvNames); GitHub
|
|
64
|
-
// tokens travel via a
|
|
65
|
-
//
|
|
64
|
+
// root-owned mirror tokens travel via a one-shot credential file, never
|
|
65
|
+
// into thread env or argv. Thread commands receive only a run bearer.
|
|
66
66
|
// 4. The GitHub App PRIVATE KEY exists only in Worker/DO scope. The
|
|
67
|
-
//
|
|
68
|
-
// resident's own repo
|
|
67
|
+
// root-owned mirror commands use 1-hour installation tokens scoped to the
|
|
68
|
+
// resident's own repo. Install/build executions
|
|
69
69
|
// (untrusted repo code) run unprivileged (worker1) and token-free
|
|
70
70
|
// (docs/decisions/0009-residents-second-credential-domain.md).
|
|
71
71
|
import {
|
|
@@ -82,6 +82,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
82
82
|
import type { DirectoryBackup, SandboxCommand } from "@cloudflare/sandbox";
|
|
83
83
|
import { createExtensionProcessSandbox } from "@cloudflare/sandbox/extensions";
|
|
84
84
|
import { DurableObject } from "cloudflare:workers";
|
|
85
|
+
import { legacyCredentialScrubCommand } from "./legacyCredentials.js";
|
|
85
86
|
import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
|
|
86
87
|
import { selectBindingsToPurge } from "../../src/execution/bindingPurge.js";
|
|
87
88
|
import { busyAfterKillReason, planForceDetach } from "../../src/execution/residentDetach.js";
|
|
@@ -162,10 +163,7 @@ import {
|
|
|
162
163
|
} from "../../src/execution/residentExecWrap.js";
|
|
163
164
|
import { shellQuote } from "../../src/execution/shellQuote.js";
|
|
164
165
|
import { envFromRequest } from "../../src/execution/sandboxEnv.js";
|
|
165
|
-
import {
|
|
166
|
-
CREDENTIAL_EXPIRY_MARGIN_MS,
|
|
167
|
-
shouldRefreshThreadCredentials,
|
|
168
|
-
} from "../../src/execution/residentCredentials.js";
|
|
166
|
+
import { CREDENTIAL_EXPIRY_MARGIN_MS } from "../../src/execution/residentCredentials.js";
|
|
169
167
|
import {
|
|
170
168
|
recordFiring,
|
|
171
169
|
scheduleForCron,
|
|
@@ -973,10 +971,8 @@ export async function mintRepoScopedToken(env: Env, slug: string, opts?: { fresh
|
|
|
973
971
|
}
|
|
974
972
|
// A repudiated token must never be re-served: drop the slug's cache entry and
|
|
975
973
|
// mint anew. Otherwise serve a cached token while it has more than the refresh
|
|
976
|
-
// margin left
|
|
977
|
-
//
|
|
978
|
-
// would immediately have to re-mint. Was 5 min — too little for a 20-minute
|
|
979
|
-
// exec to run under.
|
|
974
|
+
// margin left, so a root-owned mirror operation never starts on a token
|
|
975
|
+
// likely to expire during its network call.
|
|
980
976
|
if (opts?.fresh) githubTokenCache.delete(slug);
|
|
981
977
|
const cached = opts?.fresh ? undefined : githubTokenCache.get(slug);
|
|
982
978
|
if (cached && systemClock() < cached.expiresAtMs - CREDENTIAL_EXPIRY_MARGIN_MS) return cached;
|
|
@@ -1218,6 +1214,9 @@ interface ThreadBinding {
|
|
|
1218
1214
|
* Absent (pre-field binding) → the next writable exec falls back to the
|
|
1219
1215
|
* `credentialsWrittenAt` file-age rule; cleared alongside it on attach. */
|
|
1220
1216
|
tokenExpiresAtMs?: number;
|
|
1217
|
+
/** Non-secret host of the run-bearer Git door. The bearer lives only in
|
|
1218
|
+
* the attach request and the run's per-exec environment. */
|
|
1219
|
+
githubDoorHost?: string;
|
|
1221
1220
|
/** The deps-store entry this tree's node_modules is a view of (item 59);
|
|
1222
1221
|
* protects the entry from eviction while the binding lives. Absent on a
|
|
1223
1222
|
* binding made before the store, or on a tree with no deps. */
|
|
@@ -1248,9 +1247,8 @@ interface AttachOk {
|
|
|
1248
1247
|
* the container its pi runs in. Absent when the kernel does not say. */
|
|
1249
1248
|
container?: string;
|
|
1250
1249
|
deps: ThreadDepsMechanism;
|
|
1251
|
-
/** `ok` —
|
|
1252
|
-
*
|
|
1253
|
-
* credentials and an unfetchable origin (item 50). */
|
|
1250
|
+
/** `ok` — writable origin uses the Git door; `unavailable` — no door;
|
|
1251
|
+
* `none` — read-only attach with an unfetchable origin (item 50). */
|
|
1254
1252
|
credentials: "ok" | "unavailable" | "none";
|
|
1255
1253
|
credentialsError?: string;
|
|
1256
1254
|
/** Echo of the mode the tree was built for. */
|
|
@@ -5506,13 +5504,14 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5506
5504
|
record?: ResidentRecord,
|
|
5507
5505
|
traceparent?: string,
|
|
5508
5506
|
reason: RefHintReason = NO_REF_HINT_REASON,
|
|
5507
|
+
githubDoor?: { baseUrl: string; bearer: string },
|
|
5509
5508
|
): Promise<AttachOk | ThreadErr> {
|
|
5510
5509
|
// One step trace per attach (docs/reference/specs/tracing.md item 19): every command
|
|
5511
5510
|
// the attach runs lands on it, and the answer carries it.
|
|
5512
5511
|
const t0 = systemClock();
|
|
5513
5512
|
const trace = createStepTrace(t0);
|
|
5514
5513
|
const res = await this.stepTrace.run(trace, () =>
|
|
5515
|
-
this.attachThreadTraced(threadKey, refHint, readonly, wantSha, reuse, record, t0, reason),
|
|
5514
|
+
this.attachThreadTraced(threadKey, refHint, readonly, wantSha, reuse, record, t0, reason, githubDoor),
|
|
5516
5515
|
);
|
|
5517
5516
|
// The same steps as the resident's own `resident.attach` root (item 22).
|
|
5518
5517
|
emitStepRoot("resident.attach", t0, trace.steps(), traceparent, "error" in res ? refusalOutcome(res) : "ok");
|
|
@@ -5529,6 +5528,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5529
5528
|
record: ResidentRecord | undefined,
|
|
5530
5529
|
t0: number,
|
|
5531
5530
|
reason: RefHintReason,
|
|
5531
|
+
githubDoor?: { baseUrl: string; bearer: string },
|
|
5532
5532
|
): Promise<AttachOk | ThreadErr> {
|
|
5533
5533
|
try {
|
|
5534
5534
|
await this.ensureHydrated();
|
|
@@ -5586,6 +5586,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5586
5586
|
t0,
|
|
5587
5587
|
record,
|
|
5588
5588
|
reason,
|
|
5589
|
+
githubDoor,
|
|
5589
5590
|
);
|
|
5590
5591
|
// The run this attach opens is now in flight until its release —
|
|
5591
5592
|
// whatever its op counters read between the bot's calls (item 44).
|
|
@@ -5626,7 +5627,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5626
5627
|
t0: number,
|
|
5627
5628
|
recordFromRoute: ResidentRecord | undefined,
|
|
5628
5629
|
reason: RefHintReason,
|
|
5630
|
+
githubDoor?: { baseUrl: string; bearer: string },
|
|
5629
5631
|
): Promise<AttachOk | ThreadErr> {
|
|
5632
|
+
if (!readonly && !githubDoor)
|
|
5633
|
+
return { error: "writable attach requires a Git door run bearer", status: 403, cause: "request" };
|
|
5630
5634
|
try {
|
|
5631
5635
|
await this.refreshIfStale(resourceId);
|
|
5632
5636
|
} catch (err) {
|
|
@@ -5667,6 +5671,8 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5667
5671
|
// branch. The own-PR move remains for callers that name no branch of their
|
|
5668
5672
|
// own but carry the typed follow-up fact.
|
|
5669
5673
|
const storedPrior = stored.get(threadBindingKey(threadKey)) as ThreadBinding | undefined;
|
|
5674
|
+
if (!readonly && storedPrior?.githubDoorHost && !githubDoor)
|
|
5675
|
+
return { error: "Git door credential is required to reattach this writable tree", status: 403, cause: "request" };
|
|
5670
5676
|
const namedRef = refHint !== null && !reason.refByDefault && reason.ownPr === null;
|
|
5671
5677
|
const rebind = namedRef
|
|
5672
5678
|
? { binding: storedPrior }
|
|
@@ -5737,6 +5743,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5737
5743
|
record,
|
|
5738
5744
|
binding,
|
|
5739
5745
|
mode,
|
|
5746
|
+
githubDoor,
|
|
5740
5747
|
rollback,
|
|
5741
5748
|
namedRef,
|
|
5742
5749
|
refChanged: storedPrior !== undefined && storedPrior.ref !== binding.ref,
|
|
@@ -5968,6 +5975,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5968
5975
|
record: ResidentRecord;
|
|
5969
5976
|
binding: ThreadBinding;
|
|
5970
5977
|
mode: ReturnType<typeof planReadonlyAttach>;
|
|
5978
|
+
githubDoor?: { baseUrl: string; bearer: string };
|
|
5971
5979
|
rollback: () => Promise<void>;
|
|
5972
5980
|
/** This ask named the ref; a missing named branch may not fall back. */
|
|
5973
5981
|
namedRef: boolean;
|
|
@@ -5977,7 +5985,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5977
5985
|
rebound?: Rebound;
|
|
5978
5986
|
rebindRefused?: RebindRefused;
|
|
5979
5987
|
}): Promise<AttachOk | ThreadErr> {
|
|
5980
|
-
const { threadKey, refHint, wantSha, reuse, slug, t0, facts, record, mode, rollback } = input;
|
|
5988
|
+
const { threadKey, refHint, wantSha, reuse, slug, t0, facts, record, mode, rollback, githubDoor } = input;
|
|
5981
5989
|
const { rebound, rebindRefused } = input;
|
|
5982
5990
|
// Reassigned once, under the lock, when the bound ref turns out gone from
|
|
5983
5991
|
// the mirror and the binding goes back to the default (item 16's second
|
|
@@ -5987,24 +5995,8 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
5987
5995
|
let refChanged = input.refChanged;
|
|
5988
5996
|
let returned: Returned | undefined;
|
|
5989
5997
|
|
|
5990
|
-
//
|
|
5991
|
-
//
|
|
5992
|
-
let token: string | null = null;
|
|
5993
|
-
let tokenExpiresAtMs: number | null = null;
|
|
5994
|
-
let credentialsError: string | undefined;
|
|
5995
|
-
if (!mode.credentialFile) {
|
|
5996
|
-
// Read-only: no token for the TREE — nothing to leak, nothing to push with.
|
|
5997
|
-
} else if (githubAppConfigured(this.env)) {
|
|
5998
|
-
try {
|
|
5999
|
-
const minted = await mintRepoScopedToken(this.env, slug);
|
|
6000
|
-
token = minted.token;
|
|
6001
|
-
tokenExpiresAtMs = minted.expiresAtMs;
|
|
6002
|
-
} catch (err) {
|
|
6003
|
-
credentialsError = errMsg(err);
|
|
6004
|
-
}
|
|
6005
|
-
} else {
|
|
6006
|
-
credentialsError = "github-app-not-configured: GITHUB_APP_* secrets are unset";
|
|
6007
|
-
}
|
|
5998
|
+
// The mirror may mint a repository-scoped token as root for recovery fetches.
|
|
5999
|
+
// The thread receives no installation token, even on a writable attach.
|
|
6008
6000
|
// The mirror's recovery fetch — a ref pushed since the last refresh cycle:
|
|
6009
6001
|
// missing from the mirror, or present at a tip that is not the commit the
|
|
6010
6002
|
// caller expects (`wantSha`, item 51) — is the RESIDENT's operation: root,
|
|
@@ -6026,7 +6018,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6026
6018
|
// pre-check, the fetch under the lock and the return gate alike.
|
|
6027
6019
|
let want = wantShaForBinding({ boundRef: binding.ref, refHint, wantSha });
|
|
6028
6020
|
const returnable = input.namedRef ? false : canReturnToDefault(binding, facts.defaultRef);
|
|
6029
|
-
let fetchToken: string | null =
|
|
6021
|
+
let fetchToken: string | null = null;
|
|
6030
6022
|
if (
|
|
6031
6023
|
!fetchToken &&
|
|
6032
6024
|
githubAppConfigured(this.env) &&
|
|
@@ -6127,7 +6119,11 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6127
6119
|
throw new StepError("rev-parse", `the mirror's tip of ${JSON.stringify(binding.ref)} could not be read`);
|
|
6128
6120
|
}
|
|
6129
6121
|
const threadLockKey = await this.lockfileKey(sha);
|
|
6130
|
-
const
|
|
6122
|
+
const originUrl = githubDoor && !mode.readonly ? `${githubDoor.baseUrl}/git/${slug}.git` : mode.originUrl;
|
|
6123
|
+
// A reused tree can execute Git hooks during the worktree probes.
|
|
6124
|
+
// Delete legacy token files as root before its first thread-user command.
|
|
6125
|
+
if (mode.scrubCredentials || githubDoor) await this.scrubThreadCredentialFiles(binding);
|
|
6126
|
+
const recreated = await this.ensureThreadWorktree(binding, sha, originUrl, mode.modeSwitch, {
|
|
6131
6127
|
detached: target.kind === "sha",
|
|
6132
6128
|
reuse,
|
|
6133
6129
|
refChanged,
|
|
@@ -6173,9 +6169,10 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6173
6169
|
|
|
6174
6170
|
let deps: { deps: ThreadDepsMechanism; reconciled: boolean; depsKey?: string };
|
|
6175
6171
|
let credentials: AttachOk["credentials"] = mode.readonly ? "none" : "unavailable";
|
|
6176
|
-
let credentialsWrittenAt: number | undefined;
|
|
6177
|
-
let credentialTokenExpiresAtMs: number | undefined;
|
|
6178
6172
|
try {
|
|
6173
|
+
// A reused old tree or an interrupted old attach may still have a
|
|
6174
|
+
// user-readable App token. Remove it before install/build hooks run.
|
|
6175
|
+
if (mode.scrubCredentials || githubDoor) await this.scrubThreadCredentials(binding);
|
|
6179
6176
|
deps = await this.materializeThreadDeps(
|
|
6180
6177
|
binding,
|
|
6181
6178
|
locked.value.threadLockKey,
|
|
@@ -6183,15 +6180,14 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6183
6180
|
facts.lockfileHash,
|
|
6184
6181
|
record.commands.install,
|
|
6185
6182
|
);
|
|
6186
|
-
if (mode.
|
|
6187
|
-
|
|
6188
|
-
|
|
6189
|
-
|
|
6190
|
-
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
credentialTokenExpiresAtMs = tokenExpiresAtMs ?? undefined;
|
|
6183
|
+
if (githubDoor && !mode.readonly) {
|
|
6184
|
+
await this.threadRunOk(
|
|
6185
|
+
binding.user,
|
|
6186
|
+
binding.worktreePath,
|
|
6187
|
+
`git remote set-url origin ${shellQuote(`${githubDoor.baseUrl}/git/${slug}.git`)}`,
|
|
6188
|
+
"worktree-door-remote",
|
|
6189
|
+
DEFAULT_EXEC_TIMEOUT_MS,
|
|
6190
|
+
);
|
|
6195
6191
|
credentials = "ok";
|
|
6196
6192
|
}
|
|
6197
6193
|
} catch (err) {
|
|
@@ -6217,7 +6213,12 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6217
6213
|
// read-only attach scrubbed the file, a writable one either rewrote it
|
|
6218
6214
|
// (stamped below) or could not — and "unknown" is what makes the next exec
|
|
6219
6215
|
// re-mint.
|
|
6220
|
-
const {
|
|
6216
|
+
const {
|
|
6217
|
+
credentialsWrittenAt: _prior,
|
|
6218
|
+
tokenExpiresAtMs: _priorExp,
|
|
6219
|
+
githubDoorHost: _priorDoor,
|
|
6220
|
+
...bindingSansCred
|
|
6221
|
+
} = binding;
|
|
6221
6222
|
await this.ctx.storage.put(threadBindingKey(threadKey), {
|
|
6222
6223
|
...bindingSansCred,
|
|
6223
6224
|
lastAttachAt: new Date(systemClock()).toISOString(),
|
|
@@ -6226,10 +6227,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6226
6227
|
...(deps.depsKey ? { depsKey: deps.depsKey } : {}),
|
|
6227
6228
|
sha: locked.value.sha,
|
|
6228
6229
|
readonly: mode.readonly,
|
|
6229
|
-
|
|
6230
|
-
// exec sees the file missing and re-mints (or logs and runs without).
|
|
6231
|
-
...(credentialsWrittenAt !== undefined ? { credentialsWrittenAt } : {}),
|
|
6232
|
-
...(credentialTokenExpiresAtMs !== undefined ? { tokenExpiresAtMs: credentialTokenExpiresAtMs } : {}),
|
|
6230
|
+
...(githubDoor && !mode.readonly ? { githubDoorHost: new URL(githubDoor.baseUrl).host } : {}),
|
|
6233
6231
|
} satisfies ThreadBinding);
|
|
6234
6232
|
// Item 55: the tree is on disk now; the next refresh instance's `measure`
|
|
6235
6233
|
// step counts it — the admission's free-space term is a live `df`, and the
|
|
@@ -6248,7 +6246,6 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6248
6246
|
...(container !== undefined ? { container } : {}),
|
|
6249
6247
|
deps: deps.deps,
|
|
6250
6248
|
credentials,
|
|
6251
|
-
...(credentialsError ? { credentialsError } : {}),
|
|
6252
6249
|
readonly: mode.readonly,
|
|
6253
6250
|
mutexWaitMs: locked.waitedMs,
|
|
6254
6251
|
attachMs: systemClock() - t0,
|
|
@@ -6281,8 +6278,8 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6281
6278
|
* need write access there), and hardlinked objects would let a chown-ed
|
|
6282
6279
|
* owner chmod shared inodes under every other tree. A no-hardlink clone
|
|
6283
6280
|
* gives the thread user a fully-owned repo whose writes stay in its own
|
|
6284
|
-
* .git; `origin` is repointed at
|
|
6285
|
-
*
|
|
6281
|
+
* .git; writable `origin` is repointed at the Git door so fetch/push use
|
|
6282
|
+
* the run bearer rather than the (deliberately unreadable) mirror. */
|
|
6286
6283
|
private async ensureThreadWorktree(
|
|
6287
6284
|
binding: ThreadBinding,
|
|
6288
6285
|
sha: string,
|
|
@@ -6363,7 +6360,7 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6363
6360
|
});
|
|
6364
6361
|
}
|
|
6365
6362
|
await this.runOk(["chown", "-R", `${binding.user}:${binding.user}`, wt], "worktree-chown");
|
|
6366
|
-
// Writable: origin →
|
|
6363
|
+
// Writable: origin → Git door (fetch/push with the run bearer).
|
|
6367
6364
|
// Read-only: origin stays the local mirror, which thread users cannot
|
|
6368
6365
|
// traverse (root:worker1 750) — fetch/push fail legibly, while the
|
|
6369
6366
|
// clone-time remote-tracking refs still serve `git diff origin/<base>...HEAD`.
|
|
@@ -6395,19 +6392,28 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
6395
6392
|
}
|
|
6396
6393
|
}
|
|
6397
6394
|
|
|
6398
|
-
/**
|
|
6399
|
-
*
|
|
6395
|
+
/** Before a thread runs, remove both legacy App-token locations: the Git
|
|
6396
|
+
* store and the staging file an interrupted old attach could leave behind.
|
|
6397
|
+
* A deletion failure aborts attach before any model command. */
|
|
6400
6398
|
private async scrubThreadCredentials(binding: ThreadBinding): Promise<void> {
|
|
6401
|
-
|
|
6399
|
+
await this.scrubThreadCredentialFiles(binding);
|
|
6402
6400
|
await this.threadRunOk(
|
|
6403
6401
|
binding.user,
|
|
6404
6402
|
binding.worktreePath,
|
|
6405
|
-
|
|
6406
|
-
"thread-cred-scrub",
|
|
6403
|
+
"git config --unset-all credential.helper || true",
|
|
6404
|
+
"thread-cred-helper-scrub",
|
|
6407
6405
|
DEFAULT_EXEC_TIMEOUT_MS,
|
|
6408
6406
|
);
|
|
6409
6407
|
}
|
|
6410
6408
|
|
|
6409
|
+
private async scrubThreadCredentialFiles(binding: ThreadBinding): Promise<void> {
|
|
6410
|
+
await this.runOk(
|
|
6411
|
+
legacyCredentialScrubCommand(binding.worktreePath, `/workspace/.stage-${binding.user}`),
|
|
6412
|
+
"thread-cred-file-scrub",
|
|
6413
|
+
{ timeoutMs: DEFAULT_EXEC_TIMEOUT_MS },
|
|
6414
|
+
);
|
|
6415
|
+
}
|
|
6416
|
+
|
|
6411
6417
|
/** Run one dep-cache script (`depCacheScript` / `mutableCacheSwapScript`)
|
|
6412
6418
|
* and parse its tagged output. A failure throws the StepError the old
|
|
6413
6419
|
* per-spawn code would have thrown: the step comes from the script's
|
|
@@ -7018,78 +7024,6 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
7018
7024
|
this.stageDirsReady.add(user);
|
|
7019
7025
|
}
|
|
7020
7026
|
|
|
7021
|
-
/** Per-attach credential file: the minted token reaches the
|
|
7022
|
-
* worktree via the SDK file API into a 700 per-user staging dir, then a
|
|
7023
|
-
* privilege-dropped `cat` into `.git/github-credentials` (0600, owned by
|
|
7024
|
-
* the thread user). The token never appears in argv or process-wide env —
|
|
7025
|
-
* `ps` from another thread user sees file PATHS at most. The worktree's
|
|
7026
|
-
* git credential helper points at the file. Returns the write time the
|
|
7027
|
-
* caller persists as `credentialsWrittenAt`. */
|
|
7028
|
-
private async writeThreadCredentials(binding: ThreadBinding, token: string): Promise<number> {
|
|
7029
|
-
const stageDir = `/workspace/.stage-${binding.user}`;
|
|
7030
|
-
const stage = `${stageDir}/cred`;
|
|
7031
|
-
await this.ensureStageDir(binding.user, stageDir);
|
|
7032
|
-
await this.writeFile(stage, `https://x-access-token:${token}@github.com\n`);
|
|
7033
|
-
await this.runOk(
|
|
7034
|
-
["sh", "-c", `chown ${binding.user}:${binding.user} ${stage} && chmod 600 ${stage}`],
|
|
7035
|
-
"stage-perms",
|
|
7036
|
-
);
|
|
7037
|
-
const cred = `${binding.worktreePath}/.git/github-credentials`;
|
|
7038
|
-
await this.threadRunOk(
|
|
7039
|
-
binding.user,
|
|
7040
|
-
binding.worktreePath,
|
|
7041
|
-
`umask 077 && cat ${stage} > ${cred} && rm -f ${stage} && chmod 600 ${cred} && git config credential.helper 'store --file=${cred}'`,
|
|
7042
|
-
"thread-cred",
|
|
7043
|
-
DEFAULT_EXEC_TIMEOUT_MS,
|
|
7044
|
-
);
|
|
7045
|
-
return systemClock();
|
|
7046
|
-
}
|
|
7047
|
-
|
|
7048
|
-
/** Per-exec credential refresh: the attach-time token lives one
|
|
7049
|
-
* hour, a coding run can push later than that, and git's `store` helper
|
|
7050
|
-
* erases a 401'd credential from the file — so before every writable exec,
|
|
7051
|
-
* size the file (one `stat`) and let the pure `shouldRefreshThreadCredentials`
|
|
7052
|
-
* decide; on refresh, re-mint (cached per slug) and rewrite. Returns the
|
|
7053
|
-
* new write time, or undefined when nothing changed. Never throws: a mint
|
|
7054
|
-
* failure is command-level — logged, and the command runs with whatever the
|
|
7055
|
-
* file holds (never a lifecycle transition). Unconfigured App → nothing to
|
|
7056
|
-
* refresh, silently (attach already reported `credentials:"unavailable"`). */
|
|
7057
|
-
private async refreshThreadCredentialsIfDue(
|
|
7058
|
-
binding: ThreadBinding,
|
|
7059
|
-
): Promise<{ writtenAtMs: number; tokenExpiresAtMs: number } | undefined> {
|
|
7060
|
-
if (binding.readonly || !githubAppConfigured(this.env)) return undefined;
|
|
7061
|
-
const cred = `${binding.worktreePath}/.git/github-credentials`;
|
|
7062
|
-
const sized = await this.run(["stat", "-c", "%s", cred]);
|
|
7063
|
-
const fileBytes = sized.exitCode === 0 ? Number.parseInt(sized.stdout.trim(), 10) : null;
|
|
7064
|
-
const decision = shouldRefreshThreadCredentials({
|
|
7065
|
-
writtenAtMs: binding.credentialsWrittenAt ?? null,
|
|
7066
|
-
tokenExpiresAtMs: binding.tokenExpiresAtMs ?? null,
|
|
7067
|
-
nowMs: systemClock(),
|
|
7068
|
-
fileBytes: fileBytes === null || Number.isNaN(fileBytes) ? null : fileBytes,
|
|
7069
|
-
readonly: binding.readonly ?? false,
|
|
7070
|
-
});
|
|
7071
|
-
if (!decision.refresh) return undefined;
|
|
7072
|
-
const resource = (await this.ctx.storage.get<string>(RESOURCE_KEY)) ?? "";
|
|
7073
|
-
try {
|
|
7074
|
-
// An `empty` file means git's `store` helper erased a token GitHub had
|
|
7075
|
-
// REJECTED (401) — so the per-slug cache may still hold that same dead
|
|
7076
|
-
// token, and serving it would rewrite the rejection and 401 the next
|
|
7077
|
-
// exec too. Force a fresh mint for a repudiated token; an
|
|
7078
|
-
// expiry-driven refresh still reuses the cache.
|
|
7079
|
-
const minted = await mintRepoScopedToken(this.env, resource.slice("repo:".length), {
|
|
7080
|
-
fresh: decision.reason === "empty",
|
|
7081
|
-
});
|
|
7082
|
-
const writtenAt = await this.writeThreadCredentials(binding, minted.token);
|
|
7083
|
-
console.log(`credentials: refreshed for ${binding.threadKey} (${decision.reason})`);
|
|
7084
|
-
return { writtenAtMs: writtenAt, tokenExpiresAtMs: minted.expiresAtMs };
|
|
7085
|
-
} catch (err) {
|
|
7086
|
-
console.log(
|
|
7087
|
-
`credentials: refresh failed (${decision.reason}: ${errMsg(err)}) — command runs without a fresh token`,
|
|
7088
|
-
);
|
|
7089
|
-
return undefined;
|
|
7090
|
-
}
|
|
7091
|
-
}
|
|
7092
|
-
|
|
7093
7027
|
/** Shared entry checks for exec/read/write: hydrated resident, live
|
|
7094
7028
|
* binding, worktree actually on disk (the container may have slept since
|
|
7095
7029
|
* the last attach — disk is cache, re-attach recreates). */
|
|
@@ -7213,15 +7147,17 @@ export class ResidentDO extends Sandbox<Env> {
|
|
|
7213
7147
|
const pre = await this.threadPreflight(threadKey);
|
|
7214
7148
|
if ("error" in pre) return pre;
|
|
7215
7149
|
const { binding } = pre;
|
|
7216
|
-
|
|
7150
|
+
if (!binding.readonly && !binding.githubDoorHost)
|
|
7151
|
+
return { error: "writable command requires a Git door binding", status: 403, cause: "request" };
|
|
7152
|
+
if (
|
|
7153
|
+
binding.githubDoorHost &&
|
|
7154
|
+
(env?.GH_HOST !== binding.githubDoorHost ||
|
|
7155
|
+
!/^sbr_[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9_-]{20,128}$/.test(env?.GH_ENTERPRISE_TOKEN ?? ""))
|
|
7156
|
+
)
|
|
7157
|
+
return { error: "Git door run bearer is missing from this command", status: 403, cause: "request" };
|
|
7217
7158
|
await this.ctx.storage.put(threadBindingKey(threadKey), {
|
|
7218
7159
|
...binding,
|
|
7219
7160
|
lastAttachAt: new Date(systemClock()).toISOString(), // exec counts as activity for the sweep
|
|
7220
|
-
// A refresh re-mints and rewrites: persist both the new write time and the
|
|
7221
|
-
// new token expiry so the next exec's decision keys on this token.
|
|
7222
|
-
...(refreshed
|
|
7223
|
-
? { credentialsWrittenAt: refreshed.writtenAtMs, tokenExpiresAtMs: refreshed.tokenExpiresAtMs }
|
|
7224
|
-
: {}),
|
|
7225
7161
|
} satisfies ThreadBinding);
|
|
7226
7162
|
|
|
7227
7163
|
// A runtime replacement or a control reset under the command propagates to
|
|
@@ -9636,6 +9572,30 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
|
|
|
9636
9572
|
const refByDefault = parseRefByDefault(body.refByDefault);
|
|
9637
9573
|
if ("error" in refByDefault) return json({ error: refByDefault.error }, 400);
|
|
9638
9574
|
const reason: RefHintReason = { ownPr: parsedOwnPr.ownPr, refByDefault: refByDefault.refByDefault };
|
|
9575
|
+
let githubDoor: { baseUrl: string; bearer: string } | undefined;
|
|
9576
|
+
if (body.githubDoor !== undefined) {
|
|
9577
|
+
const value = body.githubDoor as Record<string, unknown>;
|
|
9578
|
+
if (!value || typeof value !== "object" || typeof value.baseUrl !== "string" || typeof value.bearer !== "string")
|
|
9579
|
+
return json({ error: "invalid Git door attach credential" }, 400);
|
|
9580
|
+
let url: URL;
|
|
9581
|
+
try {
|
|
9582
|
+
url = new URL(value.baseUrl);
|
|
9583
|
+
} catch {
|
|
9584
|
+
return json({ error: "invalid Git door base URL" }, 400);
|
|
9585
|
+
}
|
|
9586
|
+
if (
|
|
9587
|
+
url.protocol !== "https:" ||
|
|
9588
|
+
url.username ||
|
|
9589
|
+
url.password ||
|
|
9590
|
+
url.pathname !== "/" ||
|
|
9591
|
+
url.search ||
|
|
9592
|
+
url.hash ||
|
|
9593
|
+
!/^sbr_[A-Za-z0-9_-]{1,64}\.[A-Za-z0-9_-]{20,128}$/.test(value.bearer)
|
|
9594
|
+
)
|
|
9595
|
+
return json({ error: "invalid Git door attach credential" }, 400);
|
|
9596
|
+
githubDoor = { baseUrl: url.origin, bearer: value.bearer };
|
|
9597
|
+
}
|
|
9598
|
+
if (!readonly.readonly && !githubDoor) return json({ error: "writable attach requires a Git door run bearer" }, 403);
|
|
9639
9599
|
// Post-validation, the answer streams like /exec (item 59): heartbeat
|
|
9640
9600
|
// whitespace then ONE JSON document over HTTP 200, so an attach that waits
|
|
9641
9601
|
// on a deps install (minutes) cannot lose the connection the way a plain
|
|
@@ -9653,6 +9613,7 @@ async function handleAttach(env: Env, body: Record<string, unknown>, traceparent
|
|
|
9653
9613
|
ctx.record,
|
|
9654
9614
|
traceparent,
|
|
9655
9615
|
reason,
|
|
9616
|
+
githubDoor,
|
|
9656
9617
|
),
|
|
9657
9618
|
),
|
|
9658
9619
|
({ result, levels }) => ({ ...(result as object), ...(levels ? { levels } : {}) }),
|
|
@@ -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.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "switchboard",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.266.1",
|
|
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.1",
|
|
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.1",
|
|
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,36 @@
|
|
|
1
|
+
/** The public Git door surface, shared by the bot and its Cloudflare edge. */
|
|
2
|
+
export const GIT_DOOR_PATH =
|
|
3
|
+
/^\/(?:git\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\.git\/(info\/refs|git-upload-pack|git-receive-pack)$/;
|
|
4
|
+
|
|
5
|
+
export function isGithubDoorPath(path: string): boolean {
|
|
6
|
+
return path === "/api/graphql" || path.startsWith("/api/v3/") || GIT_DOOR_PATH.test(path);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function githubDoorMethodAllowed(url: URL, method: string): boolean {
|
|
10
|
+
const path = url.pathname;
|
|
11
|
+
if (path === "/api/graphql") return method === "POST";
|
|
12
|
+
if (path.startsWith("/api/v3/")) return method === "GET" || method === "HEAD";
|
|
13
|
+
const git = GIT_DOOR_PATH.exec(path);
|
|
14
|
+
if (!git) return false;
|
|
15
|
+
if (git[3] === "info/refs")
|
|
16
|
+
return method === "GET" && ["git-upload-pack", "git-receive-pack"].includes(url.searchParams.get("service") ?? "");
|
|
17
|
+
return method === "POST";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A configured git.* host exposes only the door. A door path on any other
|
|
21
|
+
* host, including workers.dev and the dashboard host, is never forwarded. */
|
|
22
|
+
export function githubDoorEdgeRoute(url: URL, method: string, configuredBase?: string): "door" | "other" | "refuse" {
|
|
23
|
+
const doorPath = isGithubDoorPath(url.pathname);
|
|
24
|
+
if (!configuredBase) return doorPath ? "refuse" : "other";
|
|
25
|
+
let base: URL;
|
|
26
|
+
try {
|
|
27
|
+
base = new URL(configuredBase);
|
|
28
|
+
} catch {
|
|
29
|
+
return "refuse";
|
|
30
|
+
}
|
|
31
|
+
if (base.protocol !== "https:" || base.pathname !== "/" || base.search || base.hash) return "refuse";
|
|
32
|
+
if (url.host === base.host) {
|
|
33
|
+
return url.protocol === "https:" && doorPath && githubDoorMethodAllowed(url, method) ? "door" : "refuse";
|
|
34
|
+
}
|
|
35
|
+
return doorPath ? "refuse" : "other";
|
|
36
|
+
}
|
|
@@ -313,6 +313,8 @@ export interface ExistingPrPublicationBinding {
|
|
|
313
313
|
export interface CoordinatorTag {
|
|
314
314
|
parentInstanceId: string;
|
|
315
315
|
idempotencyKey: string;
|
|
316
|
+
/** The unit's durable write branch, read from its row at child admission. */
|
|
317
|
+
branch?: string;
|
|
316
318
|
/** Workflow transport for a recovered child. Identity and idempotency remain
|
|
317
319
|
* on `parentInstanceId`; only lifecycle wake-ups use this checkpoint id. */
|
|
318
320
|
transportWorkflowId?: string;
|
|
@@ -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
|
+
}
|