@coreplane/switchboard 1.267.0 → 1.267.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/deploy/cloudflare-resident/gc.ts +1 -0
  2. package/dist/assets/deploy/cloudflare-resident/keyedAsyncLock.ts +20 -0
  3. package/dist/assets/deploy/cloudflare-resident/legacyCredentials.ts +57 -0
  4. package/dist/assets/deploy/cloudflare-resident/orphanThreadUsers.ts +14 -0
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +646 -204
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/core/budgets.ts +10 -0
  10. package/dist/assets/src/core/coordinator/contract.ts +29 -0
  11. package/dist/assets/src/core/coordinator/driver.ts +15 -2
  12. package/dist/assets/src/core/runRecord.ts +32 -0
  13. package/dist/assets/src/core/ship/coordinator.ts +42 -4
  14. package/dist/assets/src/execution/githubMintScope.ts +49 -0
  15. package/dist/assets/src/execution/residentCredentials.ts +9 -37
  16. package/dist/assets/src/execution/residentDestroyGate.ts +12 -0
  17. package/dist/assets/src/execution/residentPoolSpends.ts +96 -0
  18. package/dist/assets/src/execution/residentRecreateAdmission.ts +41 -0
  19. package/dist/assets/src/execution/residentSteps.ts +1 -0
  20. package/dist/assets/web/dist/.vite/manifest.json +67 -67
  21. package/dist/assets/web/dist/assets/{DeliveryPage-BUJckSuW.js → DeliveryPage-QyS6_Q0m.js} +1 -1
  22. package/dist/assets/web/dist/assets/{HomePage-BfpIrKDX.js → HomePage-Bm40r0wF.js} +1 -1
  23. package/dist/assets/web/dist/assets/{PendingTurnRow-C6hroa00.js → PendingTurnRow-BqY-EaZR.js} +1 -1
  24. package/dist/assets/web/dist/assets/{PlanePage-C2Cb1mkT.js → PlanePage-VmzjNLAz.js} +1 -1
  25. package/dist/assets/web/dist/assets/{ResidentDetailPage-Bd9_Q87l.js → ResidentDetailPage-hjX3tRCh.js} +1 -1
  26. package/dist/assets/web/dist/assets/{ResidentsIndexPage-C8eS7hxp.js → ResidentsIndexPage-BKGnlCEh.js} +1 -1
  27. package/dist/assets/web/dist/assets/{RunFoldRow-DrfFauYJ.js → RunFoldRow-BOWvSoIv.js} +1 -1
  28. package/dist/assets/web/dist/assets/{RunRoutePage-B_eXfNAF.js → RunRoutePage-D9ztf-uE.js} +3 -3
  29. package/dist/assets/web/dist/assets/{RunsIndexPage-BpQu36Dm.js → RunsIndexPage-DTYQbhtr.js} +1 -1
  30. package/dist/assets/web/dist/assets/{ScheduledPage-DGHcjcSt.js → ScheduledPage-BAYR0tuv.js} +1 -1
  31. package/dist/assets/web/dist/assets/{SettingsPage-IfQ71QjN.js → SettingsPage-BlYBmCG9.js} +1 -1
  32. package/dist/assets/web/dist/assets/{SilentTurn-D9ON15a8.js → SilentTurn-CHfzRQW8.js} +1 -1
  33. package/dist/assets/web/dist/assets/{StatusDot-DyFjGg4V.js → StatusDot-BMkUFVE8.js} +1 -1
  34. package/dist/assets/web/dist/assets/{Tooltip-BvPkV4kZ.js → Tooltip-Dfot16IA.js} +1 -1
  35. package/dist/assets/web/dist/assets/{UnitRoutePage-BNe3nBIG.js → UnitRoutePage-BdZoT-4d.js} +1 -1
  36. package/dist/assets/web/dist/assets/budgets-CJHiTZmM.js +1 -0
  37. package/dist/assets/web/dist/assets/{dist-v3Py3_QR.js → dist-XRrc0hhm.js} +1 -1
  38. package/dist/assets/web/dist/assets/{indexRow-BsWcHHVh.js → indexRow-BGjjLnN5.js} +1 -1
  39. package/dist/assets/web/dist/assets/{main-ks3kBojV.js → main-B32N1vUh.js} +2 -2
  40. package/dist/assets/web/dist/assets/{sseReplay-DuALaBPq.js → sseReplay-BsNQPLmJ.js} +2 -2
  41. package/dist/cli.js +928 -346
  42. package/package.json +1 -1
  43. package/dist/assets/web/dist/assets/budgets-BQgcCyvb.js +0 -1
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.267.0",
3
+ "version": "1.267.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.267.0",
9
+ "version": "1.267.1",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -21023,7 +21023,7 @@
21023
21023
  },
