@coreplane/switchboard 1.235.0 → 1.237.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/dist/assets/Dockerfile +11 -0
  2. package/dist/assets/config/config.example.yaml +9 -4
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +12 -0
  4. package/dist/assets/deploy/cloudflare-resident/Dockerfile +18 -0
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +290 -118
  6. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +11 -0
  7. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +104 -17
  8. package/dist/assets/package-lock.json +212 -3
  9. package/dist/assets/package.json +4 -1
  10. package/dist/assets/source.json +3 -3
  11. package/dist/assets/src/core/authz/authorize.ts +25 -6
  12. package/dist/assets/src/core/authz/policy.ts +9 -0
  13. package/dist/assets/src/core/authz/types.ts +15 -1
  14. package/dist/assets/src/core/coordinator/contract.ts +24 -4
  15. package/dist/assets/src/core/coordinator/driver.ts +36 -4
  16. package/dist/assets/src/core/runEvents.ts +46 -7
  17. package/dist/assets/src/core/runFriction.ts +1 -0
  18. package/dist/assets/src/core/ship/contract.ts +3 -1
  19. package/dist/assets/src/core/ship/coordinator.ts +236 -30
  20. package/dist/assets/src/core/trace/types.ts +5 -0
  21. package/dist/assets/src/execution/residentDepsStore.ts +14 -2
  22. package/dist/assets/src/execution/residentDisk.ts +21 -10
  23. package/dist/assets/src/execution/residentDiskBudget.ts +45 -11
  24. package/dist/assets/src/execution/residentHead.ts +34 -8
  25. package/dist/assets/src/execution/residentRebind.ts +88 -31
  26. package/dist/assets/src/execution/sandboxErrors.ts +72 -0
  27. package/dist/assets/src/execution/sandboxLifecycle.ts +33 -0
  28. package/dist/assets/src/execution/sandboxStart.ts +90 -0
  29. package/dist/assets/web/dist/.vite/manifest.json +437 -423
  30. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +1 -0
  31. package/dist/assets/web/dist/assets/{CostsPage-D-v8am88.js → CostsPage-DKk9OjB0.js} +1 -1
  32. package/dist/assets/web/dist/assets/{DeliveryPage-CvlWP7Eq.js → DeliveryPage-Kz6js49i.js} +1 -1
  33. package/dist/assets/web/dist/assets/{NotFoundPage-BzWS4Ca1.js → NotFoundPage-BXLrQQzy.js} +1 -1
  34. package/dist/assets/web/dist/assets/ResidentDetailPage-Dc23v_8v.js +1 -0
  35. package/dist/assets/web/dist/assets/ResidentsIndexPage-TR5D1TRR.js +1 -0
  36. package/dist/assets/web/dist/assets/RunFoldRow-mkUj7WEN.js +9 -0
  37. package/dist/assets/web/dist/assets/{RunRoutePage-oN9GkVEA.js → RunRoutePage-CQI17__g.js} +3 -3
  38. package/dist/assets/web/dist/assets/RunsIndexPage-yIP4PlKP.js +1 -0
  39. package/dist/assets/web/dist/assets/{RunsTabs-DEICQ4FY.js → RunsTabs-XpCqeWu4.js} +1 -1
  40. package/dist/assets/web/dist/assets/ScheduledPage-CdCVyNbh.js +1 -0
  41. package/dist/assets/web/dist/assets/SettingsPage-DuA8JpLU.js +1 -0
  42. package/dist/assets/web/dist/assets/{StatusDot-D14iJMy7.js → StatusDot-DmNHX6am.js} +1 -1
  43. package/dist/assets/web/dist/assets/{Tooltip-B0Ob5MQ4.js → Tooltip-qVnJTgSt.js} +1 -1
  44. package/dist/assets/web/dist/assets/UnitRoutePage-BcTeH7m2.js +1 -0
  45. package/dist/assets/web/dist/assets/{angular-html-Cw130Zyi.js → angular-html-DYtV9uIN.js} +1 -1
  46. package/dist/assets/web/dist/assets/{angular-ts-DY1m4C2s.js → angular-ts-DF2P25Zp.js} +1 -1
  47. package/dist/assets/web/dist/assets/{apl-W6Ty7v05.js → apl-Ff96wxko.js} +1 -1
  48. package/dist/assets/web/dist/assets/{astro-DlLVHwVk.js → astro-LbBnZna-.js} +1 -1
  49. package/dist/assets/web/dist/assets/{blade-Bco3tDh4.js → blade-DltuxeBW.js} +1 -1
  50. package/dist/assets/web/dist/assets/{c-1LaFY_cj.js → c-DBg7yuPx.js} +1 -1
  51. package/dist/assets/web/dist/assets/{chapel-B-OvrOL5.js → chapel-C83hUAds.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cobol-Ch3EPlHu.js → cobol-DB0yV_2D.js} +1 -1
  53. package/dist/assets/web/dist/assets/{coffee-hZuITnto.js → coffee-Bpih6Kzl.js} +1 -1
  54. package/dist/assets/web/dist/assets/{cpp-B8iRVo8m.js → cpp-CU00ZeRn.js} +1 -1
  55. package/dist/assets/web/dist/assets/{crystal-DKveI7lr.js → crystal-f8ImJkux.js} +1 -1
  56. package/dist/assets/web/dist/assets/{css-_2_CD-rr.js → css-4VrC4ISo.js} +1 -1
  57. package/dist/assets/web/dist/assets/{dist-pOdUzj6D.js → dist-TAGawIfM.js} +2 -2
  58. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +1 -0
  59. package/dist/assets/web/dist/assets/{edge-BenMDPct.js → edge-BwFDzfS_.js} +1 -1
  60. package/dist/assets/web/dist/assets/{elixir-CBbdEqG6.js → elixir-wwIYIpJm.js} +1 -1
  61. package/dist/assets/web/dist/assets/{elm-DMub_2lY.js → elm-DyDuM3v3.js} +1 -1
  62. package/dist/assets/web/dist/assets/{erb-CI9He_Up.js → erb-DFJBEVrh.js} +1 -1
  63. package/dist/assets/web/dist/assets/{favicon-BQsePYv5.js → favicon-CSt-qDvc.js} +1 -1
  64. package/dist/assets/web/dist/assets/{git-rebase-BBfhnyF8.js → git-rebase-CuDv-44X.js} +1 -1
  65. package/dist/assets/web/dist/assets/{glimmer-js-CR9Fxrau.js → glimmer-js-ZFnrm4J3.js} +1 -1
  66. package/dist/assets/web/dist/assets/{glimmer-ts-CwFwX_XS.js → glimmer-ts-XY4Yqshp.js} +1 -1
  67. package/dist/assets/web/dist/assets/{glsl-Daiodp1r.js → glsl-xnR8efx0.js} +1 -1
  68. package/dist/assets/web/dist/assets/{graphql-DZvrsEkB.js → graphql-D-gO5tnP.js} +1 -1
  69. package/dist/assets/web/dist/assets/{hack-BaXOzdg7.js → hack-CSxbW4ax.js} +1 -1
  70. package/dist/assets/web/dist/assets/{haml-CMskq_VA.js → haml-D4ar42QT.js} +1 -1
  71. package/dist/assets/web/dist/assets/{handlebars-Dhw_f7jD.js → handlebars-CMLbDE7o.js} +1 -1
  72. package/dist/assets/web/dist/assets/{html-C9k99z0z.js → html-CVekU4Fs.js} +1 -1
  73. package/dist/assets/web/dist/assets/{html-derivative-VckPItXN.js → html-derivative-St3HHw_N.js} +1 -1
  74. package/dist/assets/web/dist/assets/{http-DXU_3h9v.js → http-Mon0DWHS.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hurl-KU24JNdp.js → hurl-Cbl5IvBs.js} +1 -1
  76. package/dist/assets/web/dist/assets/indexRow-FLJu5mR9.js +1 -0
  77. package/dist/assets/web/dist/assets/{java-BbZjTNgf.js → java-Bprck1l-.js} +1 -1
  78. package/dist/assets/web/dist/assets/{javascript-DXCdqcZZ.js → javascript-C73G1-UR.js} +1 -1
  79. package/dist/assets/web/dist/assets/{jinja-pDlsjTVp.js → jinja-C4DuRxYJ.js} +1 -1
  80. package/dist/assets/web/dist/assets/{jison-DsXDk66e.js → jison-B2U03GEC.js} +1 -1
  81. package/dist/assets/web/dist/assets/{json-tCxXfRgG.js → json-CvTZUDzx.js} +1 -1
  82. package/dist/assets/web/dist/assets/{jsx-B0ZYkCmi.js → jsx-DMWKCzwo.js} +1 -1
  83. package/dist/assets/web/dist/assets/{julia-BLzzeI6x.js → julia-Czn7bV_3.js} +1 -1
  84. package/dist/assets/web/dist/assets/{just-DvD2_60c.js → just-BBxpNfvC.js} +1 -1
  85. package/dist/assets/web/dist/assets/{latex-511zn3h7.js → latex-Bw9yg5mr.js} +1 -1
  86. package/dist/assets/web/dist/assets/{liquid-BD0BkMox.js → liquid-BRCjaydE.js} +1 -1
  87. package/dist/assets/web/dist/assets/{indexFormat-B-pd4r7Y.js → localIso-CNA-bhS-.js} +1 -1
  88. package/dist/assets/web/dist/assets/{lua-NFicKm6N.js → lua-DUu2KIC0.js} +1 -1
  89. package/dist/assets/web/dist/assets/main-csihqEj5.css +1 -0
  90. package/dist/assets/web/dist/assets/main-mUd_TJjH.js +28 -0
  91. package/dist/assets/web/dist/assets/{marko-1-G_nEXK.js → marko-CZJZIlSQ.js} +1 -1
  92. package/dist/assets/web/dist/assets/{mdc-DUe3AQLp.js → mdc-BmClq6fd.js} +1 -1
  93. package/dist/assets/web/dist/assets/{nginx-Dg379_bA.js → nginx-Bmt3xSEm.js} +1 -1
  94. package/dist/assets/web/dist/assets/{nim-BnFq97ZO.js → nim-Da-i0utK.js} +1 -1
  95. package/dist/assets/web/dist/assets/{org-F0uiwGvq.js → org-BvAre2qP.js} +1 -1
  96. package/dist/assets/web/dist/assets/{perl-B0euczkl.js → perl-v9WwFFtB.js} +1 -1
  97. package/dist/assets/web/dist/assets/{php-DkL3k_n7.js → php-DTU4Zyze.js} +1 -1
  98. package/dist/assets/web/dist/assets/{pug-L_OjjZP4.js → pug-DhTwEqTO.js} +1 -1
  99. package/dist/assets/web/dist/assets/{qml-BMjB00Zz.js → qml-BUD9AC_c.js} +1 -1
  100. package/dist/assets/web/dist/assets/{r-DoeLdnqR.js → r-CAnu56n7.js} +1 -1
  101. package/dist/assets/web/dist/assets/{razor-BzYNkAWy.js → razor-Dn0P4gAq.js} +1 -1
  102. package/dist/assets/web/dist/assets/{regexp-CPmElMk3.js → regexp-CZyBRaVW.js} +1 -1
  103. package/dist/assets/web/dist/assets/residentsModel-By1fThvk.js +1 -0
  104. package/dist/assets/web/dist/assets/{rst-DrsjgfI7.js → rst-Ckyd5UsY.js} +1 -1
  105. package/dist/assets/web/dist/assets/{ruby-cB42ppvx.js → ruby-BBQ62fsx.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sas-R5Y3NM_I.js → sas-CW35hMxG.js} +1 -1
  107. package/dist/assets/web/dist/assets/{scss-C-zWxV9s.js → scss-CpV8pF4J.js} +1 -1
  108. package/dist/assets/web/dist/assets/{shellscript-oJF96aAU.js → shellscript-CRJvsmE1.js} +1 -1
  109. package/dist/assets/web/dist/assets/{shellsession-CznECKyi.js → shellsession-CyriUZxf.js} +1 -1
  110. package/dist/assets/web/dist/assets/{soy-wjHLSRag.js → soy-D5QzYUeg.js} +1 -1
  111. package/dist/assets/web/dist/assets/{sql-C_BM8IOW.js → sql-CUBf_Mse.js} +1 -1
  112. package/dist/assets/web/dist/assets/{stata-D31HO8aQ.js → stata-B-4n4ITz.js} +1 -1
  113. package/dist/assets/web/dist/assets/{surrealql-CKCLyplA.js → surrealql-RuWZBLtr.js} +1 -1
  114. package/dist/assets/web/dist/assets/{svelte-3geWk2iu.js → svelte-CO5YzCsK.js} +1 -1
  115. package/dist/assets/web/dist/assets/{templ-DiJr_hTD.js → templ-DbkWVaBF.js} +1 -1
  116. package/dist/assets/web/dist/assets/{tex-BEJGWyeF.js → tex-IuxoEcId.js} +1 -1
  117. package/dist/assets/web/dist/assets/{ts-tags-C8Mzdpxx.js → ts-tags-g6dGQPjd.js} +1 -1
  118. package/dist/assets/web/dist/assets/{tsx-UQiSm_p3.js → tsx-WnXwiyH2.js} +1 -1
  119. package/dist/assets/web/dist/assets/{twig-_Pvzkeo9.js → twig-Lx1G7PnY.js} +1 -1
  120. package/dist/assets/web/dist/assets/{typescript-BWMkINKK.js → typescript-BQd7biLF.js} +1 -1
  121. package/dist/assets/web/dist/assets/{typst-Bdg9m-e7.js → typst-D2lXPHK7.js} +1 -1
  122. package/dist/assets/web/dist/assets/{vue-CDZrrAZ8.js → vue-4wVQD9y9.js} +1 -1
  123. package/dist/assets/web/dist/assets/{vue-html-tAlpNhfN.js → vue-html-DZPaRte4.js} +1 -1
  124. package/dist/assets/web/dist/assets/{vue-vine-C27UF_rh.js → vue-vine-DbmZCmu-.js} +1 -1
  125. package/dist/assets/web/dist/assets/{xml-CxeDr9Zh.js → xml-DV16szyo.js} +1 -1
  126. package/dist/assets/web/dist/assets/{xsl-Bpi0uUnM.js → xsl-eo5-1sk0.js} +1 -1
  127. package/dist/assets/web/dist/assets/{yaml-BtUgZ1GN.js → yaml-BXdX8HZf.js} +1 -1
  128. package/dist/cli.js +3329 -1804
  129. package/package.json +1 -1
  130. package/dist/assets/web/dist/assets/AppShell-lYVcj-k7.js +0 -1
  131. package/dist/assets/web/dist/assets/ResidentDetailPage-DSpsye5e.js +0 -1
  132. package/dist/assets/web/dist/assets/ResidentsIndexPage-CT8fI1M3.js +0 -1
  133. package/dist/assets/web/dist/assets/RunFoldRow-Cqh8pTXC.js +0 -9
  134. package/dist/assets/web/dist/assets/RunsIndexPage-sjXr8iCV.js +0 -1
  135. package/dist/assets/web/dist/assets/ScheduledPage-Tz__zLzi.js +0 -1
  136. package/dist/assets/web/dist/assets/UnitRoutePage-CbKCL58v.js +0 -1
  137. package/dist/assets/web/dist/assets/indexRow-Bnj883ii.js +0 -1
  138. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +0 -1
  139. package/dist/assets/web/dist/assets/main-D3lG-yQ9.js +0 -28
  140. package/dist/assets/web/dist/assets/main-Dm11o0hc.css +0 -1
  141. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +0 -1
