@coreplane/switchboard 0.0.0 → 1.18.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 (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +18 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  126. package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,410 @@
1
+ // Sandbox proxy Worker: fronts per-thread Cloudflare Sandboxes with a minimal
2
+ // authenticated HTTP API the bot's CloudflareSandboxExecutor calls.
3
+ //
4
+ // POST /exec { command, timeoutMs?, env? } -> { stdout, stderr, exitCode, durationMs }
5
+ // POST /read { path, env? } -> { content }
6
+ // POST /write { path, content, env? } -> { ok: true }
7
+ // GET /healthz -> { ok: true, build: { commit, builtAt? } }
8
+ //
9
+ // Every request carries:
10
+ // Authorization: Bearer <SANDBOX_TOKEN> (wrangler secret put SANDBOX_TOKEN)
11
+ // X-Thread-Key: <threadKey> (one sandbox per conversation thread)
12
+ // and the body's optional `env` ({ NAME: value }) is forwarded into the sandbox
13
+ // for that one command — in the body, never in headers, because Workers Logs
14
+ // record request headers (docs/reference/specs/execution.md item 5).
15
+ //
16
+ // The Sandbox SDK only runs inside Workers — that's why this proxy exists.
17
+ // Verify method names against https://developers.cloudflare.com/sandbox/ on
18
+ // first deploy; the SDK is young and its surface may shift.
19
+ import { getSandbox, Sandbox, type ExecOptions, type ExecResult } from "@cloudflare/sandbox";
20
+ import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
21
+ import {
22
+ EXEC_KEEPALIVE_INTERVAL_MS,
23
+ SANDBOX_SLEEP_AFTER,
24
+ isRecycleError,
25
+ recycledMidCommandMessage,
26
+ withActivityKeepalive,
27
+ } from "../../src/execution/sandboxKeepalive.js";
28
+ import { envFromRequest } from "../../src/execution/sandboxEnv.js";
29
+ import { shellQuote } from "../../src/execution/shellQuote.js";
30
+ import {
31
+ fleetBusyAnswer,
32
+ fleetBusyExecAnswer,
33
+ isContainerStarting,
34
+ isFleetBusyError,
35
+ thrownShape,
36
+ thrownText,
37
+ } from "../../src/execution/sandboxErrors.js";
38
+ import { injectedBuildStamp } from "../../src/deploy/buildStamp.js";
39
+ import { classifyError } from "../../src/core/trace/classify.js";
40
+ import { systemClock } from "../../src/core/trace/clock.js";
41
+ import { createTracer } from "../../src/core/trace/tracer.js";
42
+ import { startAdoptedRoot, workerLogSink } from "../../src/core/trace/workerTrace.js";
43
+ import sandboxPkg from "./package.json" with { type: "json" };
44
+
45
+ /** The `@cloudflare/sandbox` version this Worker is built against — the pin
46
+ * `check:sandbox-pair` holds equal to the Dockerfile's image tag, so it is
47
+ * also the version a container on the CURRENT image reports. */
48
+ const SDK_PIN: string = sandboxPkg.dependencies["@cloudflare/sandbox"];
49
+
50
+ export class SwitchboardSandbox extends Sandbox {
51
+ // Idle lifetime of a thread's container (the SDK's own default is 10 min on
52
+ // 0.12.x, 20 on 0.3.x). On the 0.0.28 containers base the activity clock was
53
+ // renewed once per proxied fetch and the alarm loop SIGTERMed the container
54
+ // the moment it expired, in-flight request or not — so a review's first
55
+ // command (a 20-minute budget under a 20-minute default) was once
56
+ // killed at 20:00 exactly, surfaced as "Command execution failed", and
57
+ // the next command found a fresh container with an empty /workspace. `exec`
58
+ // below renews the clock every minute while a command runs, which makes
59
+ // this a true idle timeout (docs/reference/specs/execution.md item 2). The 0.3.x
60
+ // containers base tracks in-flight requests itself, so the keepalive is now
61
+ // belt-and-braces — kept until a live long-command receipt retires it. 5
62
+ // minutes of idle frees the slot sooner while a prompt follow-up still
63
+ // reuses the warm workspace.
64
+ sleepAfter = SANDBOX_SLEEP_AFTER;
65
+
66
+ // A Worker and its image deploy as two artifacts; until the rollout finishes
67
+ // this Worker can be handed a container still on the PREVIOUS image
68
+ // (docs/reference/specs/execution.md item 6; seen live: a 0.3.7 container under the
69
+ // 0.12.9 SDK, every command a message-less 400 for 90 s). The SDK's own
70
+ // check logs `container=unknown` at info and does nothing else; this one
71
+ // names the skew at warn so the rollout is visible in the logs.
72
+ //
73
+ // LOG ONLY — never `destroy()` here: onStart runs inside
74
+ // `blockConcurrencyWhile`, `destroy()` is unbounded and coalesced callers
75
+ // hang until eviction, a fresh placement during a gradual wave can land on
76
+ // the old image again (the incident's DO was brand new), and the
77
+ // healthy-but-not-running state after a destroy takes the SDK's stale-state
78
+ // path, which can `ctx.abort()` the DO. A command that fails on a skewed
79
+ // instance is named by `thrownText` with the rollout hint; the one-wave
80
+ // rollout (`rollout_step_percentage: 100`) keeps the window to seconds.
81
+ override async onStart(): Promise<void> {
82
+ await super.onStart();
83
+ const v = await this.client.utils.getVersion().catch(() => "unknown");
84
+ if (v !== SDK_PIN) {
85
+ console.warn(
86
+ `sandbox.version-skew container=${v} sdk=${SDK_PIN} — this instance may be on a previous image (Worker/image rollout in progress)`,
87
+ );
88
+ }
89
+ }
90
+
91
+ override async exec(command: string, options?: ExecOptions): Promise<ExecResult> {
92
+ return withActivityKeepalive(
93
+ () => this.renewActivityTimeout(),
94
+ () => super.exec(command, options),
95
+ EXEC_KEEPALIVE_INTERVAL_MS,
96
+ );
97
+ }
98
+
99
+ // A fence from the 0.3.x days, kept until a live container-restart receipt
100
+ // retires it: 0.3.x cached its default ExecutionSession in Durable
101
+ // Object memory while the session lived in the container, so a container
102
+ // restart under a live DO (image rollout, crash, sleep/wake) made every later
103
+ // call fail with "Session '<id>' not found" forever. 0.12.x persists the
104
+ // session id in DO storage, clears it itself in `onStop`, and its container
105
+ // recreates a missing session on the next exec — so this should never run;
106
+ // if it does, nulling the cached id only makes the SDK recreate the session,
107
+ // which is what it would have done anyway. The workspace disk is gone either
108
+ // way; repos re-clone — the same graceful degradation as an expired E2B
109
+ // sandbox.
110
+ resetDefaultSession(): void {
111
+ (this as unknown as { defaultSession: unknown }).defaultSession = null;
112
+ }
113
+ }
114
+
115
+ /** The 0.3.x stale-session text; see `resetDefaultSession`. */
116
+ const STALE_SESSION = /session '[^']*' not found/i;
117
+
118
+ /** Run a sandbox call and retry it ONCE when the failure says nothing ran: a
119
+ * stale session (0.3.x; reset the cached id first) or a container still
120
+ * booting (0.12.x's "Container is starting. Please retry in a moment.", after
121
+ * a short pause). Both fail before the command or file op executes, so the
122
+ * re-send is safe by construction. Anything else propagates: a failure whose
123
+ * command MAY have run (a session shell that exited mid-command, a container
124
+ * that stopped under the call) is never re-run here — /exec names it a
125
+ * recycle instead (docs/reference/specs/execution.md item 9). */
126
+ const CONTAINER_STARTING_RETRY_DELAY_MS = 3_000;
127
+
128
+ async function withSessionRecovery<T>(
129
+ sandbox: { resetDefaultSession(): void | Promise<void> },
130
+ fn: () => Promise<T>,
131
+ ): Promise<T> {
132
+ try {
133
+ return await fn();
134
+ } catch (err) {
135
+ const { message } = thrownShape(err);
136
+ if (message && STALE_SESSION.test(message)) {
137
+ await sandbox.resetDefaultSession();
138
+ return await fn();
139
+ }
140
+ if (isContainerStarting(err)) {
141
+ await new Promise((r) => setTimeout(r, CONTAINER_STARTING_RETRY_DELAY_MS));
142
+ return await fn();
143
+ }
144
+ throw err;
145
+ }
146
+ }
147
+
148
+ interface Env {
149
+ Sandbox: DurableObjectNamespace<SwitchboardSandbox>;
150
+ SANDBOX_TOKEN: string;
151
+ }
152
+
153
+ const WORKDIR = "/workspace";
154
+
155
+ // Default per-command time limit, enforced by coreutils `timeout` inside the
156
+ // sandbox. The tuned 280s applies when the body carries no timeoutMs (an older
157
+ // bot); a caller-supplied timeoutMs is clamped server-side to the shared
158
+ // [1s, 20 min] bounds (clampBashTimeout — never trust the client's number).
159
+ // Whatever the effective limit, it must stay BELOW the SDK backstop
160
+ // (COMMAND_TIMEOUT_MS in the Dockerfile, sized above the 20-min ceiling) so
161
+ // the real exit 124 wins. undici's 300s no-headers ceiling stopped mattering
162
+ // once /exec streamed heartbeats — headers go out immediately.
163
+ const EXEC_TIMEOUT_SECS = 280;
164
+
165
+ /** The commit this bundle was built from, injected by the deploy
166
+ * (`deploy/bin/build-stamp.mjs`) and answered on GET /healthz as `build`. */
167
+ const BUILD = injectedBuildStamp();
168
+
169
+ // The Worker's own spans (docs/reference/specs/tracing.md item 22): one `sandbox.exec`
170
+ // root per command, started at the attempt that produced the answer, joining
171
+ // the bot's trace (the bearer checked out before the header is read).
172
+ const tracer = createTracer({ clock: systemClock });
173
+ const traceSinks = [workerLogSink((line) => console.log(line))];
174
+
175
+ /** One command's root, started at the attempt that answered, joining the bot's trace. */
176
+ function execRoot(attemptStartedAt: number, traceparent: string | undefined) {
177
+ return startAdoptedRoot(tracer, "sandbox.exec", { sinks: traceSinks, startedAt: attemptStartedAt, traceparent });
178
+ }
179
+
180
+ export default {
181
+ async fetch(request: Request, env: Env): Promise<Response> {
182
+ const auth = request.headers.get("authorization");
183
+ if (!env.SANDBOX_TOKEN || auth !== `Bearer ${env.SANDBOX_TOKEN}`) {
184
+ return json({ error: "unauthorized" }, 401);
185
+ }
186
+ // Build identity, behind the SAME bearer as everything else: this Worker
187
+ // authenticates every request and gains no unauthenticated surface for a
188
+ // stamp (docs/reference/specs/execution.md item 13). It needs no thread, so it answers
189
+ // before the X-Thread-Key check — and it is the one GET here.
190
+ if (request.method === "GET" && new URL(request.url).pathname === "/healthz") {
191
+ return json({ ok: true, build: BUILD });
192
+ }
193
+ if (request.method !== "POST") return json({ error: "POST only" }, 405);
194
+
195
+ const threadKey = request.headers.get("x-thread-key");
196
+ if (!threadKey) return json({ error: "missing X-Thread-Key" }, 400);
197
+
198
+ // One sandbox per thread; the DO name is the thread key. getSandbox is
199
+ // generic over the namespace's class since 0.12, so the subclass's
200
+ // resetDefaultSession is callable over RPC without a cast.
201
+ const sandbox = getSandbox(env.Sandbox, threadKey);
202
+
203
+ const url = new URL(request.url);
204
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
205
+
206
+ // Optional env passthrough (e.g. GH_TOKEN), read from the BODY's `env`
207
+ // (docs/reference/specs/execution.md item 5). It then rides in the SDK's per-exec
208
+ // `env` option, which the container applies to that one command and
209
+ // restores afterwards (0.12.x; 0.3.7 ignored it, so the value used to be
210
+ // an inline base64 `export` prefix — which put the live GH_TOKEN into every
211
+ // "Command executed" line the SDK logs). Nothing persists in the
212
+ // sandbox beyond the command's lifetime, and the command text the SDK
213
+ // logs never carries a credential.
214
+ //
215
+ // Body, never headers: Workers Logs record this invocation's request
216
+ // headers and redact them by a name heuristic only — a receipt probe's
217
+ // per-variable env header was once logged in clear. Bodies are not
218
+ // recorded. The one-release per-variable-header fallback that carried a
219
+ // body-only bot against a header-only Worker during that rollout is
220
+ // gone now that the body reader is live everywhere, so the
221
+ // credential rides only in the body and no request header is read as env.
222
+ const envVars = envFromRequest({ body });
223
+
224
+ try {
225
+ switch (url.pathname) {
226
+ case "/exec": {
227
+ // Streamed with a whitespace heartbeat. A long command otherwise
228
+ // holds a byteless HTTP request open for minutes, and some hop
229
+ // between the bot and this Worker silently drops idle connections —
230
+ // measured live: the container answered a 290s command at its 280s
231
+ // timeout, but the bot's fetch died without ever seeing the
232
+ // response (undici gives up 300s after sending a request that has
233
+ // received no headers). Headers go out immediately and a heartbeat
234
+ // byte flows every 15s, so no intermediary ever sees an idle
235
+ // connection. Heartbeats are pure whitespace, which is legal around
236
+ // a JSON document — the executor's res.json() on the full body
237
+ // parses unchanged.
238
+ //
239
+ // The time limit is enforced with coreutils `timeout` INSIDE the
240
+ // sandbox, not by the SDK: the SDK's COMMAND_TIMEOUT_MS rejection is
241
+ // useless to callers — its exec handler wraps every failure as a
242
+ // generic "Command execution failed" and buries the real message in
243
+ // a field its client discards (measured live). Shell-level timeout
244
+ // produces a real exit 124 through the normal result path, no error
245
+ // classification needed. SIGKILL follows 10s after TERM for
246
+ // stragglers. COMMAND_TIMEOUT_MS (Dockerfile) sits above this as a
247
+ // pure backstop.
248
+ //
249
+ // Per-call budget (docs/reference/specs/execution.md item 11): a numeric
250
+ // timeoutMs in the body is clamped server-side to the shared
251
+ // [1s, 20 min] bounds; absent (an older bot) → the tuned 280s
252
+ // default this Worker has always used.
253
+ const requested = body.timeoutMs;
254
+ const execTimeoutSecs =
255
+ typeof requested === "number" && Number.isFinite(requested)
256
+ ? Math.ceil(clampBashTimeout(requested) / 1000)
257
+ : EXEC_TIMEOUT_SECS;
258
+ const full = `mkdir -p ${WORKDIR} && cd ${WORKDIR} && ${String(body.command ?? "")}`;
259
+ return streamExec(
260
+ sandbox,
261
+ `timeout -k 10 ${execTimeoutSecs} bash -c ${shellQuote(full)}`,
262
+ { env: envVars },
263
+ execTimeoutSecs,
264
+ request.headers.get("traceparent") ?? undefined,
265
+ );
266
+ }
267
+ case "/read": {
268
+ const file = await withSessionRecovery(sandbox, () => sandbox.readFile(abs(String(body.path ?? ""))));
269
+ return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
270
+ }
271
+ case "/write": {
272
+ await withSessionRecovery(sandbox, () =>
273
+ sandbox.writeFile(abs(String(body.path ?? "")), String(body.content ?? "")),
274
+ );
275
+ return json({ ok: true });
276
+ }
277
+ default:
278
+ return json({ error: "unknown route" }, 404);
279
+ }
280
+ } catch (err) {
281
+ // Never an empty text (item 3): a message-less SDK error is named as
282
+ // such, with the rollout hint.
283
+ const msg = thrownText(thrownShape(err));
284
+ // A full fleet (docs/reference/specs/execution.md item 14): the SDK could not get a
285
+ // container instance for this thread's Durable Object, so no session
286
+ // exists and the file op never started — re-sending is safe by
287
+ // construction. Named so the executor waits instead of reading it as a
288
+ // dead sandbox; 503 because that is what it is.
289
+ if (isFleetBusyError(err)) return json(fleetBusyAnswer(msg), 503);
290
+ return json({ error: msg }, 500);
291
+ }
292
+ },
293
+ } satisfies ExportedHandler<Env>;
294
+
295
+ /** Execute a command and stream the response: immediate headers, a whitespace
296
+ * heartbeat every 15s while the command runs, then one JSON document. All
297
+ * outcomes arrive in-body with HTTP 200 (headers are long gone by the time
298
+ * the result is known): a completed command as {stdout, stderr, exitCode},
299
+ * a sandbox-enforced timeout as exit 124, and any other failure as {error}. */
300
+ function streamExec(
301
+ sandbox: {
302
+ resetDefaultSession(): void | Promise<void>;
303
+ exec(command: string, options?: ExecOptions): Promise<{ stdout?: string; stderr?: string; exitCode?: number }>;
304
+ },
305
+ command: string,
306
+ options: ExecOptions,
307
+ execTimeoutSecs: number,
308
+ traceparent: string | undefined,
309
+ ): Response {
310
+ const encoder = new TextEncoder();
311
+ // Per ATTEMPT, not per request: withSessionRecovery may run the command a
312
+ // second time after a stale-session reset, and a retry's own failure must be
313
+ // judged on its own clock, not the first attempt's.
314
+ let attemptStartedAt = systemClock();
315
+ const stream = new ReadableStream<Uint8Array>({
316
+ start(controller) {
317
+ const beat = setInterval(() => {
318
+ try {
319
+ controller.enqueue(encoder.encode("\n"));
320
+ } catch {
321
+ clearInterval(beat); // client went away; the exec promise still settles
322
+ }
323
+ }, 15_000);
324
+ const finish = (payload: object) => {
325
+ clearInterval(beat);
326
+ try {
327
+ controller.enqueue(encoder.encode(JSON.stringify(payload)));
328
+ controller.close();
329
+ } catch {
330
+ // stream already errored/cancelled — nothing left to deliver to
331
+ }
332
+ };
333
+ withSessionRecovery(sandbox, () => {
334
+ attemptStartedAt = systemClock();
335
+ return sandbox.exec(command, options);
336
+ })
337
+ .then((result) => {
338
+ const exitCode = result.exitCode ?? 0;
339
+ // coreutils `timeout` exits 124 when the deadline killed the
340
+ // command (137 when the follow-up SIGKILL had to) — annotate so the
341
+ // agent knows what happened and how to adapt.
342
+ const timedOut = exitCode === 124 || exitCode === 137;
343
+ const note = timedOut
344
+ ? `command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
345
+ "re-run as smaller/faster steps or background it with nohup"
346
+ : "";
347
+ // The command as the Worker's own root (docs/reference/specs/tracing.md item 22).
348
+ execRoot(attemptStartedAt, traceparent).end(exitCode === 0 ? "ok" : "error", {
349
+ exitCode: timedOut ? 124 : exitCode,
350
+ ...(timedOut ? { timedOut: true } : {}),
351
+ });
352
+ finish({
353
+ stdout: result.stdout ?? "",
354
+ stderr: [result.stderr ?? "", note].filter(Boolean).join("\n"),
355
+ exitCode: timedOut ? 124 : exitCode,
356
+ // The command's wall time in the sandbox, for the bot's `exec.exec`
357
+ // span (docs/reference/specs/tracing.md item 19); an older client ignores it.
358
+ durationMs: systemClock() - attemptStartedAt,
359
+ });
360
+ })
361
+ .catch((err: unknown) => {
362
+ // A command the sandbox never answered for: an infra failure, classified, no message.
363
+ const root = execRoot(attemptStartedAt, traceparent);
364
+ root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
365
+ root.end("error");
366
+ const shape = thrownShape(err);
367
+ // The text that leaves the Worker is never empty (item 3): a
368
+ // message-less SDK error is named, with the rollout hint. The
369
+ // classifiers below still read the raw `shape`.
370
+ const raw = thrownText(shape);
371
+ // A full fleet (docs/reference/specs/execution.md item 14): session creation
372
+ // failed because no container instance was free, so the command
373
+ // never started — re-sending it is safe by construction. The named
374
+ // `reason` is what the executor waits on; the dual shape below is
375
+ // kept so an older executor still renders it as exit 127.
376
+ if (isFleetBusyError(err)) {
377
+ finish(fleetBusyExecAnswer(raw));
378
+ return;
379
+ }
380
+ // The container was replaced under the command (docs/reference/specs/execution.md
381
+ // item 2): certain when the SDK says so with a typed error, inferred
382
+ // when a recycle-shaped text arrives minutes into this attempt. Say
383
+ // so, and that /workspace is gone. Still exit 127: the workspace
384
+ // really is gone.
385
+ const certain = shape.name !== undefined && isRecycleError({ name: shape.name });
386
+ const msg = recycledMidCommandMessage(systemClock() - attemptStartedAt, raw, certain);
387
+ // Carry the failure in BOTH shapes so rollout order can't create
388
+ // a silent-success window: a new executor throws on `error`, and
389
+ // an executor that predates in-body errors (only checks exitCode)
390
+ // still renders "exit 127: <message>" instead of "(no output)".
391
+ finish({ error: msg, stdout: "", stderr: msg, exitCode: 127 });
392
+ });
393
+ },
394
+ });
395
+ return new Response(stream, { headers: { "content-type": "application/json" } });
396
+ }
397
+
398
+ function abs(p: string): string {
399
+ if (!p) throw new Error("missing path");
400
+ const path = p.startsWith("/") ? p : `${WORKDIR}/${p}`;
401
+ if (!path.startsWith(WORKDIR)) throw new Error(`Path escapes workspace: ${p}`);
402
+ return path;
403
+ }
404
+
405
+ function json(data: unknown, status = 200): Response {
406
+ return new Response(JSON.stringify(data), {
407
+ status,
408
+ headers: { "content-type": "application/json" },
409
+ });
410
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "$schema": "node_modules/wrangler/config-schema.json",
3
+ "name": "{{script}}",
4
+ "main": "worker.ts",
5
+ "compatibility_date": "2026-08-01",
6
+ // @cloudflare/sandbox 0.12.x imports Node built-ins (wrangler's dry run names
7
+ // sandbox-*.js and warns the Worker "may throw errors at runtime" without
8
+ // this); the resident Worker on the 0.13 line carries the same flag.
9
+ "compatibility_flags": ["nodejs_compat"],
10
+ // The installation's Cloudflare account (deploy/profile.json `account`) — every Worker deploys to it.
11
+ "account_id": "{{account}}",
12
+ // Reachable only with the SANDBOX_TOKEN bearer secret. Custom domain on the
13
+ // installation's zone so the bot config has a stable URL.
14
+ "workers_dev": false,
15
+ "routes": [{ "pattern": "{{hostname}}", "custom_domain": true }],
16
+ "observability": { "enabled": true },
17
+ "containers": [
18
+ {
19
+ "class_name": "SwitchboardSandbox",
20
+ // The profile's image mode decides (src/deploy/images.ts): `build` renders
21
+ // ./Dockerfile (on the @cloudflare/sandbox base image), built by wrangler
22
+ // at deploy time; `registry` renders the release's published image as
23
+ // copied into this account's registry by `deploy images`.
24
+ "image": "{{image}}",
25
+ // standard-4 = 4 vCPU / 12 GiB RAM / 20 GB disk — the largest predefined
26
+ // type; a custom type tops out at the same 4 vCPU / 12 GiB / 20 GB. The
27
+ // cold path clones + installs + checks a whole repo per thread with
28
+ // nothing warm, so the size is set by the largest single command a cold
29
+ // thread must be able to run, not by the typical one: a large monorepo's
30
+ // typecheck needs more than 8 GiB (its own CI notes put the root program
31
+ // near 8 GB and each sub-program near 4 GB), and the previous standard-3
32
+ // (2 vCPU / 8 GiB / 16 GB; a live probe read nproc 2, 8189 MiB, 15 GB on
33
+ // /) could not run it at all. "basic" (1 GiB) died running a repo's test
34
+ // suite during a cold review (vitest forks per vCPU). Cost is per AWAKE
35
+ // instance and a sandbox sleeps after 5 idle minutes (SANDBOX_SLEEP_AFTER),
36
+ // so the step is paid per active run-hour: 12 GiB against 8 of memory,
37
+ // and vCPU only while the cores are busy (docs/reference/specs/execution.md
38
+ // item 16).
39
+ "instance_type": "standard-4",
40
+ // The fleet's ceiling on AWAKE per-thread sandboxes; the next thread past
41
+ // it waits (docs/reference/specs/execution.md item 14). Billing is per awake
42
+ // instance, so a higher ceiling costs nothing while instances sleep, and
43
+ // the account has ~15x headroom on the containers limits (6 TiB memory /
44
+ // 1500 vCPU against 25 × 12 GiB = 300 GiB, ~20x, and 25 × 4 = 100 vCPU,
45
+ // 15x — the vCPU line is the tighter one). Seen live: thirteen cold
46
+ // runs in 45 min exhausted the previous ceiling of 10 and a review
47
+ // aborted in 33 s reading the wait as a dead sandbox.
48
+ "max_instances": 25,
49
+ // Replace the old-image instances in ONE wave on a deploy, not the
50
+ // platform's default [10, 100] with minutes between the waves. A Worker
51
+ // and its image deploy as two artifacts, and until the rollout finishes
52
+ // the NEW Worker code can be handed an instance still on the PREVIOUS
53
+ // image: seen live, a review's thread created 111 s after a Worker
54
+ // upload landed on a 0.3.7 container under the 0.12.9 SDK and every
55
+ // command failed for 90 s. In-flight commands on old
56
+ // instances die in any rollout mode (each old instance is SIGTERMed
57
+ // eventually; the grace period is already 0) — one wave only moves that
58
+ // moment earlier, and shrinks the window a new thread can fall into
59
+ // from minutes to seconds (docs/reference/specs/execution.md item 6).
60
+ "rollout_step_percentage": 100
61
+ }
62
+ ],
63
+ "durable_objects": {
64
+ "bindings": [{ "name": "Sandbox", "class_name": "SwitchboardSandbox" }]
65
+ },
66
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SwitchboardSandbox"] }]
67
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
3
+ "account": "00000000000000000000000000000000",
4
+ "zone": "example.com",
5
+ "workers": {
6
+ "memory": { "script": "switchboard-memory", "hostname": "switchboard-memory.example.com" },
7
+ "bot": { "script": "switchboard", "hostname": "switchboard.example.com" },
8
+ "resident": { "script": "switchboard-resident", "hostname": "switchboard-resident.example.com" },
9
+ "sandbox": { "script": "switchboard-sandbox", "hostname": "switchboard-sandbox.example.com" }
10
+ },
11
+ "configSource": "config/config.yaml",
12
+ "images": "registry"
13
+ }
@@ -0,0 +1,108 @@
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.",
3
+ "secrets": [
4
+ {
5
+ "name": "SLACK_BOT_TOKEN",
6
+ "workers": ["bot"],
7
+ "note": "Slack app bot token (xoxb-). Third-party; rotate in the Slack app config."
8
+ },
9
+ {
10
+ "name": "SLACK_APP_TOKEN",
11
+ "workers": ["bot"],
12
+ "note": "Slack app-level token (xapp-) for Socket Mode. Third-party."
13
+ },
14
+ {
15
+ "name": "ANTHROPIC_API_KEY",
16
+ "workers": ["bot"],
17
+ "note": "Anthropic Console key the bot runs on. Third-party."
18
+ },
19
+ {
20
+ "name": "ANTHROPIC_ADMIN_KEY",
21
+ "workers": ["bot"],
22
+ "optional": true,
23
+ "note": "Anthropic Admin API key (sk-ant-admin…) for the LLM-spend layer of GET /costs. Absent, /costs shows the Cloudflare side only."
24
+ },
25
+ {
26
+ "name": "BRAVE_SEARCH_API_KEY",
27
+ "workers": ["bot"],
28
+ "optional": true,
29
+ "note": "Brave Search API key for the research agent's web search. Third-party. Optional: without it the research agent has no web search."
30
+ },
31
+ {
32
+ "name": "CF_ANALYTICS_TOKEN",
33
+ "workers": ["bot"],
34
+ "optional": true,
35
+ "note": "Cloudflare API token for the installation's account, Account Analytics:Read — prices GET /costs from billing data. Optional: without it GET /costs has no infrastructure spend."
36
+ },
37
+ {
38
+ "name": "SWITCHBOARD_INGRESS_TOKENS",
39
+ "workers": ["bot"],
40
+ "optional": true,
41
+ "note": "JSON bearer→identity map for POST /ingress and POST /mcp. Self-minted (openssl rand -hex 32 per entry). Optional: without it /ingress and /mcp are disabled and `deploy restart` has no bearer."
42
+ },
43
+ {
44
+ "name": "SANDBOX_TOKEN",
45
+ "workers": ["bot", "sandbox"],
46
+ "optional": ["bot"],
47
+ "note": "Shared bearer: bot → thread-sandbox Worker. Self-minted. Required on the sandbox Worker; optional on the bot, which needs it only when `execution.type: cloudflare`."
48
+ },
49
+ {
50
+ "name": "RESIDENT_OPERATOR_TOKEN",
51
+ "workers": ["bot", "resident"],
52
+ "optional": ["bot"],
53
+ "note": "Shared bearer: bot runtime → resident Worker attach/exec/read/write/status. Self-minted. Rotating it breaks in-flight resident runs — check /runs is empty first. Required on the resident Worker; optional on the bot, which needs it only when `execution.resident` is set."
54
+ },
55
+ {
56
+ "name": "RESIDENT_ADMIN_TOKEN",
57
+ "workers": ["bot", "resident"],
58
+ "optional": ["bot"],
59
+ "note": "Shared bearer: `repo onboard/offboard/reconfigure/rebuild` → resident admin routes; also the resident deploy preflight. Self-minted. Required on the resident Worker; optional on the bot, which needs it only when `execution.resident` is set."
60
+ },
61
+ {
62
+ "name": "RESIDENT_READ_TOKEN",
63
+ "workers": ["resident"],
64
+ "note": "Read-only /residents + debug routes; what the resident deploy preflight needs (CI holds this one). Self-minted."
65
+ },
66
+ {
67
+ "name": "R2_ACCESS_KEY_ID",
68
+ "workers": ["resident"],
69
+ "optional": true,
70
+ "note": "R2 API token (S3 access key id) scoped Object Read & Write to the resident cache bucket ONLY — with R2_SECRET_ACCESS_KEY and the CLOUDFLARE_ACCOUNT_ID / BACKUP_BUCKET_NAME vars, snapshot bytes travel container↔R2 over presigned URLs and the Durable Object leaves the data path (docs/reference/specs/resident-repos.md item 61). Absent → the SDK's local-bucket mode (the DO pumps the bytes; a 1.16 GB restore exceeds the isolate's memory). Created in the Cloudflare dashboard (R2 → Manage API tokens); rotate = new token, `deploy secrets resident`. /healthz `backupTransfer` says which mode is live."
71
+ },
72
+ {
73
+ "name": "R2_SECRET_ACCESS_KEY",
74
+ "workers": ["resident"],
75
+ "optional": true,
76
+ "note": "The secret half of R2_ACCESS_KEY_ID (same token). Both or neither."
77
+ },
78
+ {
79
+ "name": "MCP_CREDENTIAL_KEY",
80
+ "workers": ["bot"],
81
+ "optional": true,
82
+ "note": "AES-256-GCM key (32 bytes, base64: `openssl rand -base64 32`) the bot seals MCP server credentials with before they go to the state Worker's McpDO (docs/reference/specs/mcp-tools.md). Bot-only by design: the Worker stores ciphertext. Rotation re-seals every credential — not built yet; rotate = users re-run `mcp connect`."
83
+ },
84
+ {
85
+ "name": "MEMORY_TOKEN",
86
+ "workers": ["bot", "resident", "memory"],
87
+ "note": "Shared bearer for the state Worker (memory, friction ledger, run history, schedule firings). ONE value on all three Workers — a resident with a different mint 401s on every watchdog firing record. Self-minted."
88
+ },
89
+ {
90
+ "name": "GITHUB_APP_ID",
91
+ "workers": ["bot", "resident"],
92
+ "optional": ["bot", "resident"],
93
+ "note": "The installation's GitHub App id. Not sensitive, but lives alongside the key so both Workers mint installation tokens the same way. Optional on both: without the triple the bot falls back to GH_TOKEN (or has no GitHub tools) and the resident clones anonymously."
94
+ },
95
+ {
96
+ "name": "GITHUB_APP_INSTALLATION_ID",
97
+ "workers": ["bot", "resident"],
98
+ "optional": ["bot", "resident"],
99
+ "note": "The App's installation id on the organization the bot works in. Not sensitive. Optional on both, with GITHUB_APP_ID."
100
+ },
101
+ {
102
+ "name": "GITHUB_APP_PRIVATE_KEY",
103
+ "workers": ["bot", "resident"],
104
+ "optional": ["bot", "resident"],
105
+ "note": "The App's PEM. Second credential domain: the resident holds its own copy; rotate both together (GitHub → generate new key → put on both → revoke old). Optional on both, with GITHUB_APP_ID."
106
+ }
107
+ ]
108
+ }
@@ -0,0 +1,15 @@
1
+ #!/bin/sh
2
+ # The image's entrypoint (the Dockerfile installs it as `switchboard`).
3
+ # no arguments → the bot, `node dist/index.js` (docker compose, the Cloudflare container)
4
+ # node|sh|bash … → that command, as given (a shell into the image)
5
+ # anything else → the CLI: `init`, `ask "…"`, `<group> <verb> …`
6
+ # The CLI reads `.env` and `./config/config.yaml` from the working directory,
7
+ # so `-w /work -v "$PWD":/work` installs into the host's directory.
8
+ set -e
9
+ if [ "$#" -eq 0 ]; then
10
+ exec node /app/dist/index.js
11
+ fi
12
+ case "$1" in
13
+ node | sh | bash) exec "$@" ;;
14
+ esac
15
+ exec node /app/dist/cli.js "$@"