21024
21024
  "packages/switchboard": {
21025
21025
  "name": "@coreplane/switchboard",
21026
- "version": "1.267.0",
21026
+ "version": "1.267.1",
21027
21027
  "license": "Apache-2.0",
21028
21028
  "dependencies": {
21029
21029
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.267.0",
3
+ "version": "1.267.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.267.0",
3
- "commit": "594f06834184ea4ff5559b00a8fc265f33e3db64",
4
- "builtAt": "2026-09-28T22:36:17.080Z"
2
+ "version": "1.267.1",
3
+ "commit": "6382de7f03c465e18b040fd93103afb1dc44d543",
4
+ "builtAt": "2026-09-28T23:55:40.901Z"
5
5
  }
@@ -64,6 +64,16 @@ export const QUESTION_TTL_MS = DAY_MS;
64
64
  * that FAILED is never cached — the next read asks again. */
65
65
  export const AUTHOR_BINDING_TTL_MS = 15 * MINUTE_MS;
66
66
 
67
+ /** Maximum local lifetime of any Git receive-pack forward. The bounded
68
+ * request prevents a write connection outliving its run indefinitely after
69
+ * revocation. An aborted remote write has an unknown outcome; existing-PR
70
+ * writes retain their owned pending intent for authoritative reconciliation. */
71
+ export const GIT_RECEIVE_PACK_FORWARD_TIMEOUT_MS = 5 * MINUTE_MS;
72
+ /** The run's final record waits for that one forwarded report, plus a small
73
+ * durable-receipt margin, before sealing. If it still has no answer, the
74
+ * pending intent stays as an uncertain write and no retry is authorized. */
75
+ export const GIT_PUBLICATION_SETTLE_TIMEOUT_MS = GIT_RECEIVE_PACK_FORWARD_TIMEOUT_MS + 10 * SECOND_MS;
76
+
67
77
  /** How long a dispatch's FIRST attach to a resident — a fresh run's, or a
68
78
  * resumed run's re-attach to its recorded worktree — waits for the resident
69
79
  * to wake when the Worker typed its refusal as the platform's transient
@@ -493,6 +493,15 @@ export interface RoundGate {
493
493
  findings: string[];
494
494
  }
495
495
 
496
+ /** A later human review is evidence to investigate, never typed findings. */
497
+ export interface RecoveryReviewEvidence {
498
+ id: number;
499
+ reviewer: { login: string; id: number };
500
+ headSha: string;
501
+ submittedAt: number;
502
+ body: string;
503
+ }
504
+
496
505
  /** The original grant and historically priced children carried through a recovery checkpoint. */
497
506
  export interface RecoveryAccounting {
498
507
  spendUsd: number;
@@ -506,6 +515,7 @@ export interface RecoveryAccounting {
506
515
  * the original instance/unit idempotency namespace. */
507
516
  export interface OriginalUnitRecovery {
508
517
  kind: "findings" | "review";
518
+ externalReview?: RecoveryReviewEvidence;
509
519
  accounting?: RecoveryAccounting;
510
520
  round: number;
511
521
  expectedHeadSha: string;
@@ -540,6 +550,7 @@ export interface OriginalUnitRecovery {
540
550
  }
541
551
 
542
552
  export interface OriginalUnitRecoveryReceipt {
553
+ externalReview?: RecoveryReviewEvidence;
543
554
  accounting?: RecoveryAccounting;
544
555
  reviewRunId: string;
545
556
  workflowId: string;
@@ -767,7 +778,18 @@ export const isUnitWakeAnswer = (v: unknown): v is UnitWakeAnswer => {
767
778
  );
768
779
  };
769
780
 
781
+ const isPositiveId = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
770
782
  const isDollars = (v: unknown): v is number => isFinite(v) && v >= 0;
783
+ const isRecoveryReview = (v: unknown): v is RecoveryReviewEvidence =>
784
+ isObject(v) &&
785
+ isPositiveId(v.id) &&
786
+ isObject(v.reviewer) &&
787
+ isText(v.reviewer.login) &&
788
+ isPositiveId(v.reviewer.id) &&
789
+ typeof v.headSha === "string" &&
790
+ /^[0-9a-f]{40}$/i.test(v.headSha) &&
791
+ isFinite(v.submittedAt) &&
792
+ typeof v.body === "string";
771
793
  const isRecoveryAccounting = (v: unknown): v is RecoveryAccounting =>
772
794
  isObject(v) &&
773
795
  isDollars(v.spendUsd) &&
@@ -839,6 +861,12 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
839
861
  STEP_NAME_PATTERN.test(r.recovery.step) &&
840
862
  isText(r.recovery.reviewRunId) &&
841
863
  (r.recovery.accounting === undefined || isRecoveryAccounting(r.recovery.accounting)) &&
864
+ (r.recovery.externalReview === undefined ||
865
+ (isRecoveryReview(r.recovery.externalReview) &&
866
+ r.recovery.kind === "review" &&
867
+ isRecoveryAccounting(r.recovery.accounting) &&
868
+ r.recovery.findings === undefined &&
869
+ r.recovery.findingsRunId === undefined)) &&
842
870
  (r.recovery.findingsRunId === undefined || isText(r.recovery.findingsRunId)) &&
843
871
  (r.recovery.findingsKey === undefined || isText(r.recovery.findingsKey)) &&
844
872
  ((r.recovery.findingsRunId === undefined && r.recovery.findingsKey === undefined) ||
@@ -869,6 +897,7 @@ export function isCoordinatorUnit(v: unknown): v is CoordinatorUnit {
869
897
  r.recoveryReceipt !== undefined &&
870
898
  (!isObject(r.recoveryReceipt) ||
871
899
  !isText(r.recoveryReceipt.reviewRunId) ||
900
+ (r.recoveryReceipt.externalReview !== undefined && !isRecoveryReview(r.recoveryReceipt.externalReview)) ||
872
901
  (r.recoveryReceipt.accounting !== undefined && !isRecoveryAccounting(r.recoveryReceipt.accounting)) ||
873
902
  typeof r.recoveryReceipt.workflowId !== "string" ||
874
903
  !INSTANCE_ID_PATTERN.test(r.recoveryReceipt.workflowId) ||
@@ -687,6 +687,18 @@ function isOpaqueNotFound(reply: BotReply): boolean {
687
687
  }
688
688
  }
689
689
 
690
+ /** A finished, addressed child whose final record is still being sealed.
691
+ * Unlike an opaque 404, this answer already establishes the child's identity. */
692
+ function isRecordPending(reply: BotReply): boolean {
693
+ if (reply.status !== 409) return false;
694
+ try {
695
+ const body = JSON.parse(reply.text) as { ok?: unknown; error?: unknown };
696
+ return body?.ok === false && body.error === "record_pending";
697
+ } catch {
698
+ return false;
699
+ }
700
+ }
701
+
690
702
  async function readRecordStep(
691
703
  step: StepRunner,
692
704
  bot: CoordinatorBot,
@@ -696,8 +708,9 @@ async function readRecordStep(
696
708
  for (let retry = 0; ; retry++) {
697
709
  const name = retry === 0 ? action.step : `${action.step}/record-read/${retry}`;
698
710
  const reply = await step.do(name, STEP_CONFIG, () => call(bot, "read-record", body, true));
699
- if (!isOpaqueNotFound(reply) || action.finishedObserved !== true || retry >= SHIP_RECORD_VISIBILITY.retries)
700
- return reply;
711
+ const pending = isRecordPending(reply);
712
+ const eventQualifiedMissing = action.finishedObserved === true && isOpaqueNotFound(reply);
713
+ if ((!pending && !eventQualifiedMissing) || retry >= SHIP_RECORD_VISIBILITY.retries) return reply;
701
714
  await step.sleep(`${action.step}/record-visible/${retry + 1}`, SHIP_RECORD_VISIBILITY.retryMs);
702
715
  }
703
716
  }
@@ -279,6 +279,17 @@ export interface RunRecord {
279
279
  * last `pushed_head` event's sha winning — the fact a renewal reads to know
280
280
  * the run made progress. Present only on a run that pushed. */
281
281
  pushed?: PushedHead[];
282
+ /** A Git-door write whose remote outcome was still uncertain at seal. The
283
+ * live ledger row is deleted at finish, so recovery retains this exact
284
+ * ref transition until head reconciliation. A PR number exists only for a
285
+ * verified existing-PR publication. */
286
+ doorPublicationPending?: {
287
+ id: string;
288
+ repo: string;
289
+ pr?: number;
290
+ owner?: { instanceId: string; unit: string };
291
+ update: { ref: string; old: string; next: string };
292
+ };
282
293
  /** What the run cost in tokens, per model, summed from its `model.turn`
283
294
  * spans at finish (`usageOfEvents`; docs/reference/specs/costs.md, cost by user).
284
295
  * Every record written since carries it (zero turns included); one written
@@ -1124,6 +1135,27 @@ export function isRunRecord(v: unknown): v is RunRecord {
1124
1135
  )
1125
1136
  )
1126
1137
  return false;
1138
+ if (r.doorPublicationPending !== undefined) {
1139
+ const pending = r.doorPublicationPending;
1140
+ if (typeof pending !== "object" || pending === null) return false;
1141
+ const { id, repo, pr, owner, update } = pending as Record<string, unknown>;
1142
+ if (
1143
+ typeof id !== "string" ||
1144
+ !id ||
1145
+ typeof repo !== "string" ||
1146
+ !repo ||
1147
+ (pr !== undefined && (!Number.isInteger(pr) || (pr as number) < 1))
1148
+ )
1149
+ return false;
1150
+ if (owner !== undefined && (typeof owner !== "object" || owner === null)) return false;
1151
+ if (typeof update !== "object" || update === null) return false;
1152
+ const o = owner as Record<string, unknown> | undefined;
1153
+ const u = update as Record<string, unknown>;
1154
+ if (o !== undefined && (typeof o.instanceId !== "string" || typeof o.unit !== "string")) return false;
1155
+ if (typeof u.ref !== "string" || !u.ref.startsWith("refs/heads/")) return false;
1156
+ if (typeof u.old !== "string" || !/^[0-9a-f]{40}$/.test(u.old)) return false;
1157
+ if (typeof u.next !== "string" || !/^[0-9a-f]{40}$/.test(u.next)) return false;
1158
+ }
1127
1159
  // A parent is named by a run id (item 46): the same shape as the record's own.
1128
1160
  if (r.parentRunId !== undefined && (typeof r.parentRunId !== "string" || !RUN_ID_PATTERN.test(r.parentRunId)))
1129
1161
  return false;
@@ -1243,6 +1243,9 @@ type Phase =
1243
1243
  * must independently prove an open branch and pull request at exactly
1244
1244
  * `childHead`; neither a push record nor this flag is remote evidence. */
1245
1245
  findingsReady?: true;
1246
+ /** A capped recovery must verify this pushed head before ending, but
1247
+ * missing findings outputs still forbid review or merge-ready success. */
1248
+ findingsIncomplete?: string[];
1246
1249
  /** The coding child died (`failed` or `interrupted`) after it may have
1247
1250
  * pushed: the pr-check recovers a pushed branch by opening its pull
1248
1251
  * request; with nothing pushed the unit ends with the child's own reason. */
@@ -1648,7 +1651,7 @@ export function nextAction(s: UnitPipelineState): CoordinatorAction {
1648
1651
  return {
1649
1652
  type: "pr-check",
1650
1653
  step: `${roundStep(s, p.round)}/pr-check`,
1651
- ...(p.dead !== undefined || p.recover === true || p.findingsReady === true
1654
+ ...(p.dead !== undefined || p.recover === true || p.findingsReady === true || p.findingsIncomplete !== undefined
1652
1655
  ? { recover: { runId: p.runId } }
1653
1656
  : {}),
1654
1657
  // The adopted pull request rides the check so the bot can follow it
@@ -1884,7 +1887,8 @@ function capEnding(s: UnitPipelineState, round?: RoundRef, refused?: Carve): Uni
1884
1887
  };
1885
1888
  }
1886
1889
 
1887
- /** A recovered unit keeps its original dollar cap across the checkpoint. */
1890
+ /** Recovery cannot buy another child (or claim readiness) after the original
1891
+ * dollar cap is spent. Unknown cost is not zero and never resets that cap. */
1888
1892
  function recoveryCostEnding(s: UnitPipelineState): UnitEnding | undefined {
1889
1893
  const cap = s.input.grant?.costCapUsd;
1890
1894
  if (s.input.recovery === undefined || cap === undefined) return undefined;
@@ -2037,6 +2041,21 @@ function settleCoding(
2037
2041
  if (facts.description !== true) missingOutputs.push("missing updated pull request description");
2038
2042
  const observedHead = fullHead(facts.headSha);
2039
2043
  if (observedHead === undefined) missingOutputs.push("missing final observed commit");
2044
+ if (
2045
+ missingOutputs.length > 0 &&
2046
+ costEnding !== undefined &&
2047
+ observedHead !== undefined &&
2048
+ facts.pushed?.length === 1 &&
2049
+ facts.pushed[0]?.ref === next.input.unit.branch &&
2050
+ facts.pushed[0]?.sha === observedHead
2051
+ )
2052
+ return {
2053
+ state: {
2054
+ ...next,
2055
+ phase: { at: "pr-check", round, runId, childHead: observedHead, findingsIncomplete: missingOutputs },
2056
+ },
2057
+ notes: [],
2058
+ };
2040
2059
  if (missingOutputs.length > 0)
2041
2060
  return end(
2042
2061
  next,
@@ -2656,7 +2675,7 @@ function finishSupersededChild(
2656
2675
  if (phase.round.kind === "findings") {
2657
2676
  const settled = settleCoding(s, phase.round, phase.runId, facts);
2658
2677
  const ready = settled.state.phase;
2659
- if (ready.at === "pr-check" && ready.findingsReady === true)
2678
+ if (ready.at === "pr-check" && (ready.findingsReady === true || ready.findingsIncomplete !== undefined))
2660
2679
  return settlePrCheck(settled.state, ready, supersession.pullRequest);
2661
2680
  return settled;
2662
2681
  }
@@ -2709,11 +2728,24 @@ export function reconcileTerminalPr(s: UnitPipelineState, pr: PrCheck): UnitPipe
2709
2728
  /** The pull request heading the branch after round 0 or a findings step. */
2710
2729
  function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-check" }>, pr: PrCheck): Transition {
2711
2730
  const { round } = phase;
2731
+ const endIncompleteFindings = (verified: UnitPipelineState): Transition => {
2732
+ const cap = recoveryCostEnding(verified);
2733
+ return end(
2734
+ verified,
2735
+ {
2736
+ kind: "aborted",
2737
+ reason: `${cap?.kind === "aborted" ? `${cap.reason} ` : ""}The completed findings work still lacks required results (${phase.findingsIncomplete?.join("; ")}); no review started.`,
2738
+ round,
2739
+ reviewRounds: verified.reviewRounds,
2740
+ },
2741
+ [roundNote(round, "aborted")],
2742
+ );
2743
+ };
2712
2744
  // A completed findings run is not ready because it says it pushed. It is
2713
2745
  // ready only when this independent read sees the adopted pull request at the
2714
2746
  // exact full observed source head (and, while open, its live branch). Every
2715
2747
  // other remote fact is a resumable stop with no review or merged success.
2716
- if (phase.findingsReady === true) {
2748
+ if (phase.findingsReady === true || phase.findingsIncomplete !== undefined) {
2717
2749
  const observedHead = fullHead(phase.childHead)!;
2718
2750
  const withPr = pr.state !== "none" ? { ...s, pr: { number: pr.prNumber, url: pr.url } } : s;
2719
2751
  if (pr.state === "merged") {
@@ -2745,6 +2777,8 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2745
2777
  },
2746
2778
  [roundNote(round, "aborted")],
2747
2779
  );
2780
+ if (phase.findingsIncomplete !== undefined)
2781
+ return endIncompleteFindings({ ...withPr, lastReviewHead: remoteHead });
2748
2782
  return foundMerged(withPr, pr, [roundNote(round, "completed")]);
2749
2783
  }
2750
2784
  if (pr.state !== "open" || pr.headBranchExists === false)
@@ -2788,6 +2822,10 @@ function settlePrCheck(s: UnitPipelineState, phase: Extract<Phase, { at: "pr-che
2788
2822
  },
2789
2823
  [roundNote(round, "aborted")],
2790
2824
  );
2825
+ const reconciled = { ...withPr, lastReviewHead: remoteHead };
2826
+ if (phase.findingsIncomplete !== undefined) return endIncompleteFindings(reconciled);
2827
+ const costEnding = recoveryCostEnding(reconciled);
2828
+ if (costEnding !== undefined) return end(reconciled, costEnding, [roundNote(round, "aborted")]);
2791
2829
  return roundOnOpenPr(withPr, phase, pr);
2792
2830
  }
2793
2831
  // The merge landed during the round: the child found nothing left to ship
@@ -0,0 +1,49 @@
1
+ /** Permissions for the trusted Git door's receive-pack operation. GitHub
2
+ * also requires Workflows when a pushed commit edits .github/workflows. */
3
+ export const DOOR_PUSH_PERMISSIONS = {
4
+ contents: "write",
5
+ workflows: "write",
6
+ metadata: "read",
7
+ } as const;
8
+
9
+ /** The resident's root-owned token only clones/fetches its mirror and reads
10
+ * pull request state for worktree reclamation. */
11
+ export const RESIDENT_MIRROR_PERMISSIONS = {
12
+ contents: "read",
13
+ pull_requests: "read",
14
+ metadata: "read",
15
+ } as const;
16
+
17
+ type Permission = "read" | "write";
18
+
19
+ function record(value: unknown): value is Record<string, unknown> {
20
+ return value !== null && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+
23
+ /** Check GitHub's effective scope before a token enters either cache. GitHub
24
+ * may omit its implicit Metadata read grant from the response. Errors contain
25
+ * only a fixed reason; they never serialize a response or bearer. */
26
+ export function verifyGithubMintScope(
27
+ response: unknown,
28
+ repo: string,
29
+ requested: Readonly<Record<string, Permission>>,
30
+ ): void {
31
+ if (!record(response) || !record(response.permissions)) throw new Error("GitHub token scope mismatch");
32
+ const actual = response.permissions;
33
+ if (
34
+ Object.entries(actual).some(([name, value]) => requested[name] !== value) ||
35
+ Object.keys(requested).some((name) => !(name in actual) && !(name === "metadata" && requested[name] === "read"))
36
+ ) {
37
+ throw new Error("GitHub token scope mismatch");
38
+ }
39
+ if (
40
+ response.repository_selection !== "selected" ||
41
+ !Array.isArray(response.repositories) ||
42
+ response.repositories.length !== 1 ||
43
+ !record(response.repositories[0]) ||
44
+ typeof response.repositories[0].full_name !== "string" ||
45
+ response.repositories[0].full_name.toLowerCase() !== repo.toLowerCase()
46
+ ) {
47
+ throw new Error("GitHub token scope mismatch");
48
+ }
49
+ }
@@ -1,45 +1,17 @@
1
- /** The per-exec credential-refresh decision of the execution layer, kept pure
2
- * and dependency-free so it is unit-testable from src/ and imported across
3
- * packages by the Worker (like residentDetach/shellQuote) — the tested code
4
- * IS the shipped code. Two callers share it — the ONE credential refresher:
5
- * the resident Worker before every writable `/exec`
6
- * (deploy/cloudflare-resident/worker.ts `execThreadImpl`), and the sandbox
7
- * executor before every `/exec` it sends
8
- * (src/execution/sandboxCredentials.ts, issue 1915).
9
- *
10
- * Background: attach writes a 1-hour GitHub App installation token
11
- * into `<worktree>/.git/github-credentials` and points git's `store` helper
12
- * at it. Nothing used to refresh it during a run, so a push more than ~60
13
- * minutes after attach got 401 — and git's `store` helper ERASES a rejected
14
- * credential from its file, leaving the agent staring at an empty file. The
15
- * Worker now asks this function before every writable `/exec` and re-mints
16
- * (cached per slug) + rewrites the file when it says so.
17
- *
18
- * The refresh is driven by the TOKEN'S OWN EXPIRY, not the file's age.
19
- * `mintRepoScopedToken` caches per repository slug, so a token minted for an
20
- * earlier thread — with only a few minutes of life left — could be written
21
- * for a brand-new attach and, keyed on file age alone, read "fresh" for ~45
22
- * minutes while every writable exec in between ran on a dead token (the first
23
- * git write 401s, `store` blanks the file, and only the NEXT exec refreshed).
24
- * Now the binding records the token's `expiresAtMs`, and this predicate
25
- * refreshes once the token is within `CREDENTIAL_EXPIRY_MARGIN_MS` of expiry
26
- * — so a writable exec never starts on a token that cannot outlive it. */
1
+ /** The pure expiry decision retained for the optional legacy sandbox
2
+ * credential refresher. The production executor factory does not supply
3
+ * that credential source: model workspaces use a revocable Git-door run
4
+ * bearer, and the resident never refreshes a model-visible App token.
5
+ * The resident imports only the expiry margin for its root-owned mirror
6
+ * credential cache. */
27
7
 
28
8
  import { BASH_TIMEOUT_MAX_MS } from "./bashTimeout.js";
29
9
 
30
- /** Backstop for bindings that predate `tokenExpiresAtMs`: re-mint
31
- * this long after the file was written when the token's real expiry is
32
- * unknown — comfortably before the 60-minute token expiry. New bindings carry
33
- * the expiry and take the margin path below instead. */
10
+ /** Legacy refresher backstop when the stored token's expiry is unknown. */
34
11
  export const CREDENTIAL_REFRESH_AFTER_MS = 45 * 60_000;
35
12
 
36
- /** Refresh once the token is within this much of expiry, so a writable exec
37
- * never starts on a token that cannot outlive the command it is about to run.
38
- * Sized like the sandbox's per-command credential margin: the longest
39
- * single exec is `BASH_TIMEOUT_MAX_MS` (20 min), plus slack for the mint +
40
- * file write and clock skew ⇒ 25 min. This is also `mintRepoScopedToken`'s
41
- * cache serve threshold, so the cache and this predicate agree: a token the
42
- * cache would still hand out is a token this predicate would not refresh. */
13
+ /** Legacy refresh and root-mirror cache margin: the longest exec is
14
+ * `BASH_TIMEOUT_MAX_MS` (20 min), plus five minutes for mint and clock skew. */
43
15
  export const CREDENTIAL_EXPIRY_MARGIN_MS = BASH_TIMEOUT_MAX_MS + 5 * 60_000;
44
16
 
45
17
  export type CredentialRefreshReason =
@@ -0,0 +1,12 @@
1
+ /** Persist uncertainty before asking the platform to destroy a resident VM.
2
+ * A rejected or interrupted destroy leaves the fence in storage: callers may
3
+ * retry destruction, but may not provision or assign a pool user on the old
4
+ * disk. A failed clear is conservative too, even if the VM was destroyed. */
5
+ export async function destroyWithPersistentFence(
6
+ fence: { mark(): Promise<void>; clear(): Promise<void> },
7
+ destroy: () => Promise<void>,
8
+ ): Promise<void> {
9
+ await fence.mark();
10
+ await destroy();
11
+ await fence.clear();
12
+ }
@@ -0,0 +1,96 @@
1
+ /** Durable UID ownership within one VM generation. Missing or malformed
2
+ * records are unknown generations, never an empty pool. */
3
+ export interface SpentPoolUser {
4
+ user: string;
5
+ owner: string;
6
+ }
7
+
8
+ export function parseSpentPoolUsers(value: unknown, pool: readonly string[]): ReadonlyMap<string, string> | null {
9
+ if (
10
+ !Array.isArray(value) ||
11
+ !value.every(
12
+ (row): row is SpentPoolUser =>
13
+ row !== null &&
14
+ typeof row === "object" &&
15
+ "user" in row &&
16
+ "owner" in row &&
17
+ typeof row.user === "string" &&
18
+ typeof row.owner === "string" &&
19
+ /^(thread|op):\S+$/.test(row.owner),
20
+ )
21
+ )
22
+ return null;
23
+ const spent = new Map(value.map(({ user, owner }) => [user, owner]));
24
+ if (spent.size !== value.length || [...spent.keys()].some((user) => !pool.includes(user))) return null;
25
+ return spent;
26
+ }
27
+
28
+ /** A new owner must get an unused UID; only the exact recorded owner may
29
+ * reclaim its UID. Both paths record ownership before untrusted work. */
30
+ export function spendPoolUser(
31
+ value: unknown,
32
+ pool: readonly string[],
33
+ user: string,
34
+ owner: string,
35
+ ): SpentPoolUser[] | null {
36
+ const spent = parseSpentPoolUsers(value, pool);
37
+ if (!spent || !pool.includes(user) || !/^(thread|op):\S+$/.test(owner)) return null;
38
+ const prior = spent.get(user);
39
+ if (prior && prior !== owner) return null;
40
+ const rows = [...spent].map(([user, owner]) => ({ user, owner }));
41
+ return prior ? rows : [...rows, { user, owner }];
42
+ }
43
+
44
+ /** A UID-scoped command must have exactly one live owner. An old duplicate
45
+ * binding never gains authority merely because its UID is spent. */
46
+ export function mayRunAsPoolUser(
47
+ value: unknown,
48
+ pool: readonly string[],
49
+ user: string,
50
+ liveThreadKeys: readonly string[],
51
+ activeOpOwner: string | undefined,
52
+ expectedOwner?: string,
53
+ ): boolean {
54
+ const recorded = parseSpentPoolUsers(value, pool)?.get(user);
55
+ if (!recorded || (expectedOwner !== undefined && recorded !== expectedOwner)) return false;
56
+ if (activeOpOwner !== undefined) return liveThreadKeys.length === 0 && recorded === activeOpOwner;
57
+ return liveThreadKeys.length === 1 && recorded === `thread:${liveThreadKeys[0]}`;
58
+ }
59
+
60
+ /** One row per UID is rebuilt from primary bindings when a DO starts. A
61
+ * malformed or missing row cannot authorize a shell, even if the spend ledger
62
+ * names the caller. Keep every live claimant so duplicates remain visible. */
63
+ export function parsePoolBindings(value: unknown): string[] | null {
64
+ if (!Array.isArray(value) || !value.every((key): key is string => typeof key === "string" && key.length > 0))
65
+ return null;
66
+ if (new Set(value).size !== value.length) return null;
67
+ return value;
68
+ }
69
+
70
+ export function claimPoolBinding(value: unknown, threadKey: string): string[] | null {
71
+ const current = parsePoolBindings(value);
72
+ if (!current || !threadKey || current.includes(threadKey)) return null;
73
+ return [...current, threadKey];
74
+ }
75
+
76
+ export function releasePoolBinding(value: unknown, threadKey: string): string[] | null {
77
+ const current = parsePoolBindings(value);
78
+ if (!current || !threadKey || !current.includes(threadKey)) return null;
79
+ return current.filter((key) => key !== threadKey);
80
+ }
81
+
82
+ export function rebuildPoolBindingIndex(
83
+ bindings: Iterable<{ threadKey: string; user: string; evicted?: boolean }>,
84
+ pool: readonly string[],
85
+ ): Map<string, string[]> | null {
86
+ const byUser = new Map(pool.map((user) => [user, [] as string[]]));
87
+ for (const binding of bindings) {
88
+ if (binding?.evicted === true) continue;
89
+ if (!binding || typeof binding.threadKey !== "string" || !binding.threadKey) return null;
90
+ if (!binding.user) continue;
91
+ const keys = byUser.get(binding.user);
92
+ if (!keys || keys.includes(binding.threadKey)) return null;
93
+ keys.push(binding.threadKey);
94
+ }
95
+ return byUser;
96
+ }
@@ -0,0 +1,41 @@
1
+ /** An operator recreate closes new admission before its idle read and keeps a
2
+ * durable refusal across the container destroy. A new DO isolate may reclaim
3
+ * the mark only by re-running the operator's checked recreate. */
4
+ export class ResidentRecreateAdmission {
5
+ private claiming = false;
6
+
7
+ constructor(
8
+ private readonly store: {
9
+ mark(): Promise<void>;
10
+ clear(): Promise<void>;
11
+ held(): Promise<boolean>;
12
+ },
13
+ ) {}
14
+
15
+ get pending(): boolean {
16
+ return this.claiming;
17
+ }
18
+
19
+ async blocked(): Promise<boolean> {
20
+ return this.claiming || (await this.store.held());
21
+ }
22
+
23
+ async run<T>(action: () => Promise<T>): Promise<{ busy: true } | { busy: false; value: T }> {
24
+ if (this.claiming) return { busy: true };
25
+ this.claiming = true; // no await before a concurrent admission sees this
26
+ let marked = false;
27
+ try {
28
+ await this.store.mark();
29
+ marked = true;
30
+ return { busy: false, value: await action() };
31
+ } finally {
32
+ try {
33
+ // A failed mark may already have persisted. Leave it fail-closed for
34
+ // an explicit checked retry rather than guessing that it did not.
35
+ if (marked) await this.store.clear();
36
+ } finally {
37
+ this.claiming = false;
38
+ }
39
+ }
40
+ }
41
+ }
@@ -29,6 +29,7 @@ export const RESIDENT_STEP_LABELS = {
29
29
  git: "a git command",
30
30
  "git-setup": "configuring git",
31
31
  "stage-perms": "securing the credential stage",
32
+ "stage-write-cleanup": "removing a failed staged write",
32
33
  install: "installing dependencies",
33
34
  build: "building",
34
35
  test: "running the tests",