@@ -176,6 +176,17 @@ RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 \
176
176
  && pi --version | grep -qx '0.85.1' \
177
177
  && pi --help | grep -q -- '--mode <mode>'
178
178
 
179
+ # OpenCode, the second harness (docs/reference/specs/harness.md): `@opencode/cli`
180
+ # at the EXACT pin held by src/deploy/imageOpenCodeHarness.test.ts, beside pi.
181
+ # The wrapper's postinstall hard-links the platform binary into place, so it
182
+ # is the one install script allowed by name; the grep proves the binary on
183
+ # PATH prints the pin's version text (`opencode v2.0.3`, exactly); the npm
184
+ # cache (a 200 MB tarball) is dropped in the same layer. Dark until a
185
+ # deployment names it: nothing here starts it on its own.
186
+ RUN npm install -g --allow-scripts=@opencode/cli @opencode/cli@2.0.3 \
187
+ && npm cache clean --force \
188
+ && opencode --version | grep -qx 'opencode v2.0.3'
189
+
179
190
  # The container outlives a crash of the SDK's container server (the
180
191
  # supervisor's header says why). The base image's ENTRYPOINT is tini running
181
192
  # the server, so one uncaught exception there ended the container; here tini
@@ -41,9 +41,13 @@ import {
41
41
  SANDBOX_SLEEP_AFTER,
42
42
  isRecycleError,
43
43
  recycledMidCommandMessage,
44
+ DETACH_HINT,
45
+ OUTPUT_AFTER_EXIT_MS,
46
+ heldOutputNote,
44
47
  } from "../../src/execution/sandboxLifecycle.js";
