@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,159 @@
1
+ // Fleet-capacity classification for the per-thread sandbox path
2
+ // (docs/reference/specs/execution.md item 14): ONE recognizer and one answer shape, shared
3
+ // by the sandbox Worker (which names the condition) and the bot's executor
4
+ // (which waits on it). Deliberately free of node: imports so wrangler can
5
+ // bundle it into the Worker, like bashTimeout.ts and shellQuote.ts.
6
+ //
7
+ // Why this exists: enough concurrent cold runs exhaust the sandbox fleet's
8
+ // `max_instances`. The @cloudflare/sandbox 0.3.x client could not parse the
9
+ // platform's plain-text "no instance available" 503 and threw the bare
10
+ // `Failed to create session: 503`; carried in-body as an ordinary failure, the
11
+ // executor threw ExecInfraError on it, and the runner's fail-fast breaker read
12
+ // two of them in a row as a WEDGED sandbox and aborted the run within seconds.
13
+ // A full fleet is capacity: nothing ran, nothing is broken, and the right
14
+ // move is to wait for an instance. On 0.12.x the SDK throws a typed
15
+ // `ContainerUnavailableError` (code `CONTAINER_UNAVAILABLE`) for the same
16
+ // condition; the recognizer below takes the type first and the text second.
17
+
18
+ /** The named reason the Worker answers with, mirroring the resident's
19
+ * `mirror-busy` (resident-repos.md) — a machine token the executor matches
20
+ * on, never the SDK's message text. */
21
+ export const FLEET_BUSY_REASON = "fleet-busy" as const;
22
+
23
+ /** What a full fleet means, in the words the model and the operator see. */
24
+ export const FLEET_BUSY_EXPLANATION =
25
+ "no free per-thread sandbox — every container instance the fleet may run (wrangler.jsonc max_instances) is awake serving another thread";
26
+
27
+ /** The executor waits at most this long for an instance — the default bash
28
+ * budget, so a default command never waits past its own limit. A longer
29
+ * command's wait is still capped here: waiting five minutes for a slot is
30
+ * patience; waiting twenty is a run that should have said so. */
31
+ export const FLEET_BUSY_WAIT_MAX_MS = 5 * 60_000;
32
+
33
+ /** Backoff between re-sends: 10 s, 20 s, then 30 s until the wait is spent.
34
+ * Slots free up when other threads' runs end (minutes), so sub-10 s polling
35
+ * would only burn Worker requests. */
36
+ export const FLEET_BUSY_BACKOFF_MS: readonly number[] = [10_000, 20_000, 30_000];
37
+
38
+ /** The messages a full fleet produces, across SDK generations:
39
+ * - `Failed to create session: 503` — the 0.3.x client's unparsed answer to
40
+ * the base Container's plain-text 503;
41
+ * - "no container instance that can be provided to this durable object" /
42
+ * "no Container instance available" — the platform's own wording, which the
43
+ * 0.12.x SDK keeps as the message of its `ContainerUnavailableError`;
44
+ * - `CONTAINER_UNAVAILABLE` — that error's JSON code, when a client prints it.
45
+ * A stale session, a wedged sandbox, a file-op failure, or a 503 from
46
+ * something a command itself contacted is NOT this. */
47
+ const FLEET_BUSY_PATTERNS: readonly RegExp[] = [
48
+ /^Failed to create session: 503\b/i,
49
+ /no container instance (?:that can be provided|available)/i,
50
+ /\bCONTAINER_UNAVAILABLE\b/,
51
+ ];
52
+
53
+ export function isFleetBusy(message: string): boolean {
54
+ const text = message.trim();
55
+ return text.length > 0 && FLEET_BUSY_PATTERNS.some((re) => re.test(text));
56
+ }
57
+
58
+ /** The 0.12.x SDK's typed class for a full fleet. Matched by NAME and by its
59
+ * `code`, not `instanceof`: the Worker sees the error after it crossed the
60
+ * Durable Object RPC boundary, which keeps `name`/`message` and drops the
61
+ * prototype. Text stays as the second layer for older SDKs and for causes
62
+ * the SDK wraps in a plain Error. */
63
+ export const FLEET_BUSY_ERROR_NAME = "ContainerUnavailableError";
64
+
65
+ /** The shape of anything thrown at the Worker — a typed SDK error, a plain
66
+ * Error, or a string — reduced to what classification can read. */
67
+ export interface ThrownShape {
68
+ name?: string;
69
+ code?: unknown;
70
+ message?: string;
71
+ }
72
+
73
+ export function thrownShape(err: unknown): ThrownShape {
74
+ if (err instanceof Error) {
75
+ return { name: err.name, code: (err as { code?: unknown }).code, message: err.message };
76
+ }
77
+ if (typeof err === "object" && err !== null) {
78
+ const o = err as ThrownShape;
79
+ return { name: o.name, code: o.code, message: typeof o.message === "string" ? o.message : String(err) };
80
+ }
81
+ return { message: String(err) };
82
+ }
83
+
84
+ /** Type first, text second: a `ContainerUnavailableError` (by name or code) or
85
+ * any message `isFleetBusy` recognizes. */
86
+ export function isFleetBusyError(err: unknown): boolean {
87
+ const s = thrownShape(err);
88
+ return (
89
+ s.name === FLEET_BUSY_ERROR_NAME || s.code === "CONTAINER_UNAVAILABLE" || (!!s.message && isFleetBusy(s.message))
90
+ );
91
+ }
92
+
93
+ /** The 0.12.x Durable Object's answer while its container is still booting:
94
+ * no session exists yet and nothing ran, so the SAME request can be re-sent
95
+ * after a short pause — the one retry the Worker still does itself. */
96
+ export const CONTAINER_STARTING_PATTERN = /^Container is starting\. Please retry in a moment\.?$/i;
97
+
98
+ export function isContainerStarting(err: unknown): boolean {
99
+ const { message } = thrownShape(err);
100
+ return !!message && CONTAINER_STARTING_PATTERN.test(message.trim());
101
+ }
102
+
103
+ /** The Worker's answer on /read and /write (sent as HTTP 503): the named
104
+ * reason plus an `error` that keeps the SDK's own message as the cause, so
105
+ * the logs and the model can still see what the platform actually said. */
106
+ export function fleetBusyAnswer(cause: string): { error: string; reason: typeof FLEET_BUSY_REASON } {
107
+ return { error: `${FLEET_BUSY_REASON}: ${FLEET_BUSY_EXPLANATION} (${cause})`, reason: FLEET_BUSY_REASON };
108
+ }
109
+
110
+ /** The Worker's answer on /exec, in-body under the streamed HTTP 200 like every
111
+ * other exec failure: the dual `error` + exit-127/stderr shape (item 3) so an
112
+ * executor that predates in-body errors still renders it, plus the reason. */
113
+ export function fleetBusyExecAnswer(cause: string): {
114
+ error: string;
115
+ reason: typeof FLEET_BUSY_REASON;
116
+ stdout: "";
117
+ stderr: string;
118
+ exitCode: 127;
119
+ } {
120
+ const { error, reason } = fleetBusyAnswer(cause);
121
+ return { error, reason, stdout: "", stderr: error, exitCode: 127 };
122
+ }
123
+
124
+ /** The message `ExecCapacityError` carries once the wait is spent: names the
125
+ * wait and the knob, and tells the reader this is a retry-later condition. */
126
+ export function fleetBusyExhaustedMessage(waitedMs: number): string {
127
+ return (
128
+ `sandbox fleet busy — no free per-thread sandbox after waiting ${Math.round(waitedMs / 1000)}s ` +
129
+ "(the fleet's max_instances is reached); try again in a few minutes"
130
+ );
131
+ }
132
+
133
+ /** The text the Worker carries in-body for a thrown value: the SDK's own
134
+ * message when it has one, else a sentence that says the SDK gave none —
135
+ * naming the error's name and code, and the one condition known to produce
136
+ * it. Never the empty string (docs/reference/specs/execution.md items 3 and 6).
137
+ *
138
+ * Why: during a Worker+image rollout a new thread's Durable Object can land
139
+ * on a container still running the previous (0.3.x) image. The 0.12.x
140
+ * client posts `{command, sessionId}`, the old server
141
+ * answered 400 `{"error": "Session ID and command are required"}`, and the
142
+ * client built a `SandboxError` from a body with no `message` — so
143
+ * `err.message` was `""`. The Worker's `shape.message ?? String(err)` kept
144
+ * the empty string (`??` only fires on null/undefined), every classifier
145
+ * fell through, and seven commands reached the model as silent `exit 127`s
146
+ * that read as a dead shell. Classifiers (`isFleetBusyError`,
147
+ * `isRecycleError`, `recycledMidCommandMessage`) keep reading the raw shape;
148
+ * this is only the text that leaves the Worker. */
149
+ export function thrownText(shape: ThrownShape): string {
150
+ const text = shape.message?.trim() ?? "";
151
+ if (text) return text;
152
+ const who = shape.name
153
+ ? `${shape.name}${shape.code !== undefined ? `, code ${String(shape.code)}` : ""}`
154
+ : "no error name";
155
+ return (
156
+ `sandbox exec failed with no message from the SDK (${who}); the container may still be running a previous image ` +
157
+ "while a Worker/image rollout is in progress — retry in a minute"
158
+ );
159
+ }
@@ -0,0 +1,118 @@
1
+ // Sandbox activity keepalive (docs/reference/specs/execution.md item 2): one in-flight
2
+ // exec never outlives the container's activity timeout.
3
+ //
4
+ // On @cloudflare/containers 0.0.28 the base class kept an activity clock that
5
+ // every proxied fetch renewed ONCE, before the fetch, and an alarm loop
6
+ // stopped the container (SIGTERM) the moment the clock read expired — with no
7
+ // notion of a request still in flight. So a single command running longer
8
+ // than `sleepAfter` had its container killed under it, deterministically, at
9
+ // exactly `sleepAfter`: the command ends in `Command execution failed` and
10
+ // the next one lands in a fresh container with an empty /workspace. Renewing
11
+ // the clock on a timer WHILE a command runs turns `sleepAfter` into what its
12
+ // name says: idle time. The 0.3.x containers class that ships with sandbox
13
+ // 0.12.x counts in-flight requests itself and refuses to expire while one is
14
+ // open, so the keepalive is now belt-and-braces; it stays until a live
15
+ // long-command run proves the SDK's own tracking on our path.
16
+ //
17
+ // Deliberately free of node: imports so wrangler can bundle it into the
18
+ // sandbox Worker.
19
+
20
+ /** How long an IDLE container stays warm before the Durable Object stops it,
21
+ * in the Container class's own `<n>[smh]` grammar. With the keepalive below
22
+ * (and the SDK's own in-flight tracking) this is pure idle time — a running
23
+ * command can never reach it. 5 minutes frees a finished thread's slot
24
+ * (`max_instances`) sooner than the SDK default (20 min on 0.3.x, 10 min on
25
+ * 0.12.x), while a follow-up inside 5 minutes still lands on the same warm
26
+ * workspace; a later one re-clones, which is the documented per-thread
27
+ * degradation (item 1). */
28
+ export const SANDBOX_SLEEP_AFTER = "5m";
29
+
30
+ /** How often a running command renews the activity clock. Well inside
31
+ * SANDBOX_SLEEP_AFTER (the unit tests hold the ordering), so between two
32
+ * renewals the clock always has minutes to spare. */
33
+ export const EXEC_KEEPALIVE_INTERVAL_MS = 60_000;
34
+
35
+ /** The Container class's `sleepAfter` grammar (`"5m"`, `"90s"`, `"1h"`), in
36
+ * milliseconds — so a test can compare the interval against it. */
37
+ export function parseSleepAfterMs(expr: string): number {
38
+ const m = /^(\d+)([smh])$/.exec(expr);
39
+ if (!m) throw new Error(`invalid sleepAfter expression: "${expr}" (expected <n>s, <n>m or <n>h)`);
40
+ const n = Number(m[1]);
41
+ return n * (m[2] === "s" ? 1_000 : m[2] === "m" ? 60_000 : 3_600_000);
42
+ }
43
+
44
+ /** Run `run()` while calling `renew()` every `intervalMs` until it settles —
45
+ * resolve or reject alike. A renew that throws or rejects is swallowed: the
46
+ * keepalive exists to protect the command's result, never to replace it. A
47
+ * command that finishes inside one interval never triggers a renew. */
48
+ export async function withActivityKeepalive<T>(
49
+ renew: () => void | Promise<void>,
50
+ run: () => Promise<T>,
51
+ intervalMs: number,
52
+ ): Promise<T> {
53
+ const timer = setInterval(() => {
54
+ try {
55
+ void Promise.resolve(renew()).catch(() => {});
56
+ } catch {
57
+ // a synchronous throw from renew — same policy as an async rejection
58
+ }
59
+ }, intervalMs);
60
+ try {
61
+ return await run();
62
+ } finally {
63
+ clearInterval(timer);
64
+ }
65
+ }
66
+
67
+ /** The texts the SDK produces when the container is torn down under a
68
+ * command, across generations. 0.3.x: the exec handler's generic wrapper (its
69
+ * real cause, "Session terminated", sat in a field the client discarded), the
70
+ * cause itself, and the stale-session answer the same attempt got once the
71
+ * sessions were cleared. 0.12.x: the typed `SessionTerminatedError` text
72
+ * (`Session '<id>' shell exited (exit code: <n>)`) and the
73
+ * `OperationInterruptedError` text for a container that stopped under a
74
+ * pending call, and the disconnect text for a sandbox `destroy()`ed under a
75
+ * pending call. Anything else — a transport error, a file-op failure — is
76
+ * never recycle-shaped, whenever it arrives. */
77
+ const RECYCLE_SHAPED: readonly RegExp[] = [
78
+ /^Command execution failed$/,
79
+ /^Session terminated$/i,
80
+ /^Session '[^']*' not found$/i,
81
+ /^Session '[^']*' shell exited \(exit code: /i,
82
+ /^The sandbox container stopped while the operation was pending\.?$/i,
83
+ /^The sandbox was destroyed while the operation was pending\.?$/i,
84
+ ];
85
+
86
+ /** The 0.12.x typed errors that MEAN the container went away under the call.
87
+ * Matched by name, not `instanceof`: the Worker sees them after the Durable
88
+ * Object RPC boundary, which keeps `name`/`message` and drops the prototype. */
89
+ export const RECYCLE_ERROR_NAMES: readonly string[] = ["SessionTerminatedError", "OperationInterruptedError"];
90
+
91
+ /** Type first, text second: a typed recycle error, or a recycle-shaped text. */
92
+ export function isRecycleError(err: { name?: string; message?: string }): boolean {
93
+ if (err.name && RECYCLE_ERROR_NAMES.includes(err.name)) return true;
94
+ return !!err.message && RECYCLE_SHAPED.some((re) => re.test(err.message!.trim()));
95
+ }
96
+
97
+ /** Grace inside which a recycle-shaped TEXT is taken at face value: a session
98
+ * that fails to start does so in seconds, not minutes. A typed recycle error
99
+ * needs no grace — the SDK is stating the container stopped. */
100
+ const RECYCLE_SUSPECT_AFTER_MS = 60_000;
101
+
102
+ /** The message `/exec` puts in-body when a command's failure looks like the
103
+ * container was recycled under it: a typed recycle error (`certain`), or a
104
+ * recycle-shaped text that arrived more than a minute into THIS attempt (a
105
+ * startup failure shows in seconds). Any other text, however late, is
106
+ * returned unchanged — timing alone never rewords an unrelated error. The exit
107
+ * code stays 127: it IS an infra failure — the workspace really is gone — and
108
+ * a faked exit 124 would tell the model to shorten a command that was never
109
+ * the problem. */
110
+ export function recycledMidCommandMessage(elapsedMs: number, msg: string, certain = false): string {
111
+ const shaped = RECYCLE_SHAPED.some((re) => re.test(msg.trim()));
112
+ if (!certain && (!shaped || elapsedMs <= RECYCLE_SUSPECT_AFTER_MS)) return msg;
113
+ const secs = Math.round(elapsedMs / 1_000);
114
+ return (
115
+ `sandbox recycled mid-command after ${secs}s — the container was replaced and /workspace is empty; ` +
116
+ `re-clone before continuing (${msg})`
117
+ );
118
+ }
@@ -0,0 +1,8 @@
1
+ /** POSIX single-quote escaping so an arbitrary command survives `bash -c`.
2
+ * Pure and dependency-free — lives in src/ (inside the root tsconfig's
3
+ * rootDir) and is imported across packages by the sandbox proxy Worker
4
+ * (deploy/cloudflare-sandbox/worker.ts; wrangler's bundler follows the
5
+ * relative import), so the tested code IS the shipped code. */
6
+ export function shellQuote(s: string): string {
7
+ return `'${s.replaceAll("'", `'\\''`)}'`;
8
+ }
@@ -0,0 +1,242 @@
1
+ // The MCP server contract shared by the config layer, the bot's service, and
2
+ // the state Worker (docs/reference/specs/mcp-tools.md items 11–17). Node-free and I/O-free:
3
+ // `deploy/cloudflare-memory/worker.ts` imports the ticket and sealed-credential
4
+ // validators by relative path so both ends check one shape. A server is a
5
+ // `Scope` setting (`Scope.mcpServers[name]`, src/config.ts) — resolved through
6
+ // the same `defaults → channel → user` layers as models and instructions —
7
+ // never a record in a parallel store. Credentials are never part of an entry:
8
+ // they are sealed blobs keyed by `<scopeKey>/<name>`, opened only by the bot.
9
+
10
+ /** `none`: no Authorization header. `bearer`: a static token — `tokenEnv` on
11
+ * the bot, or one pasted on the connect page and sealed. `oauth`: OAuth 2.1
12
+ * (item 18) — the connect page sends the person to the server's authorization
13
+ * server; the sealed credential is the token set the callback exchanged. */
14
+ export type McpAuthKind = "none" | "bearer" | "oauth";
15
+
16
+ /** One server as a scope carries it. `tokenEnv` is the static-config way to
17
+ * supply a bearer (an env var on the bot); without it a bearer server's token
18
+ * is the sealed credential the connect page stored. */
19
+ export interface McpServerEntry {
20
+ url: string;
21
+ /** Agents whose runs may see it (default general + research). */
22
+ agents?: string[];
23
+ auth: McpAuthKind;
24
+ tokenEnv?: string;
25
+ /** Who added it at run time (`slack:U…`, `cli:local`); absent for static config. */
26
+ addedBy?: string;
27
+ addedAt?: number;
28
+ }
29
+
30
+ /** The three tiers a server can live in, in precedence order for a name clash. */
31
+ export type McpScopeKind = "org" | "channel" | "user";
32
+
33
+ /** `org` | `channel:<platform-namespaced id>` | `user:<platform-namespaced id>`
34
+ * — the first half of a credential key. */
35
+ export function mcpScopeKey(kind: McpScopeKind, id?: string): string {
36
+ if (kind === "org") return "org";
37
+ if (!id) throw new Error(`${kind} scope needs an id`);
38
+ return `${kind}:${id}`;
39
+ }
40
+
41
+ export function parseMcpScopeKey(key: string): { kind: McpScopeKind; id?: string } | undefined {
42
+ if (key === "org") return { kind: "org" };
43
+ for (const kind of ["channel", "user"] as const) {
44
+ const prefix = `${kind}:`;
45
+ if (key.startsWith(prefix) && key.length > prefix.length) return { kind, id: key.slice(prefix.length) };
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /** `<scopeKey>/<name>` — the credential and ticket key; unique across tiers. */
51
+ export function mcpCredentialKey(scopeKey: string, name: string): string {
52
+ return `${scopeKey}/${name}`;
53
+ }
54
+
55
+ export function splitCredentialKey(key: string): { scopeKey: string; name: string } | undefined {
56
+ const slash = key.lastIndexOf("/");
57
+ if (slash <= 0) return undefined;
58
+ const scopeKey = key.slice(0, slash);
59
+ const name = key.slice(slash + 1);
60
+ return parseMcpScopeKey(scopeKey) && MCP_SERVER_NAME_RE.test(name) ? { scopeKey, name } : undefined;
61
+ }
62
+
63
+ /** An encrypted credential as stored — opaque to the Worker. `keyId` names
64
+ * the KEK generation; the credential key is the GCM additional data. */
65
+ export interface SealedCredential {
66
+ /** `<scopeKey>/<name>` */
67
+ serverId: string;
68
+ keyId: string;
69
+ /** base64: 12-byte IV ‖ AES-256-GCM ciphertext+tag */
70
+ sealed: string;
71
+ updatedAt: number;
72
+ }
73
+
74
+ /** The connect flow's state machine (item 15). One ticket per `mcp add`/
75
+ * `mcp connect`; single use; bound to the requester. */
76
+ export type McpTicketState = "pending" | "opened" | "authorizing" | "completed" | "cancelled";
77
+ /** Every state, for the Worker's route validation — one list, both ends. */
78
+ export const MCP_TICKET_STATES: readonly McpTicketState[] = [
79
+ "pending",
80
+ "opened",
81
+ "authorizing",
82
+ "completed",
83
+ "cancelled",
84
+ ];
85
+
86
+ export interface McpTicket {
87
+ nonce: string;
88
+ /** `<scopeKey>/<name>` */
89
+ serverId: string;
90
+ /** The chat/CLI identity that asked (`slack:U…`, `cli:local`). */
91
+ requesterId: string;
92
+ /** Resolved when the channel could (Slack `users:read.email`); the connect
93
+ * page then requires the Access identity's email to match. */
94
+ requesterEmail?: string;
95
+ createdAt: number;
96
+ expiresAt: number;
97
+ state: McpTicketState;
98
+ /** Without `requesterEmail`, the FIRST Access identity to open the page is
99
+ * bound and the completion must come from the same identity. */
100
+ openedBy?: { sub: string; email?: string; at: number };
101
+ completedBy?: { sub: string; email?: string; at: number };
102
+ /** OAuth (item 18): the pending authorization — PKCE verifier, client id,
103
+ * `state`, endpoints — sealed under the bot's key (AAD `ticket:<nonce>`)
104
+ * while the person is at the authorization server. Set when the ticket
105
+ * enters `authorizing`; opaque to the Worker. */
106
+ oauth?: { keyId: string; sealed: string };
107
+ /** Set with `completed` (item 19): what the completion found, so the thread
108
+ * that asked can be told without re-probing — the bridged tool count, or
109
+ * the verify warning when the server could not be reached. */
110
+ outcome?: { toolCount?: number; warning?: string };
111
+ }
112
+
113
+ export const MCP_TICKET_TTL_MS = 10 * 60_000;
114
+ export const MCP_SERVER_NAME_MAX = 32;
115
+ export const MCP_SERVER_NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
116
+ export const MCP_URL_MAX = 2_048;
117
+ export const MCP_AGENTS_MAX = 8;
118
+ /** Agents a CHANNEL- or USER-scoped server may reach — never the code-writing
119
+ * or read-only-by-contract agents (item 14); only an org server (admins) may. */
120
+ export const MCP_SELF_SERVE_AGENTS: readonly string[] = ["general", "research"];
121
+ export const MCP_TOKEN_MAX_CHARS = 8_192;
122
+ export const MCP_SERVERS_PER_SCOPE_MAX = 32;
123
+
124
+ // ---- structural validators (both ends) --------------------------------------
125
+
126
+ const isStr = (v: unknown, max = 4_096): v is string => typeof v === "string" && v.length > 0 && v.length <= max;
127
+ const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
128
+
129
+ function isOutcome(v: unknown): v is NonNullable<McpTicket["outcome"]> {
130
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
131
+ const o = v as Record<string, unknown>;
132
+ return (
133
+ (o.toolCount === undefined || (isNum(o.toolCount) && o.toolCount >= 0)) &&
134
+ (o.warning === undefined || isStr(o.warning, 1_024))
135
+ );
136
+ }
137
+
138
+ /** Shape only (the config layer adds the semantic checks: SSRF, known agents, tier rules). */
139
+ export function isMcpServerEntry(v: unknown): v is McpServerEntry {
140
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
141
+ const e = v as Record<string, unknown>;
142
+ return (
143
+ isStr(e.url, MCP_URL_MAX) &&
144
+ (e.agents === undefined ||
145
+ (Array.isArray(e.agents) &&
146
+ e.agents.length > 0 &&
147
+ e.agents.length <= MCP_AGENTS_MAX &&
148
+ e.agents.every((a) => isStr(a, 32)))) &&
149
+ (e.auth === "none" || e.auth === "bearer" || e.auth === "oauth") &&
150
+ (e.tokenEnv === undefined || isStr(e.tokenEnv, 128)) &&
151
+ (e.addedBy === undefined || isStr(e.addedBy, 260)) &&
152
+ (e.addedAt === undefined || isNum(e.addedAt))
153
+ );
154
+ }
155
+
156
+ export function isSealedCredential(v: unknown): v is SealedCredential {
157
+ if (!v || typeof v !== "object") return false;
158
+ const c = v as Record<string, unknown>;
159
+ return isStr(c.serverId, 400) && isStr(c.keyId, 64) && isStr(c.sealed, 64 * 1024) && isNum(c.updatedAt);
160
+ }
161
+
162
+ export function isMcpTicket(v: unknown): v is McpTicket {
163
+ if (!v || typeof v !== "object") return false;
164
+ const t = v as Record<string, unknown>;
165
+ const actor = (a: unknown) =>
166
+ a === undefined ||
167
+ (!!a && typeof a === "object" && isStr((a as { sub?: unknown }).sub, 260) && isNum((a as { at?: unknown }).at));
168
+ return (
169
+ isStr(t.nonce, 128) &&
170
+ /^[A-Za-z0-9_-]{16,128}$/.test(t.nonce) &&
171
+ isStr(t.serverId, 400) &&
172
+ isStr(t.requesterId, 260) &&
173
+ (t.requesterEmail === undefined || isStr(t.requesterEmail, 320)) &&
174
+ isNum(t.createdAt) &&
175
+ isNum(t.expiresAt) &&
176
+ MCP_TICKET_STATES.includes(t.state as McpTicketState) &&
177
+ actor(t.openedBy) &&
178
+ actor(t.completedBy) &&
179
+ (t.oauth === undefined ||
180
+ (!!t.oauth &&
181
+ typeof t.oauth === "object" &&
182
+ isStr((t.oauth as { keyId?: unknown }).keyId, 64) &&
183
+ isStr((t.oauth as { sealed?: unknown }).sealed, 64 * 1024))) &&
184
+ (t.outcome === undefined || isOutcome(t.outcome))
185
+ );
186
+ }
187
+
188
+ /** A server as surfaces show it: never a credential, and the URL reduced to
189
+ * its origin + path (a query string could carry a key). */
190
+ export interface McpServerView {
191
+ name: string;
192
+ scope: McpScopeKind;
193
+ scopeKey: string;
194
+ url: string;
195
+ agents: string[];
196
+ auth: McpAuthKind;
197
+ /** `static` = bearer from `tokenEnv` on the bot; `connected` = a sealed
198
+ * credential is stored (or no credential is needed); `awaiting_credential`
199
+ * = bearer, nothing stored yet. */
200
+ state: "connected" | "awaiting_credential" | "static";
201
+ source: "config" | "runtime";
202
+ addedBy?: string;
203
+ addedAt?: number;
204
+ }
205
+
206
+ export function serverView(
207
+ scopeKey: string,
208
+ name: string,
209
+ entry: McpServerEntry,
210
+ opts: { hasCredential: boolean; source: "config" | "runtime" },
211
+ ): McpServerView {
212
+ const parsed = parseMcpScopeKey(scopeKey);
213
+ const state: McpServerView["state"] =
214
+ entry.auth === "none"
215
+ ? "connected"
216
+ : entry.tokenEnv
217
+ ? "static"
218
+ : opts.hasCredential
219
+ ? "connected"
220
+ : "awaiting_credential";
221
+ return {
222
+ name,
223
+ scope: parsed?.kind ?? "org",
224
+ scopeKey,
225
+ url: safeUrl(entry.url),
226
+ agents: [...(entry.agents ?? MCP_SELF_SERVE_AGENTS)],
227
+ auth: entry.auth,
228
+ state,
229
+ source: opts.source,
230
+ ...(entry.addedBy ? { addedBy: entry.addedBy } : {}),
231
+ ...(entry.addedAt !== undefined ? { addedAt: entry.addedAt } : {}),
232
+ };
233
+ }
234
+
235
+ export function safeUrl(raw: string): string {
236
+ try {
237
+ const u = new URL(raw);
238
+ return `${u.origin}${u.pathname}`;
239
+ } catch {
240
+ return "(invalid url)";
241
+ }
242
+ }