@coreplane/switchboard 0.0.0 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +17 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- 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 "$@"
|