45
48
  import { envFromRequest } from "../../src/execution/sandboxEnv.js";
46
49
  import { IdleGuard, type IdleGuardHost } from "../../src/execution/sandboxIdle.js";
50
+ import { StartGate, type StartGateHost, type StartingCause } from "../../src/execution/sandboxStart.js";
47
51
  import { RUNTIME_REPLACEMENT_WORDING, isRuntimeUnreachableSignal } from "../../src/execution/residentRefresh.js";
48
52
  import {
49
53
  fleetBusyAnswer,
@@ -53,6 +57,8 @@ import {
53
57
  runtimeUnreachableAnswer,
54
58
  runtimeUnreachableExecAnswer,
55
59
  SandboxRuntimeUnreachableError,
60
+ sandboxStartingAnswer,
61
+ sandboxStartingExecAnswer,
56
62
  thrownShape,
57
63
  thrownText,
58
64
  } from "../../src/execution/sandboxErrors.js";
@@ -85,10 +91,17 @@ const EXEC_TIMEOUT_SECS = 280;
85
91
  * the shell-level 124 is always the deadline a command meets first. */
86
92
  const SDK_BACKSTOP_MARGIN_MS = 40_000;
87
93
 
88
- /** How long the Durable Object waits for the process's exit past the SDK's
89
- * own limit before it kills the process itself and reports the timeout: the
90
- * runtime should have ended it at the limit; a starved container ends it late. */
91
- const OUTPUT_WAIT_MARGIN_MS = 30_000;
94
+ /** How long the Durable Object waits for the process's output to END past
95
+ * the command's own deadline before it asks the runtime what became of the
96
+ * process (docs/reference/specs/execution.md item 24). The runtime ends the
97
+ * output stream only when every holder of the command's stdout and stderr is
98
+ * gone; a process that exited while a detached child holds them is answered
99
+ * with its exit code and a note, a process still running is killed and
100
+ * answered as the shell-level timeout. The wait is `OUTPUT_AFTER_EXIT_MS`
101
+ * (20 s), inside the executor's per-send margin, so this answer always
102
+ * reaches the bot before it gives the command up — the SDK's own limit
103
+ * (`SDK_BACKSTOP_MARGIN_MS`) stays above it as the last resort. */
104
+ const OUTPUT_WAIT_AFTER_DEADLINE_MS = OUTPUT_AFTER_EXIT_MS;
92
105
 
93
106
  /** The interruption reasons that mean the container's runtime is not the one
94
107
  * the command started on: the process, if it started, is gone with its
@@ -167,6 +180,12 @@ interface FileRefusal {
167
180
  reason?: string;
168
181
  }
169
182
 
183
+ /** The SDK's own limit on the warm-up command (`true`): generous, since the
184
+ * SDK's start — an instance grant up to 30 s, the runtime's port up to 90 s
185
+ * by its defaults — happens inside this one call. The executor's wait for
186
+ * the `sandbox-starting` token is bounded on its own side. */
187
+ const WARM_UP_BACKSTOP_MS = 4 * 60_000;
188
+
170
189
  /** Durable Object storage key of the idle ledger's served-time. */
171
190
  const IDLE_LEDGER_KEY = "switchboard.idle.lastServedAt";
172
191
  /** The scheduled-callback name of the idle sweep (a method below). */
@@ -185,10 +204,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
185
204
  sleepAfter = SANDBOX_SLEEP_AFTER;
186
205
 
187
206
  private readonly idle: IdleGuard;
207
+ private readonly gate: StartGate;
188
208
 
189
209
  constructor(...args: ConstructorParameters<typeof Sandbox<Env>>) {
190
210
  super(...args);
191
211
  this.idle = new IdleGuard(this.idleHost());
212
+ this.gate = new StartGate(this.startHost());
192
213
  // Every wake, including the one the SDK's own alarm causes on a leaked
193
214
  // container: the baseline is read and a sweep armed before any request.
194
215
  this.ctx.blockConcurrencyWhile(() => this.idle.wake());
@@ -215,6 +236,25 @@ export class SwitchboardSandbox extends Sandbox<Env> {
215
236
  };
216
237
  }
217
238
 
239
+ /** The start gate's view of this object (docs/reference/specs/execution.md
240
+ * item 23): the platform's running flag, and a warm-up that is one trivial
241
+ * command through the SDK — which does the start (instance grant, image
242
+ * pull, boot, the runtime's port) and answers when the runtime does. The
243
+ * warm-up runs inside the idle ledger so the guard sees a request in flight
244
+ * for as long as the start takes. */
245
+ private startHost(): StartGateHost {
246
+ return {
247
+ now: systemClock,
248
+ containerRunning: () => this.ctx.container?.running,
249
+ warmUp: () =>
250
+ this.idle.served(async () => {
251
+ const proc = await createExtensionProcessSandbox(this).exec(["true"], { timeout: WARM_UP_BACKSTOP_MS });
252
+ await proc.output({ encoding: "utf8", timeout: WARM_UP_BACKSTOP_MS });
253
+ }),
254
+ log: (event) => console.log(JSON.stringify({ ...event, thread: this.ctx.id.name ?? this.ctx.id.toString() })),
255
+ };
256
+ }
257
+
218
258
  /** The idle sweep, scheduled by the guard through the SDK's schedule table
219
259
  * and re-armed by it while a container is running. */
220
260
  async idleSweep(): Promise<void> {
@@ -240,7 +280,19 @@ export class SwitchboardSandbox extends Sandbox<Env> {
240
280
  execTimeoutSecs: number,
241
281
  envVars: Record<string, string>,
242
282
  ): Promise<ExecAnswer | ExecFailure> {
243
- return this.idle.served(() => this.execute(command, execTimeoutSecs, envVars));
283
+ const startedAt = systemClock();
284
+ return this.idle.served(async () => {
285
+ try {
286
+ return await this.gate.through(
287
+ () => this.execute(command, execTimeoutSecs, envVars),
288
+ (cause) => sandboxStartingExecAnswer(cause),
289
+ );
290
+ } catch (err) {
291
+ // The warm-up's own failure, handed on by the gate: a full fleet or a
292
+ // silent control port keeps its name; anything else propagates.
293
+ return this.execFailure(err, startedAt);
294
+ }
295
+ });
244
296
  }
245
297
 
246
298
  /** `runCommand` without the ledger entry: the body, and the internal caller
@@ -261,7 +313,10 @@ export class SwitchboardSandbox extends Sandbox<Env> {
261
313
  return this.execFailure(err, startedAt);
262
314
  }
263
315
  try {
264
- const out = await proc.output({ encoding: "utf8", timeout: backstopMs + OUTPUT_WAIT_MARGIN_MS });
316
+ const out = await proc.output({
317
+ encoding: "utf8",
318
+ timeout: execTimeoutSecs * 1000 + OUTPUT_WAIT_AFTER_DEADLINE_MS,
319
+ });
265
320
  // coreutils `timeout` exits 124 when the deadline killed the command
266
321
  // (137 when the follow-up SIGKILL had to); the SDK's own limit, if it
267
322
  // ever wins, reports `timedOut`. All three are the one story.
@@ -279,9 +334,21 @@ export class SwitchboardSandbox extends Sandbox<Env> {
279
334
  };
280
335
  } catch (err) {
281
336
  if (err instanceof ProcessWaitTimeoutError) {
282
- // The runtime should have ended the process at the SDK's limit and
283
- // did not; abandoning a live process would leave it running in the
284
- // workspace. End it, then report the shell-level timeout it exceeded.
337
+ // The output never ended inside the command's deadline plus the
338
+ // margin. Two stories, told apart by asking the runtime (item 24):
339
+ // the process EXITED and a detached child holds its output open —
340
+ // the answer is its exit code and the note, the child runs on; or
341
+ // the process is still RUNNING past its own `timeout` — end it, and
342
+ // report the shell-level timeout it exceeded.
343
+ const status = await proc.status().catch(() => null);
344
+ if (status?.state === "exited") {
345
+ return {
346
+ stdout: "",
347
+ stderr: heldOutputNote(status.exit.code),
348
+ exitCode: status.exit.code,
349
+ durationMs: systemClock() - startedAt,
350
+ };
351
+ }
285
352
  await proc.kill(9).catch(() => {});
286
353
  return {
287
354
  stdout: "",
@@ -337,9 +404,19 @@ export class SwitchboardSandbox extends Sandbox<Env> {
337
404
  }
338
405
  }
339
406
 
407
+ /** A file route's answer while the container starts: HTTP 503 with the
408
+ * token, like a full fleet's. */
409
+ private startingRefusal(cause: StartingCause): FileRefusal {
410
+ const { error, reason } = sandboxStartingAnswer(cause);
411
+ return { error, status: 503, reason };
412
+ }
413
+
340
414
  async readText(path: string): Promise<{ content: string } | FileRefusal> {
341
415
  return this.idle.served(() =>
342
- this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content })),
416
+ this.gate.through(
417
+ () => this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content })),
418
+ (cause) => this.startingRefusal(cause),
419
+ ),
343
420
  );
