@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.
- package/LICENSE +201 -0
- package/README.md +18 -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-D3shEnzl.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.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-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -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,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
|
+
}
|