@coreplane/switchboard 1.265.1 → 1.266.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/assets/.env.example +6 -0
  2. package/dist/assets/deploy/cloudflare/depotCi.ts +246 -0
  3. package/dist/assets/deploy/cloudflare/worker.ts +23 -1
  4. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +5 -1
  5. package/dist/assets/deploy/cloudflare-memory/worker.ts +48 -3
  6. package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +43 -0
  7. package/dist/assets/deploy/cloudflare-resident/worker.ts +102 -141
  8. package/dist/assets/deploy/secrets.manifest.json +13 -1
  9. package/dist/assets/package-lock.json +3 -3
  10. package/dist/assets/package.json +1 -1
  11. package/dist/assets/source.json +3 -3
  12. package/dist/assets/src/channels/githubDoorPaths.ts +36 -0
  13. package/dist/assets/src/core/coordinator/contract.ts +2 -0
  14. package/dist/assets/src/core/depotCi.ts +72 -0
  15. package/dist/assets/src/core/refusal.ts +2 -0
  16. package/dist/assets/src/core/runEvents.ts +1 -0
  17. package/dist/assets/src/core/runRecord.ts +41 -0
  18. package/dist/assets/src/execution/residentReadonly.ts +9 -8
  19. package/dist/assets/src/execution/residentSteps.ts +2 -1
  20. package/dist/assets/src/execution/seedPlan.ts +2 -2
  21. package/dist/assets/web/dist/.vite/manifest.json +50 -50
  22. package/dist/assets/web/dist/assets/{HomePage-CBQpcnaW.js → HomePage-BfpIrKDX.js} +1 -1
  23. package/dist/assets/web/dist/assets/{PendingTurnRow-DIC86uWA.js → PendingTurnRow-C6hroa00.js} +1 -1
  24. package/dist/assets/web/dist/assets/{PlanePage-CfrbMh8t.js → PlanePage-C2Cb1mkT.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentDetailPage-C-SgOUTl.js → ResidentDetailPage-Bd9_Q87l.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ResidentsIndexPage-CiDhvXok.js → ResidentsIndexPage-C8eS7hxp.js} +1 -1
  27. package/dist/assets/web/dist/assets/{RunFoldRow-DZ8huwUU.js → RunFoldRow-DrfFauYJ.js} +1 -1
  28. package/dist/assets/web/dist/assets/{RunRoutePage-Cp6wicYc.js → RunRoutePage-B_eXfNAF.js} +3 -3
  29. package/dist/assets/web/dist/assets/{RunsIndexPage-BPXtfwwx.js → RunsIndexPage-BpQu36Dm.js} +1 -1
  30. package/dist/assets/web/dist/assets/{ScheduledPage-DbvP48HI.js → ScheduledPage-DGHcjcSt.js} +1 -1
  31. package/dist/assets/web/dist/assets/{SettingsPage-B87lZaiN.js → SettingsPage-IfQ71QjN.js} +1 -1
  32. package/dist/assets/web/dist/assets/{SilentTurn-CPED_XCV.js → SilentTurn-D9ON15a8.js} +1 -1
  33. package/dist/assets/web/dist/assets/{StatusDot-CFLn-DZ2.js → StatusDot-DyFjGg4V.js} +1 -1
  34. package/dist/assets/web/dist/assets/{Tooltip-Dx9YBVEx.js → Tooltip-BvPkV4kZ.js} +1 -1
  35. package/dist/assets/web/dist/assets/{UnitRoutePage-d4A2X_o7.js → UnitRoutePage-BNe3nBIG.js} +1 -1
  36. package/dist/assets/web/dist/assets/{dist-Cn51vWpw.js → dist-v3Py3_QR.js} +1 -1
  37. package/dist/assets/web/dist/assets/main-DPB0UO1-.css +1 -0
  38. package/dist/assets/web/dist/assets/main-ks3kBojV.js +28 -0
  39. package/dist/assets/web/dist/assets/{sseReplay-DGRDRLua.js → sseReplay-DuALaBPq.js} +1 -1
  40. package/dist/cli.js +2950 -1260
  41. package/package.json +1 -1
  42. package/dist/assets/web/dist/assets/main-DGC6WFqS.css +0 -1
  43. package/dist/assets/web/dist/assets/main-DYwRHh0F.js +0 -28
@@ -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 + per-attach credential file),
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 root-only one-shot credential file, never env and
65
- // never argv.
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
- // container sees nothing but 1-hour installation tokens scoped to the
68
- // resident's own repo, injected per command. Install/build executions
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: the same threshold `shouldRefreshThreadCredentials`
977
- // refreshes at, so a token the cache hands out is never one a fresh attach
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` — credential file written; `unavailable` — writable attach but no
1252
- * token (see credentialsError); `none` — read-only attach, deliberately no
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
- // Command-level token mint — before the lock so mint latency
5991
- // never holds the mutex, and failure never blocks the attach.
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 = token;
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 recreated = await this.ensureThreadWorktree(binding, sha, mode.originUrl, mode.modeSwitch, {
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.scrubCredentials) {
6187
- // Every read-only attach, reused tree included: a tree built before
6188
- // this rule (or by a writable attach on this thread) may carry a file.
6189
- await this.scrubThreadCredentials(binding);
6190
- } else if (token) {
6191
- credentialsWrittenAt = await this.writeThreadCredentials(binding, token);
6192
- // Persist the token's expiry beside the write time so the first writable
6193
- // exec refreshes off the token's own life, not the file's age.
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 { credentialsWrittenAt: _prior, tokenExpiresAtMs: _priorExp, ...bindingSansCred } = binding;
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
- // A writable attach that could not mint keeps nothing to date: the next
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 GitHub so fetch/push use the per-attach
6285
- * credential file rather than the (deliberately unreadable) mirror. */
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 → GitHub (fetch/push via the per-attach credential file).
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
- /** Read-only attach (item 50): make sure the tree carries no credential file
6399
- * and no credential helper — idempotent, run on every read-only attach. */
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
- const cred = `${binding.worktreePath}/.git/github-credentials`;
6399
+ await this.scrubThreadCredentialFiles(binding);
6402
6400
  await this.threadRunOk(
6403
6401
  binding.user,
6404
6402
  binding.worktreePath,
6405
- `rm -f ${cred} && (git config --unset-all credential.helper || true)`,
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
- const refreshed = await this.refreshThreadCredentialsIfDue(binding);
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.265.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.265.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.265.1",
21063
+ "version": "1.266.1",
21064
21064
  "license": "Apache-2.0",
21065
21065
  "dependencies": {
21066
21066
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.265.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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.265.1",
3
- "commit": "dccb10815d97823de2be35d9768379a2d9ff6aaf",
4
- "builtAt": "2026-09-26T14:55:53.149Z"
2
+ "version": "1.266.1",
3
+ "commit": "2b840cc31a6aae35accff736dad1dc74a30c2db3",
4
+ "builtAt": "2026-09-26T22:15:46.755Z"
5
5
  }
@@ -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
+ }