344
421
  }
345
422
 
@@ -348,7 +425,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
348
425
  * decoded bytes to it — an SDK read that came back short would otherwise
349
426
  * pass as the file. A file over the cap is refused by name inside a 200. */
350
427
  async readBase64(path: string): Promise<Base64ReadAnswer | FileRefusal> {
351
- return this.idle.served(() => this.readBase64Now(path));
428
+ return this.idle.served(() =>
429
+ this.gate.through(
430
+ () => this.readBase64Now(path),
431
+ (cause) => this.startingRefusal(cause),
432
+ ),
433
+ );
352
434
  }
353
435
 
354
436
  private async readBase64Now(path: string): Promise<Base64ReadAnswer | FileRefusal> {
@@ -377,10 +459,14 @@ export class SwitchboardSandbox extends Sandbox<Env> {
377
459
 
378
460
  async write(path: string, content: string): Promise<{ ok: true } | FileRefusal> {
379
461
  return this.idle.served(() =>
380
- this.fileOp(async () => {
381
- await this.writeFile(path, content);
382
- return { ok: true as const };
383
- }),
462
+ this.gate.through(
463
+ () =>
464
+ this.fileOp(async () => {
465
+ await this.writeFile(path, content);
466
+ return { ok: true as const };
467
+ }),
468
+ (cause) => this.startingRefusal(cause),
469
+ ),
384
470
  );
385
471
  }
386
472
  }
@@ -388,11 +474,12 @@ export class SwitchboardSandbox extends Sandbox<Env> {
388
474
  /** The stderr line an exit 124 carries: the limit, the knob, and the way to
389
475
  * outlive a command (`setsid -f`: every /exec runs under `timeout … bash -c`,
390
476
  * whose process group is reaped when the command returns, so a plain
391
- * background job dies with it). */
477
+ * background job dies with it — and with the wrapper's stdio redirected, so
478
+ * the command's output ends when the command does, item 24). */
392
479
  function timeoutNote(execTimeoutSecs: number): string {
393
480
  return (
394
481
  `command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
395
- "re-run as smaller/faster steps, or start it detached with `setsid -f sh -c '<command> > /tmp/job.log 2>&1'` and poll the log on later calls"
482
+ `re-run as smaller/faster steps, or start it detached with \`${DETACH_HINT}\` and poll the log on later calls`
396
483
  );
397
484
  }
398
485
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.235.0",
3
+ "version": "1.237.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.235.0",
9
+ "version": "1.237.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -34,7 +34,10 @@
34
34
  "devDependencies": {
35
35
  "@earendil-works/pi-coding-agent": "0.85.1",
36
36
  "@eslint/js": "^10.0.1",
37
+ "@opencode/protocol": "2.0.3",
38
+ "@opencode/schema": "2.0.3",
37
39
  "@types/node": "^24.0.0",
40
+ "effect": "4.0.0-rc.112",
38
41
  "eslint": "^10.10.0",
39
42
  "eslint-config-prettier": "^10.1.8",
40
43
  "eslint-plugin-vue": "^10.11.0",
@@ -5863,6 +5866,90 @@
5863
5866
  "@chevrotain/types": "~11.1.2"
5864
5867
  }
5865
5868
  },
5869
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
5870
+ "version": "3.0.4",
5871
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
5872
+ "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
5873
+ "cpu": [
5874
+ "arm64"
5875
+ ],
5876
+ "dev": true,
5877
+ "license": "MIT",
5878
+ "optional": true,
5879
+ "os": [
5880
+ "darwin"
5881
+ ]
5882
+ },
5883
+ "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
5884
+ "version": "3.0.4",
5885
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
5886
+ "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
5887
+ "cpu": [
5888
+ "x64"
5889
+ ],
5890
+ "dev": true,
5891
+ "license": "MIT",
5892
+ "optional": true,
5893
+ "os": [
5894
+ "darwin"
5895
+ ]
5896
+ },
5897
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
5898
+ "version": "3.0.4",
5899
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
5900
+ "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
5901
+ "cpu": [
5902
+ "arm"
5903
+ ],
5904
+ "dev": true,
5905
+ "license": "MIT",
5906
+ "optional": true,
5907
+ "os": [
5908
+ "linux"
5909
+ ]
5910
+ },
5911
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
5912
+ "version": "3.0.4",
5913
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
5914
+ "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
5915
+ "cpu": [
5916
+ "arm64"
5917
+ ],
5918
+ "dev": true,
5919
+ "license": "MIT",
5920
+ "optional": true,
5921
+ "os": [
5922
+ "linux"
5923
+ ]
5924
+ },
5925
+ "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
5926
+ "version": "3.0.4",
5927
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
5928
+ "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
5929
+ "cpu": [
5930
+ "x64"
5931
+ ],
5932
+ "dev": true,
5933
+ "license": "MIT",
5934
+ "optional": true,
5935
+ "os": [
5936
+ "linux"
5937
+ ]
5938
+ },
5939
+ "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
5940
+ "version": "3.0.4",
5941
+ "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
5942
+ "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
5943
+ "cpu": [
5944
+ "x64"
5945
+ ],
5946
+ "dev": true,
5947
+ "license": "MIT",
5948
+ "optional": true,
5949
+ "os": [
5950
+ "win32"
5951
+ ]
5952
+ },
5866
5953
  "node_modules/@napi-rs/lzma-linux-x64-gnu": {
5867
5954
  "version": "1.5.1",
5868
5955
  "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -6155,6 +6242,28 @@
6155
6242
  "dev": true,
6156
6243
  "license": "MIT"
6157
6244
  },
6245
+ "node_modules/@opencode/protocol": {
6246
+ "version": "2.0.3",
6247
+ "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.0.3.tgz",
6248
+ "integrity": "sha512-DNHbkLDTuAyMsmVdnjuKulcfoDbllW2dxt4W19r2aJEYvswJpEO9168CSK6Sjbx1FOh/B9yL7SQgoDRMd4K5Vw==",
6249
+ "dev": true,
6250
+ "license": "MIT",
6251
+ "dependencies": {
6252
+ "@opencode/schema": "2.0.3",
6253
+ "effect": "4.0.0-rc.112"
6254
+ }
6255
+ },
6256
+ "node_modules/@opencode/schema": {
6257
+ "version": "2.0.3",
6258
+ "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.0.3.tgz",
6259
+ "integrity": "sha512-thPeBbqw4+SkZ/0LOuqfahSRGuWogRa/wwfIVyiQYsoE0vzLx8XUpHQ+FSUWkdz6llDtP0+ZsyBqXUMpXmPgbg==",
6260
+ "dev": true,
6261
+ "license": "MIT",
6262
+ "dependencies": {
6263
+ "@standard-schema/spec": "1.1.0",
6264
+ "effect": "4.0.0-rc.112"
6265
+ }
6266
+ },
6158
6267
  "node_modules/@oxc-project/types": {
6159
6268
  "version": "0.148.0",
6160
6269
  "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz",
@@ -11840,6 +11949,17 @@
11840
11949
  "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
11841
11950
  "license": "MIT"
11842
11951
  },
11952
+ "node_modules/effect": {
11953
+ "version": "4.0.0-rc.112",
11954
+ "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz",
11955
+ "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==",
11956
+ "dev": true,
11957
+ "license": "MIT",
11958
+ "dependencies": {
11959
+ "fast-check": "^4.9.0",
11960
+ "msgpackr": "^2.0.5"
11961
+ }
11962
+ },
11843
11963
  "node_modules/embla-carousel": {
11844
11964
  "version": "8.6.0",
11845
11965
  "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
@@ -12447,6 +12567,29 @@
12447
12567
  "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
12448
12568
  "license": "MIT"
12449
12569
  },
12570
+ "node_modules/fast-check": {
12571
+ "version": "4.10.1",
12572
+ "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.1.tgz",
12573
+ "integrity": "sha512-sB5Vghiu8MyCyToHoBVGsT0baZg3sZWNIY+a6Ct2EDrQJlT4YdH6MC1BSLNe3kX1k5i5g0q1O52XFgcKK/rGHg==",
12574
+ "dev": true,
12575
+ "funding": [
12576
+ {
12577
+ "type": "individual",
12578
+ "url": "https://github.com/sponsors/dubzzz"
12579
+ },
12580
+ {
12581
+ "type": "opencollective",
12582
+ "url": "https://opencollective.com/fast-check"
12583
+ }
12584
+ ],
12585
+ "license": "MIT",
12586
+ "dependencies": {
12587
+ "pure-rand": "^8.0.0"
12588
+ },
12589
+ "engines": {
12590
+ "node": ">=12.17.0"
12591
+ }
12592
+ },
12450
12593
  "node_modules/fast-deep-equal": {
12451
12594
  "version": "3.1.3",
12452
12595
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -15349,6 +15492,39 @@
15349
15492
  "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
15350
15493
  "license": "MIT"
15351
15494
  },
15495
+ "node_modules/msgpackr": {
15496
+ "version": "2.1.0",
15497
+ "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz",
15498
+ "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==",
15499
+ "dev": true,
15500
+ "license": "MIT",
15501
+ "optionalDependencies": {
15502
+ "msgpackr-extract": "^3.0.4"
15503
+ }
15504
+ },
15505
+ "node_modules/msgpackr-extract": {
15506
+ "version": "3.0.4",
15507
+ "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
15508
+ "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
15509
+ "dev": true,
15510
+ "hasInstallScript": true,
15511
+ "license": "MIT",
15512
+ "optional": true,
15513
+ "dependencies": {
15514
+ "node-gyp-build-optional-packages": "5.2.2"
15515
+ },
15516
+ "bin": {
15517
+ "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
15518
+ },
15519
+ "optionalDependencies": {
15520
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
15521
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
15522
+ "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
15523
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
15524
+ "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
15525
+ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
15526
+ }
15527
+ },
15352
15528
  "node_modules/muggle-string": {
15353
15529
  "version": "0.4.1",
15354
15530
  "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@@ -15453,6 +15629,22 @@
15453
15629
  "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==",
15454
15630
  "license": "MIT"
15455
15631
  },
15632
+ "node_modules/node-gyp-build-optional-packages": {
15633
+ "version": "5.2.2",
15634
+ "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
15635
+ "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
15636
+ "dev": true,
15637
+ "license": "MIT",
15638
+ "optional": true,
15639
+ "dependencies": {
15640
+ "detect-libc": "^2.0.1"
15641
+ },
15642
+ "bin": {
15643
+ "node-gyp-build-optional-packages": "bin.js",
15644
+ "node-gyp-build-optional-packages-optional": "optional.js",
15645
+ "node-gyp-build-optional-packages-test": "build-test.js"
15646
+ }
15647
+ },
15456
15648
  "node_modules/node-mock-http": {
15457
15649
  "version": "1.0.5",
15458
15650
  "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz",
@@ -16269,6 +16461,23 @@
16269
16461
  "node": ">=6"
16270
16462
  }
16271
16463
  },
16464
+ "node_modules/pure-rand": {
16465
+ "version": "8.4.2",
16466
+ "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz",
16467
+ "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==",
16468
+ "dev": true,
16469
+ "funding": [
16470
+ {
16471
+ "type": "individual",
16472
+ "url": "https://github.com/sponsors/dubzzz"
16473
+ },
16474
+ {
16475
+ "type": "opencollective",
16476
+ "url": "https://opencollective.com/fast-check"
16477
+ }
16478
+ ],
16479
+ "license": "MIT"
16480
+ },
16272
16481
  "node_modules/qified": {
16273
16482
  "version": "0.10.1",
16274
16483
  "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
@@ -20032,7 +20241,7 @@
20032
20241
  },
20033
20242
  "packages/switchboard": {
20034
20243
  "name": "@coreplane/switchboard",
20035
- "version": "1.235.0",
20244
+ "version": "1.237.0",
20036
20245
  "license": "Apache-2.0",
20037
20246
  "dependencies": {
20038
20247
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.235.0",
3
+ "version": "1.237.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -91,7 +91,10 @@
91
91
  "devDependencies": {
92
92
  "@earendil-works/pi-coding-agent": "0.85.1",
93
93
  "@eslint/js": "^10.0.1",
94
+ "@opencode/protocol": "2.0.3",
95
+ "@opencode/schema": "2.0.3",
94
96
  "@types/node": "^24.0.0",
97
+ "effect": "4.0.0-rc.112",
95
98
  "eslint": "^10.10.0",
96
99
  "eslint-config-prettier": "^10.1.8",
97
100
  "eslint-plugin-vue": "^10.11.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.235.0",
3
- "commit": "091ab2f1f6d89290bde9c84e933d23525f9c8873",
4
- "builtAt": "2026-09-16T17:37:07.278Z"
2
+ "version": "1.237.0",
3
+ "commit": "48c0666b37e01a43b1cfaafb6a9b80c398efa813",
4
+ "builtAt": "2026-09-16T22:10:57.396Z"
5
5
  }
@@ -63,6 +63,21 @@ export function principalOf(actor: Actor): Actor {
63
63
  return current;
64
64
  }
65
65
 
66
+ /** The ids that mean "me" for a decision (record 0042): the root principal's
67
+ * `self` when it carries one — its own id and the person a dashboard session
68
+ * is linked to — else its id alone. Never read by a grant check. */
69
+ export function selfIdsOf(actor: Actor): readonly string[] {
70
+ const principal = principalOf(actor);
71
+ return principal.self && principal.self.length > 0 ? principal.self : [principal.id];
72
+ }
73
+
74
+ /** Whether a self set names a chat identity — the `acts-as-person` condition:
75
+ * every chat actor, a dashboard session linked to its person; never an
76
+ * unlinked browser session or a credential. */
77
+ export function actsAsPerson(selfIds: readonly string[]): boolean {
78
+ return selfIds.some((id) => id.startsWith("slack:"));
79
+ }
80
+
66
81
  // ── decision ─────────────────────────────────────────────────────────────────
67
82
 
68
83
  const ALLOW: Decision = Object.freeze({ allow: true });
@@ -76,11 +91,13 @@ export type DenyReason =
76
91
  | "missing-grant"
77
92
  | "not-member"
78
93
  | "not-self"
94
+ | "not-person"
79
95
  | "not-owner"
80
96
  | "not-all-channels";
81
97
 
82
98
  const FAILURE_REASON: Readonly<Record<Condition["kind"], DenyReason>> = {
83
99
  "has-grant": "missing-grant",
100
+ "acts-as-person": "not-person",
84
101
  "member-of": "not-member",
85
102
  "is-self": "not-self",
86
103
  "owner-of": "not-owner",
@@ -98,7 +115,7 @@ export function isKnownActorKind(kind: string): boolean {
98
115
  export function evaluateCondition(
99
116
  condition: Condition,
100
117
  grants: Grants,
101
- selfId: string,
118
+ selfIds: readonly string[],
102
119
  attributes: ResourceAttributes,
103
120
  ): boolean {
104
121
  switch (condition.kind) {
@@ -115,7 +132,9 @@ export function evaluateCondition(
115
132
  attributes.channelVisibility === "public"
116
133
  );
117
134
  case "is-self":
118
- return attributes.userId !== undefined && attributes.userId === selfId;
135
+ return attributes.userId !== undefined && selfIds.includes(attributes.userId);
136
+ case "acts-as-person":
137
+ return actsAsPerson(selfIds);
119
138
  case "owner-of":
120
139
  return attributes.repo !== undefined && holds(grants.repos, attributes.repo);
121
140
  case "all-channels":
@@ -134,8 +153,8 @@ export function evaluateRule(rule: Rule, actor: Actor, resource: Resource): bool
134
153
  const attributes = attributesOf(resource);
135
154
  if (rule.originVisibility && !rule.originVisibility.includes(attributes.visibility)) return false;
136
155
  const grants = effectiveGrants(actor);
137
- const selfId = principalOf(actor).id;
138
- return rule.when.every((condition) => evaluateCondition(condition, grants, selfId, attributes));
156
+ const selfIds = selfIdsOf(actor);
157
+ return rule.when.every((condition) => evaluateCondition(condition, grants, selfIds, attributes));
139
158
  }
140
159
 
141
160
  /** `authorize` over an explicit (validated) table. Tests use it to drive
@@ -153,10 +172,10 @@ export function authorizeWith(rules: readonly Rule[], actor: Actor, action: Acti
153
172
  );
154
173
  if (forOrigin.length === 0) return deny("origin-visibility");
155
174
  const grants = effectiveGrants(actor);
156
- const selfId = principalOf(actor).id;
175
+ const selfIds = selfIdsOf(actor);
157
176
  let reason: DenyReason | undefined;
158
177
  for (const rule of forOrigin) {
159
- const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfId, attributes));
178
+ const failed = rule.when.find((condition) => !evaluateCondition(condition, grants, selfIds, attributes));
160
179
  if (!failed) return ALLOW;
161
180
  reason ??= FAILURE_REASON[failed.kind];
162
181
  }
@@ -41,6 +41,7 @@ import type { ActorKind, Condition, Rule } from "./types.js";
41
41
  const grant = (g: string): Condition => ({ kind: "has-grant", grant: g });
42
42
  const MEMBER_OF: Condition = { kind: "member-of" };
43
43
  const IS_SELF: Condition = { kind: "is-self" };
44
+ const ACTS_AS_PERSON: Condition = { kind: "acts-as-person" };
44
45
  const OWNER_OF: Condition = { kind: "owner-of" };
45
46
  const ALL_CHANNELS: Condition = { kind: "all-channels" };
46
47
 
@@ -144,6 +145,13 @@ export const POLICY: readonly Rule[] = [
144
145
  // ── mcp (external MCP servers live in the three config tiers) ────────────
145
146
  { action: "mcp:read", resource: "command", when: [grant("mcp:read")] },
146
147
  { action: "mcp:write", resource: "command", when: [grant("mcp:write")] },
148
+ // A person always has their own tier to write (`mcp add|connect|remove`
149
+ // for themselves), as `config set me` already is theirs: the row admits an
150
+ // actor whose `self` names a chat identity — every chat user (who holds
151
+ // `mcp:write` by baseline anyway) and a dashboard session linked to its
152
+ // person (record 0042) — never an unlinked browser session or a credential.
153
+ // Which tier the write reaches stays the handler's question below.
154
+ { action: "mcp:write", resource: "command", actorKinds: ["user"], when: [ACTS_AS_PERSON] },
147
155
  // A CHANNEL's servers: the channel-config right for a person; a credential
148
156
  // an admin minted with `mcp:write` manages any tier it can name.
149
157
  { action: "mcp:write", resource: "config-scope", resourceKind: "channel", when: [grant("config:write")] },
@@ -214,6 +222,7 @@ export const CONDITION_KINDS: readonly Condition["kind"][] = [
214
222
  "has-grant",
215
223
  "member-of",
216
224
  "is-self",
225
+ "acts-as-person",
217
226
  "owner-of",
218
227
  "all-channels",
219
228
  ];