@coreplane/switchboard 1.226.1 → 1.228.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/dist/assets/deploy/cloudflare-memory/worker.ts +157 -3
- package/dist/assets/deploy/cloudflare-resident/worker.ts +354 -9
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +25 -14
- package/dist/assets/deploy/cloudflare-sandbox/package.json +1 -1
- package/dist/assets/deploy/cloudflare-sandbox/runtime-supervisor.sh +68 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +343 -261
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +2 -2
- package/dist/assets/package-lock.json +7 -48
- package/dist/assets/package.json +1 -1
- package/dist/assets/project.json +2 -2
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +15 -0
- package/dist/assets/src/core/authz/policy.ts +8 -0
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/coordinator/driver.ts +8 -6
- package/dist/assets/src/core/runEvents.ts +50 -3
- package/dist/assets/src/core/runFriction.ts +2 -1
- package/dist/assets/src/core/runRecord.ts +107 -3
- package/dist/assets/src/core/runUsage.ts +199 -0
- package/dist/assets/src/core/ship/coordinator.ts +4 -2
- package/dist/assets/src/execution/residentRebind.ts +310 -0
- package/dist/assets/src/execution/residentSteps.ts +1 -0
- package/dist/assets/src/execution/sandboxErrors.ts +85 -10
- package/dist/assets/src/execution/sandboxLifecycle.ts +78 -0
- package/dist/assets/web/dist/.vite/manifest.json +18 -18
- package/dist/assets/web/dist/assets/{ResidentDetailPage-BBOpejGX.js → ResidentDetailPage-B1Q9pabX.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-B8kFWHpB.js → ResidentsIndexPage-CP7U_4aK.js} +1 -1
- package/dist/assets/web/dist/assets/{RunRoutePage-COWeVtIE.js → RunRoutePage-dCC25f_b.js} +4 -4
- package/dist/assets/web/dist/assets/{RunsIndexPage-BjH93cKx.js → RunsIndexPage-Cgp4t4C8.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-grlNKvCA.js → ScheduledPage-DthDA2xG.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-BQNoaXO6.js → StatusDot-DDc88Kbs.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-DVCeIzaa.js → Tooltip-CBapNhsh.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-B5Wfk-oY.js → dist-BnwSD1cL.js} +1 -1
- package/dist/assets/web/dist/assets/{main-CN0U7d6s.js → main-ZhQGbZ2E.js} +2 -2
- package/dist/cli.js +1277 -344
- package/package.json +1 -1
- package/dist/assets/src/execution/sandboxKeepalive.ts +0 -118
|
@@ -13,10 +13,21 @@
|
|
|
13
13
|
// for that one command — in the body, never in headers, because Workers Logs
|
|
14
14
|
// record request headers (docs/reference/specs/execution.md item 5).
|
|
15
15
|
//
|
|
16
|
-
// The Sandbox SDK only runs inside Workers — that's why this proxy exists.
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
16
|
+
// The Sandbox SDK only runs inside Workers — that's why this proxy exists. The
|
|
17
|
+
// Durable Object below does the work (one supervised process per command, the
|
|
18
|
+
// file reads and writes) and answers the fetch handler over RPC with plain
|
|
19
|
+
// data; the handler authenticates, streams, and names what came back.
|
|
20
|
+
import {
|
|
21
|
+
OperationInterruptedError,
|
|
22
|
+
ProcessWaitTimeoutError,
|
|
23
|
+
RPCTransportError,
|
|
24
|
+
RuntimeIdentityInactiveError,
|
|
25
|
+
Sandbox,
|
|
26
|
+
StaleProcessHandleError,
|
|
27
|
+
getSandbox,
|
|
28
|
+
type SandboxCommand,
|
|
29
|
+
} from "@cloudflare/sandbox";
|
|
30
|
+
import { createExtensionProcessSandbox } from "@cloudflare/sandbox/extensions";
|
|
20
31
|
import { BASH_TIMEOUT_MAX_MS, clampBashTimeout } from "../../src/execution/bashTimeout.js";
|
|
21
32
|
import {
|
|
22
33
|
base64ByteLength,
|
|
@@ -27,19 +38,20 @@ import {
|
|
|
27
38
|
type Base64ReadAnswer,
|
|
28
39
|
} from "../../src/execution/binaryRead.js";
|
|
29
40
|
import {
|
|
30
|
-
EXEC_KEEPALIVE_INTERVAL_MS,
|
|
31
41
|
SANDBOX_SLEEP_AFTER,
|
|
32
42
|
isRecycleError,
|
|
33
43
|
recycledMidCommandMessage,
|
|
34
|
-
|
|
35
|
-
} from "../../src/execution/sandboxKeepalive.js";
|
|
44
|
+
} from "../../src/execution/sandboxLifecycle.js";
|
|
36
45
|
import { envFromRequest } from "../../src/execution/sandboxEnv.js";
|
|
37
|
-
import {
|
|
46
|
+
import { RUNTIME_REPLACEMENT_WORDING, isRuntimeUnreachableSignal } from "../../src/execution/residentRefresh.js";
|
|
38
47
|
import {
|
|
39
48
|
fleetBusyAnswer,
|
|
40
49
|
fleetBusyExecAnswer,
|
|
41
|
-
isContainerStarting,
|
|
42
50
|
isFleetBusyError,
|
|
51
|
+
isRuntimeUnreachableError,
|
|
52
|
+
runtimeUnreachableAnswer,
|
|
53
|
+
runtimeUnreachableExecAnswer,
|
|
54
|
+
SandboxRuntimeUnreachableError,
|
|
43
55
|
thrownShape,
|
|
44
56
|
thrownText,
|
|
45
57
|
} from "../../src/execution/sandboxErrors.js";
|
|
@@ -52,137 +64,286 @@ import sandboxPkg from "./package.json" with { type: "json" };
|
|
|
52
64
|
|
|
53
65
|
/** The `@cloudflare/sandbox` version this Worker is built against — the pin
|
|
54
66
|
* `check:sandbox-pair` holds equal to the Dockerfile's image tag, so it is
|
|
55
|
-
* also the version a container on the CURRENT image
|
|
67
|
+
* also the version a container on the CURRENT image runs. */
|
|
56
68
|
const SDK_PIN: string = sandboxPkg.dependencies["@cloudflare/sandbox"];
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
const WORKDIR = "/workspace";
|
|
71
|
+
|
|
72
|
+
// Default per-command time limit, enforced by coreutils `timeout` inside the
|
|
73
|
+
// sandbox. The tuned 280s applies when the body carries no timeoutMs (an older
|
|
74
|
+
// bot); a caller-supplied timeoutMs is clamped server-side to the shared
|
|
75
|
+
// [1s, 20 min] bounds (clampBashTimeout — never trust the client's number).
|
|
76
|
+
// Whatever the effective limit, it must stay BELOW the SDK's own backstops so
|
|
77
|
+
// the real exit 124 wins: the per-process `timeout` this Worker launches with
|
|
78
|
+
// (the limit plus SDK_BACKSTOP_MARGIN_MS) and the container's
|
|
79
|
+
// COMMAND_TIMEOUT_MS (Dockerfile, above the 20-min ceiling).
|
|
80
|
+
const EXEC_TIMEOUT_SECS = 280;
|
|
81
|
+
|
|
82
|
+
/** How far above coreutils `timeout`'s deadline the SDK's own per-process
|
|
83
|
+
* limit sits: room for `-k 10`'s SIGKILL follow-up and the exit to land, so
|
|
84
|
+
* the shell-level 124 is always the deadline a command meets first. */
|
|
85
|
+
const SDK_BACKSTOP_MARGIN_MS = 40_000;
|
|
86
|
+
|
|
87
|
+
/** How long the Durable Object waits for the process's exit past the SDK's
|
|
88
|
+
* own limit before it kills the process itself and reports the timeout: the
|
|
89
|
+
* runtime should have ended it at the limit; a starved container ends it late. */
|
|
90
|
+
const OUTPUT_WAIT_MARGIN_MS = 30_000;
|
|
91
|
+
|
|
92
|
+
/** The interruption reasons that mean the container's runtime is not the one
|
|
93
|
+
* the command started on: the process, if it started, is gone with its
|
|
94
|
+
* output. `transport_disposed` is the Durable Object's own connection going
|
|
95
|
+
* away and is not one of them. */
|
|
96
|
+
const RUNTIME_REPLACED_REASONS = new Set([
|
|
97
|
+
"runtime_replaced",
|
|
98
|
+
"container_stopped",
|
|
99
|
+
"sandbox_destroyed",
|
|
100
|
+
"sandbox_lifetime_changed",
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
/** The transport losses that mean the same: the control connection's peer
|
|
104
|
+
* closed (a runtime crash or stop), the socket failed, the upgrade failed. */
|
|
105
|
+
const RPC_TRANSPORT_LOSS_KINDS = new Set(["peer_closed", "connection_failed", "upgrade_failed", "session_disposed"]);
|
|
106
|
+
|
|
107
|
+
/** `err` and its `cause` chain, bounded like the SDK's own walk. */
|
|
108
|
+
function* selfAndCauses(err: unknown): Generator<unknown> {
|
|
109
|
+
let link: unknown = err;
|
|
110
|
+
for (let depth = 0; link !== null && link !== undefined && depth < 8; depth++) {
|
|
111
|
+
yield link;
|
|
112
|
+
link = typeof link === "object" ? (link as { cause?: unknown }).cause : undefined;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Did the container's runtime change under the command? Typed first (the
|
|
117
|
+
* Durable Object sees the SDK's own classes, so `instanceof` holds here), the
|
|
118
|
+
* SDK's replacement wording second — the same list the resident Worker
|
|
119
|
+
* classifies its own execs with. */
|
|
120
|
+
function isRuntimeReplacement(err: unknown): boolean {
|
|
121
|
+
if (err instanceof StaleProcessHandleError) return true;
|
|
122
|
+
if (err instanceof RuntimeIdentityInactiveError) return true;
|
|
123
|
+
if (err instanceof OperationInterruptedError && RUNTIME_REPLACED_REASONS.has(err.reason)) return true;
|
|
124
|
+
if (err instanceof RPCTransportError && RPC_TRANSPORT_LOSS_KINDS.has(err.kind)) return true;
|
|
125
|
+
for (const link of selfAndCauses(err)) {
|
|
126
|
+
if (RUNTIME_REPLACEMENT_WORDING.test(thrownShape(link).message ?? "")) return true;
|
|
127
|
+
}
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Did the container's control port never answer? The SDK's connect abort
|
|
132
|
+
* (30 s), anywhere in the cause chain. Asked only after `isRuntimeReplacement`. */
|
|
133
|
+
function isRuntimeUnreachable(err: unknown): boolean {
|
|
134
|
+
for (const link of selfAndCauses(err)) if (isRuntimeUnreachableSignal(link)) return true;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A finished command, as `/exec` answers it. `durationMs` is the command's
|
|
139
|
+
* wall time in the sandbox (docs/reference/specs/tracing.md item 19). */
|
|
140
|
+
export interface ExecAnswer {
|
|
141
|
+
stdout: string;
|
|
142
|
+
stderr: string;
|
|
143
|
+
exitCode: number;
|
|
144
|
+
durationMs: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A command the sandbox never answered for, in the dual in-body shape
|
|
148
|
+
* (docs/reference/specs/execution.md item 3): a new executor throws on `error`, an
|
|
149
|
+
* older one still renders `exit 127: <stderr>`. `reason` names the machine
|
|
150
|
+
* token when there is one (`fleet-busy`, `runtime-unreachable`). */
|
|
151
|
+
export interface ExecFailure {
|
|
152
|
+
error: string;
|
|
153
|
+
reason?: string;
|
|
154
|
+
stdout: "";
|
|
155
|
+
stderr: string;
|
|
156
|
+
exitCode: 127;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A file route's refusal, with the HTTP status the fetch handler answers and,
|
|
160
|
+
* when the refusal is a named condition the executor waits on (`fleet-busy`,
|
|
161
|
+
* `runtime-unreachable`), its machine token — the executor reads the token,
|
|
162
|
+
* never the text, so a refusal without it is a dead sandbox to it. */
|
|
163
|
+
interface FileRefusal {
|
|
164
|
+
error: string;
|
|
165
|
+
status: number;
|
|
166
|
+
reason?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export class SwitchboardSandbox extends Sandbox<Env> {
|
|
170
|
+
// Idle lifetime of a thread's container (the SDK's own default is 10 min).
|
|
171
|
+
// Idle means idle: the SDK renews the activity timeout every second while a
|
|
172
|
+
// command's stream is open (its control connection's busy poll), so a
|
|
173
|
+
// running command never counts toward it and the shell-level `timeout` is
|
|
174
|
+
// the one deadline a command can hit (docs/reference/specs/execution.md item 2).
|
|
175
|
+
// 5 minutes frees the slot sooner while a prompt follow-up still reuses the
|
|
176
|
+
// warm workspace.
|
|
72
177
|
sleepAfter = SANDBOX_SLEEP_AFTER;
|
|
73
178
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
);
|
|
179
|
+
/** One command as one supervised process (docs/reference/specs/execution.md
|
|
180
|
+
* item 4): `timeout -k 10 <secs> bash -c 'mkdir -p /workspace && cd
|
|
181
|
+
* /workspace && <command>'`, the caller's env on the process alone (item 5),
|
|
182
|
+
* the SDK's own per-process limit a margin above the shell's. The process
|
|
183
|
+
* is started and collected here, inside the Durable Object, where the
|
|
184
|
+
* SDK's error classes are still themselves — so a full fleet, a silent
|
|
185
|
+
* control port and a runtime replaced under the command are named by type
|
|
186
|
+
* and answered as data; anything else propagates to the fetch handler. */
|
|
187
|
+
async runCommand(
|
|
188
|
+
command: string,
|
|
189
|
+
execTimeoutSecs: number,
|
|
190
|
+
envVars: Record<string, string>,
|
|
191
|
+
): Promise<ExecAnswer | ExecFailure> {
|
|
192
|
+
const startedAt = systemClock();
|
|
193
|
+
const full = `mkdir -p ${WORKDIR} && cd ${WORKDIR} && ${command}`;
|
|
194
|
+
const argv: SandboxCommand = ["timeout", "-k", "10", String(execTimeoutSecs), "bash", "-c", full];
|
|
195
|
+
const backstopMs = execTimeoutSecs * 1000 + SDK_BACKSTOP_MARGIN_MS;
|
|
196
|
+
let proc: Awaited<ReturnType<ReturnType<typeof createExtensionProcessSandbox>["exec"]>>;
|
|
197
|
+
try {
|
|
198
|
+
proc = await createExtensionProcessSandbox(this).exec(argv, { env: envVars, timeout: backstopMs });
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return this.execFailure(err, startedAt);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const out = await proc.output({ encoding: "utf8", timeout: backstopMs + OUTPUT_WAIT_MARGIN_MS });
|
|
204
|
+
// coreutils `timeout` exits 124 when the deadline killed the command
|
|
205
|
+
// (137 when the follow-up SIGKILL had to); the SDK's own limit, if it
|
|
206
|
+
// ever wins, reports `timedOut`. All three are the one story.
|
|
207
|
+
const timedOut = out.timedOut || out.exitCode === 124 || out.exitCode === 137;
|
|
208
|
+
const notes: string[] = [];
|
|
209
|
+
if (timedOut) notes.push(timeoutNote(execTimeoutSecs));
|
|
210
|
+
// The SDK cut the process's log past its own retention: the output
|
|
211
|
+
// here is a prefix, whatever the executor's caps say.
|
|
212
|
+
if (out.truncated) notes.push("output truncated by the sandbox runtime — the streams above are a prefix");
|
|
213
|
+
return {
|
|
214
|
+
stdout: out.stdout,
|
|
215
|
+
stderr: [out.stderr, ...notes].filter(Boolean).join("\n"),
|
|
216
|
+
exitCode: timedOut ? 124 : out.exitCode,
|
|
217
|
+
durationMs: systemClock() - startedAt,
|
|
218
|
+
};
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (err instanceof ProcessWaitTimeoutError) {
|
|
221
|
+
// The runtime should have ended the process at the SDK's limit and
|
|
222
|
+
// did not; abandoning a live process would leave it running in the
|
|
223
|
+
// workspace. End it, then report the shell-level timeout it exceeded.
|
|
224
|
+
await proc.kill(9).catch(() => {});
|
|
225
|
+
return {
|
|
226
|
+
stdout: "",
|
|
227
|
+
stderr: `${timeoutNote(execTimeoutSecs)}\n(the process outlived the sandbox's own limit and was killed)`,
|
|
228
|
+
exitCode: 124,
|
|
229
|
+
durationMs: systemClock() - startedAt,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return this.execFailure(err, startedAt);
|
|
96
233
|
}
|
|
97
234
|
}
|
|
98
235
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
236
|
+
/** The named failures, as `/exec` data; anything else is thrown as it came. */
|
|
237
|
+
private execFailure(err: unknown, startedAt: number): ExecFailure {
|
|
238
|
+
const raw = thrownText(thrownShape(err));
|
|
239
|
+
// A full fleet (docs/reference/specs/execution.md item 14): no container
|
|
240
|
+
// instance for this thread, so nothing started — the executor waits.
|
|
241
|
+
if (isFleetBusyError(err)) return fleetBusyExecAnswer(raw);
|
|
242
|
+
// The runtime changed under the command (item 9): the process, if it
|
|
243
|
+
// started, is gone with its output. Certain — the SDK said so by type.
|
|
244
|
+
if (isRuntimeReplacement(err)) {
|
|
245
|
+
const msg = recycledMidCommandMessage(systemClock() - startedAt, raw, true);
|
|
246
|
+
return { error: msg, stdout: "", stderr: msg, exitCode: 127 };
|
|
247
|
+
}
|
|
248
|
+
// The control port never answered (item 9): nothing ran.
|
|
249
|
+
if (isRuntimeUnreachable(err)) return runtimeUnreachableExecAnswer(this.runtimeUnreachable(raw).message);
|
|
250
|
+
throw err;
|
|
105
251
|
}
|
|
106
252
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// sandbox.
|
|
118
|
-
resetDefaultSession(): void {
|
|
119
|
-
(this as unknown as { defaultSession: unknown }).defaultSession = null;
|
|
253
|
+
/** The typed, named error for a silent control port, with this container's
|
|
254
|
+
* facts — thrown across the RPC boundary to the fetch handler on the file
|
|
255
|
+
* routes, where it is matched by name. */
|
|
256
|
+
private runtimeUnreachable(cause: string): SandboxRuntimeUnreachableError {
|
|
257
|
+
return new SandboxRuntimeUnreachableError({
|
|
258
|
+
containerId: this.ctx.id.toString(),
|
|
259
|
+
running: this.ctx.container?.running,
|
|
260
|
+
sdkVersion: SDK_PIN,
|
|
261
|
+
cause,
|
|
262
|
+
});
|
|
120
263
|
}
|
|
121
|
-
}
|
|
122
264
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const CONTAINER_STARTING_RETRY_DELAY_MS = 3_000;
|
|
135
|
-
|
|
136
|
-
async function withSessionRecovery<T>(
|
|
137
|
-
sandbox: { resetDefaultSession(): void | Promise<void> },
|
|
138
|
-
fn: () => Promise<T>,
|
|
139
|
-
): Promise<T> {
|
|
140
|
-
try {
|
|
141
|
-
return await fn();
|
|
142
|
-
} catch (err) {
|
|
143
|
-
const { message } = thrownShape(err);
|
|
144
|
-
if (message && STALE_SESSION.test(message)) {
|
|
145
|
-
await sandbox.resetDefaultSession();
|
|
146
|
-
return await fn();
|
|
265
|
+
/** A file operation with its runtime failures named for the fetch handler:
|
|
266
|
+
* a silent control port becomes the typed error (item 9); a missing file
|
|
267
|
+
* is the refusal the route answers 404. The SDK's other errors propagate. */
|
|
268
|
+
private async fileOp<T>(op: () => Promise<T>): Promise<T | FileRefusal> {
|
|
269
|
+
try {
|
|
270
|
+
return await op();
|
|
271
|
+
} catch (err) {
|
|
272
|
+
const shape = thrownShape(err);
|
|
273
|
+
if (shape.name === "FileNotFoundError") return { error: `read-failed: ${thrownText(shape)}`, status: 404 };
|
|
274
|
+
if (!isRuntimeReplacement(err) && isRuntimeUnreachable(err)) throw this.runtimeUnreachable(thrownText(shape));
|
|
275
|
+
throw err;
|
|
147
276
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async readText(path: string): Promise<{ content: string } | FileRefusal> {
|
|
280
|
+
return this.fileOp(async () => ({ content: (await this.readFile(path, { encoding: "utf-8" })).content }));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** `encoding: "base64"` (src/execution/binaryRead.ts): the size first, from
|
|
284
|
+
* `stat`, so the cap is judged before any read and the client can hold the
|
|
285
|
+
* decoded bytes to it — an SDK read that came back short would otherwise
|
|
286
|
+
* pass as the file. A file over the cap is refused by name inside a 200. */
|
|
287
|
+
async readBase64(path: string): Promise<Base64ReadAnswer | FileRefusal> {
|
|
288
|
+
const stat = await this.runCommand(statCommandFor(path), 60, {});
|
|
289
|
+
if ("error" in stat) {
|
|
290
|
+
// The stat's own named failure — a full fleet, a silent control port —
|
|
291
|
+
// is the read's, token included, so the executor waits as it would
|
|
292
|
+
// have for the text read.
|
|
293
|
+
return stat.reason ? { error: stat.error, status: 503, reason: stat.reason } : { error: stat.error, status: 500 };
|
|
151
294
|
}
|
|
152
|
-
|
|
295
|
+
if (stat.exitCode !== 0) return { error: `read-failed: ${(stat.stderr || stat.stdout).trim()}`, status: 404 };
|
|
296
|
+
const size = parseByteSize(stat.stdout);
|
|
297
|
+
if (size === null) return { error: `read-failed: stat answered ${JSON.stringify(stat.stdout)}`, status: 500 };
|
|
298
|
+
if (size > MAX_READ_BYTES) return { encoding: "base64", tooLarge: true } satisfies Base64ReadAnswer;
|
|
299
|
+
return this.fileOp(async () => {
|
|
300
|
+
const content = (await this.readFile(path, { encoding: "base64" })).content;
|
|
301
|
+
const got = base64ByteLength(content);
|
|
302
|
+
// 409, not 5xx: the client retries a 5xx over 30 s, and a short read is
|
|
303
|
+
// answered by the caller re-reading, not by waiting.
|
|
304
|
+
if (got !== size) {
|
|
305
|
+
return { error: `read-inconsistent: ${path} is ${size} bytes but the read returned ${got}`, status: 409 };
|
|
306
|
+
}
|
|
307
|
+
return { encoding: "base64", content, size } satisfies Base64ReadAnswer;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async write(path: string, content: string): Promise<{ ok: true } | FileRefusal> {
|
|
312
|
+
return this.fileOp(async () => {
|
|
313
|
+
await this.writeFile(path, content);
|
|
314
|
+
return { ok: true as const };
|
|
315
|
+
});
|
|
153
316
|
}
|
|
154
317
|
}
|
|
155
318
|
|
|
319
|
+
/** The stderr line an exit 124 carries: the limit, the knob, and the way to
|
|
320
|
+
* outlive a command (`setsid -f`: every /exec runs under `timeout … bash -c`,
|
|
321
|
+
* whose process group is reaped when the command returns, so a plain
|
|
322
|
+
* background job dies with it). */
|
|
323
|
+
function timeoutNote(execTimeoutSecs: number): string {
|
|
324
|
+
return (
|
|
325
|
+
`command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
|
|
326
|
+
"re-run as smaller/faster steps, or start it detached with `setsid -f sh -c '<command> > /tmp/job.log 2>&1'` and poll the log on later calls"
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
156
330
|
interface Env {
|
|
157
331
|
Sandbox: DurableObjectNamespace<SwitchboardSandbox>;
|
|
158
332
|
SANDBOX_TOKEN: string;
|
|
159
333
|
}
|
|
160
334
|
|
|
161
|
-
const WORKDIR = "/workspace";
|
|
162
|
-
|
|
163
|
-
// Default per-command time limit, enforced by coreutils `timeout` inside the
|
|
164
|
-
// sandbox. The tuned 280s applies when the body carries no timeoutMs (an older
|
|
165
|
-
// bot); a caller-supplied timeoutMs is clamped server-side to the shared
|
|
166
|
-
// [1s, 20 min] bounds (clampBashTimeout — never trust the client's number).
|
|
167
|
-
// Whatever the effective limit, it must stay BELOW the SDK backstop
|
|
168
|
-
// (COMMAND_TIMEOUT_MS in the Dockerfile, sized above the 20-min ceiling) so
|
|
169
|
-
// the real exit 124 wins. undici's 300s no-headers ceiling stopped mattering
|
|
170
|
-
// once /exec streamed heartbeats — headers go out immediately.
|
|
171
|
-
const EXEC_TIMEOUT_SECS = 280;
|
|
172
|
-
|
|
173
335
|
/** The commit this bundle was built from, injected by the deploy
|
|
174
336
|
* (`deploy/bin/build-stamp.mjs`) and answered on GET /healthz as `build`. */
|
|
175
337
|
const BUILD = injectedBuildStamp();
|
|
176
338
|
|
|
177
339
|
// The Worker's own spans (docs/reference/specs/tracing.md item 22): one `sandbox.exec`
|
|
178
|
-
// root per command,
|
|
179
|
-
//
|
|
340
|
+
// root per command, joining the bot's trace (the bearer checked out before the
|
|
341
|
+
// header is read).
|
|
180
342
|
const tracer = createTracer({ clock: systemClock });
|
|
181
343
|
const traceSinks = [workerLogSink((line) => console.log(line))];
|
|
182
344
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return startAdoptedRoot(tracer, "sandbox.exec", { sinks: traceSinks, startedAt: attemptStartedAt, traceparent });
|
|
345
|
+
function execRoot(startedAt: number, traceparent: string | undefined) {
|
|
346
|
+
return startAdoptedRoot(tracer, "sandbox.exec", { sinks: traceSinks, startedAt, traceparent });
|
|
186
347
|
}
|
|
187
348
|
|
|
188
349
|
export default {
|
|
@@ -203,57 +364,26 @@ export default {
|
|
|
203
364
|
const threadKey = request.headers.get("x-thread-key");
|
|
204
365
|
if (!threadKey) return json({ error: "missing X-Thread-Key" }, 400);
|
|
205
366
|
|
|
206
|
-
// One sandbox per thread; the DO name is the thread key.
|
|
207
|
-
//
|
|
208
|
-
//
|
|
367
|
+
// One sandbox per thread; the DO name is the thread key. The stub's own
|
|
368
|
+
// methods (`runCommand`, `readText`, …) are what the routes call — the
|
|
369
|
+
// work happens in the Durable Object, the data comes back over RPC.
|
|
209
370
|
const sandbox = getSandbox(env.Sandbox, threadKey);
|
|
210
371
|
|
|
211
372
|
const url = new URL(request.url);
|
|
212
373
|
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
|
213
374
|
|
|
214
375
|
// Optional env passthrough (e.g. GH_TOKEN), read from the BODY's `env`
|
|
215
|
-
// (docs/reference/specs/execution.md item 5). It then rides in the SDK's per-
|
|
216
|
-
// `env` option, which the
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
// logs never carries a credential.
|
|
222
|
-
//
|
|
223
|
-
// Body, never headers: Workers Logs record this invocation's request
|
|
224
|
-
// headers and redact them by a name heuristic only — a receipt probe's
|
|
225
|
-
// per-variable env header was once logged in clear. Bodies are not
|
|
226
|
-
// recorded. The one-release per-variable-header fallback that carried a
|
|
227
|
-
// body-only bot against a header-only Worker during that rollout is
|
|
228
|
-
// gone now that the body reader is live everywhere, so the
|
|
229
|
-
// credential rides only in the body and no request header is read as env.
|
|
376
|
+
// (docs/reference/specs/execution.md item 5). It then rides in the SDK's per-process
|
|
377
|
+
// `env` option, which the runtime applies to that one command and
|
|
378
|
+
// nothing else. Body, never headers: Workers Logs record this
|
|
379
|
+
// invocation's request headers and redact them by a name heuristic only —
|
|
380
|
+
// a receipt probe's per-variable env header was once logged in clear.
|
|
381
|
+
// Bodies are not recorded.
|
|
230
382
|
const envVars = envFromRequest({ body });
|
|
231
383
|
|
|
232
384
|
try {
|
|
233
385
|
switch (url.pathname) {
|
|
234
386
|
case "/exec": {
|
|
235
|
-
// Streamed with a whitespace heartbeat. A long command otherwise
|
|
236
|
-
// holds a byteless HTTP request open for minutes, and some hop
|
|
237
|
-
// between the bot and this Worker silently drops idle connections —
|
|
238
|
-
// measured live: the container answered a 290s command at its 280s
|
|
239
|
-
// timeout, but the bot's fetch died without ever seeing the
|
|
240
|
-
// response (undici gives up 300s after sending a request that has
|
|
241
|
-
// received no headers). Headers go out immediately and a heartbeat
|
|
242
|
-
// byte flows every 15s, so no intermediary ever sees an idle
|
|
243
|
-
// connection. Heartbeats are pure whitespace, which is legal around
|
|
244
|
-
// a JSON document — the executor's res.json() on the full body
|
|
245
|
-
// parses unchanged.
|
|
246
|
-
//
|
|
247
|
-
// The time limit is enforced with coreutils `timeout` INSIDE the
|
|
248
|
-
// sandbox, not by the SDK: the SDK's COMMAND_TIMEOUT_MS rejection is
|
|
249
|
-
// useless to callers — its exec handler wraps every failure as a
|
|
250
|
-
// generic "Command execution failed" and buries the real message in
|
|
251
|
-
// a field its client discards (measured live). Shell-level timeout
|
|
252
|
-
// produces a real exit 124 through the normal result path, no error
|
|
253
|
-
// classification needed. SIGKILL follows 10s after TERM for
|
|
254
|
-
// stragglers. COMMAND_TIMEOUT_MS (Dockerfile) sits above this as a
|
|
255
|
-
// pure backstop.
|
|
256
|
-
//
|
|
257
387
|
// Per-call budget (docs/reference/specs/execution.md item 11): a numeric
|
|
258
388
|
// timeoutMs in the body is clamped server-side to the shared
|
|
259
389
|
// [1s, 20 min] bounds; absent (an older bot) → the tuned 280s
|
|
@@ -263,64 +393,37 @@ export default {
|
|
|
263
393
|
typeof requested === "number" && Number.isFinite(requested)
|
|
264
394
|
? Math.ceil(clampBashTimeout(requested) / 1000)
|
|
265
395
|
: EXEC_TIMEOUT_SECS;
|
|
266
|
-
const full = `mkdir -p ${WORKDIR} && cd ${WORKDIR} && ${String(body.command ?? "")}`;
|
|
267
396
|
return streamExec(
|
|
268
|
-
sandbox,
|
|
269
|
-
`timeout -k 10 ${execTimeoutSecs} bash -c ${shellQuote(full)}`,
|
|
270
|
-
{ env: envVars },
|
|
271
|
-
execTimeoutSecs,
|
|
397
|
+
() => sandbox.runCommand(String(body.command ?? ""), execTimeoutSecs, envVars),
|
|
272
398
|
request.headers.get("traceparent") ?? undefined,
|
|
273
399
|
);
|
|
274
400
|
}
|
|
275
401
|
case "/read": {
|
|
276
|
-
// `encoding: "base64"` is a binary read (src/execution/binaryRead.ts):
|
|
277
|
-
// the SDK encodes the bytes, and a file over the cap is refused by
|
|
278
|
-
// name inside a 200 — the client counts a non-2xx as a sick Worker.
|
|
279
402
|
const encoding = readEncodingOf(body);
|
|
280
403
|
if (typeof encoding !== "string") return json({ error: encoding.error }, 400);
|
|
281
404
|
const path = abs(String(body.path ?? ""));
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// read and the client can hold the decoded bytes to it — an SDK
|
|
285
|
-
// read that came back short would otherwise pass as the file.
|
|
286
|
-
const stat = await withSessionRecovery(sandbox, () => sandbox.exec(statCommandFor(path)));
|
|
287
|
-
if ((stat.exitCode ?? 0) !== 0) {
|
|
288
|
-
return json({ error: `read-failed: ${String(stat.stderr ?? stat.stdout ?? "").trim()}` }, 404);
|
|
289
|
-
}
|
|
290
|
-
const size = parseByteSize(String(stat.stdout ?? ""));
|
|
291
|
-
if (size === null) return json({ error: `read-failed: stat answered ${JSON.stringify(stat.stdout)}` }, 500);
|
|
292
|
-
if (size > MAX_READ_BYTES) return json({ encoding: "base64", tooLarge: true } satisfies Base64ReadAnswer);
|
|
293
|
-
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path, { encoding: "base64" }));
|
|
294
|
-
const content = typeof file === "string" ? file : (file?.content ?? "");
|
|
295
|
-
const got = base64ByteLength(content);
|
|
296
|
-
if (got !== size) {
|
|
297
|
-
// 409, not 5xx: the client retries a 5xx twice over 30 s, and a
|
|
298
|
-
// short read is answered by the caller re-reading, not by waiting.
|
|
299
|
-
return json({ error: `read-inconsistent: ${path} is ${size} bytes but the read returned ${got}` }, 409);
|
|
300
|
-
}
|
|
301
|
-
return json({ encoding: "base64", content, size } satisfies Base64ReadAnswer);
|
|
302
|
-
}
|
|
303
|
-
const file = await withSessionRecovery(sandbox, () => sandbox.readFile(path));
|
|
304
|
-
return json({ content: typeof file === "string" ? file : (file?.content ?? "") });
|
|
405
|
+
const answer = encoding === "base64" ? await sandbox.readBase64(path) : await sandbox.readText(path);
|
|
406
|
+
return "status" in answer ? refused(answer) : json(answer);
|
|
305
407
|
}
|
|
306
408
|
case "/write": {
|
|
307
|
-
await
|
|
308
|
-
|
|
309
|
-
);
|
|
310
|
-
return json({ ok: true });
|
|
409
|
+
const answer = await sandbox.write(abs(String(body.path ?? "")), String(body.content ?? ""));
|
|
410
|
+
return "status" in answer ? refused(answer) : json(answer);
|
|
311
411
|
}
|
|
312
412
|
default:
|
|
313
413
|
return json({ error: "unknown route" }, 404);
|
|
314
414
|
}
|
|
315
415
|
} catch (err) {
|
|
316
|
-
// Never an empty text (item 3): a message-less SDK error is named as
|
|
317
|
-
// such, with the rollout hint.
|
|
416
|
+
// Never an empty text (item 3): a message-less SDK error is named as such.
|
|
318
417
|
const msg = thrownText(thrownShape(err));
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
//
|
|
418
|
+
// Nothing answered at the container's control port (item 9): the file
|
|
419
|
+
// op never reached a runtime, so a 503 the executor's transport retry
|
|
420
|
+
// re-sends, with the named reason and the container in the text.
|
|
421
|
+
if (isRuntimeUnreachableError(err)) return json(runtimeUnreachableAnswer(msg), 503);
|
|
422
|
+
// A full fleet (docs/reference/specs/execution.md item 14): no container
|
|
423
|
+
// instance for this thread's Durable Object, so the file op never
|
|
424
|
+
// started — re-sending is safe by construction. Named so the executor
|
|
425
|
+
// waits instead of reading it as a dead sandbox; 503 because that is
|
|
426
|
+
// what it is.
|
|
324
427
|
if (isFleetBusyError(err)) return json(fleetBusyAnswer(msg), 503);
|
|
325
428
|
return json({ error: msg }, 500);
|
|
326
429
|
}
|
|
@@ -328,32 +431,26 @@ export default {
|
|
|
328
431
|
} satisfies ExportedHandler<Env>;
|
|
329
432
|
|
|
330
433
|
/** Execute a command and stream the response: immediate headers, a whitespace
|
|
331
|
-
* heartbeat every 15s while the command runs, then one JSON document.
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
},
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
execTimeoutSecs: number,
|
|
343
|
-
traceparent: string | undefined,
|
|
344
|
-
): Response {
|
|
434
|
+
* heartbeat every 15s while the command runs, then one JSON document. A long
|
|
435
|
+
* command otherwise holds a byteless HTTP request open for minutes, and some
|
|
436
|
+
* hop between the bot and this Worker silently drops idle connections
|
|
437
|
+
* (measured live: undici gives up 300s after sending a request that has
|
|
438
|
+
* received no headers). Heartbeats are pure whitespace, which is legal
|
|
439
|
+
* around a JSON document — the executor's res.json() on the full body parses
|
|
440
|
+
* unchanged. All outcomes arrive in-body with HTTP 200 (headers are long
|
|
441
|
+
* gone by the time the result is known): a completed command as {stdout,
|
|
442
|
+
* stderr, exitCode}, a sandbox-enforced timeout as exit 124, and any other
|
|
443
|
+
* failure as {error} (docs/reference/specs/execution.md item 3). */
|
|
444
|
+
function streamExec(run: () => Promise<ExecAnswer | ExecFailure>, traceparent: string | undefined): Response {
|
|
345
445
|
const encoder = new TextEncoder();
|
|
346
|
-
|
|
347
|
-
// second time after a stale-session reset, and a retry's own failure must be
|
|
348
|
-
// judged on its own clock, not the first attempt's.
|
|
349
|
-
let attemptStartedAt = systemClock();
|
|
446
|
+
const startedAt = systemClock();
|
|
350
447
|
const stream = new ReadableStream<Uint8Array>({
|
|
351
448
|
start(controller) {
|
|
352
449
|
const beat = setInterval(() => {
|
|
353
450
|
try {
|
|
354
451
|
controller.enqueue(encoder.encode("\n"));
|
|
355
452
|
} catch {
|
|
356
|
-
clearInterval(beat); // client went away; the
|
|
453
|
+
clearInterval(beat); // client went away; the promise still settles
|
|
357
454
|
}
|
|
358
455
|
}, 15_000);
|
|
359
456
|
const finish = (payload: object) => {
|
|
@@ -365,74 +462,59 @@ function streamExec(
|
|
|
365
462
|
// stream already errored/cancelled — nothing left to deliver to
|
|
366
463
|
}
|
|
367
464
|
};
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
// runs under `timeout … bash -c`, whose process group is reaped when
|
|
380
|
-
// the command returns, so a plain background job dies with it.
|
|
381
|
-
const note = timedOut
|
|
382
|
-
? `command timed out in the sandbox after ${execTimeoutSecs}s (pass the bash tool's timeoutMs for longer commands, max ${BASH_TIMEOUT_MAX_MS} ms); ` +
|
|
383
|
-
"re-run as smaller/faster steps, or start it detached with `setsid -f sh -c '<command> > /tmp/job.log 2>&1'` and poll the log on later calls"
|
|
384
|
-
: "";
|
|
465
|
+
run()
|
|
466
|
+
.then((answer) => {
|
|
467
|
+
if ("error" in answer) {
|
|
468
|
+
// A command the sandbox never answered for, named by the Durable
|
|
469
|
+
// Object: an infra failure, classified, no message on the span.
|
|
470
|
+
const root = execRoot(startedAt, traceparent);
|
|
471
|
+
root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
|
|
472
|
+
root.end("error");
|
|
473
|
+
finish(answer);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
385
476
|
// The command as the Worker's own root (docs/reference/specs/tracing.md item 22).
|
|
386
|
-
execRoot(
|
|
387
|
-
exitCode:
|
|
388
|
-
...(
|
|
389
|
-
});
|
|
390
|
-
finish({
|
|
391
|
-
stdout: result.stdout ?? "",
|
|
392
|
-
stderr: [result.stderr ?? "", note].filter(Boolean).join("\n"),
|
|
393
|
-
exitCode: timedOut ? 124 : exitCode,
|
|
394
|
-
// The command's wall time in the sandbox, for the bot's `exec.exec`
|
|
395
|
-
// span (docs/reference/specs/tracing.md item 19); an older client ignores it.
|
|
396
|
-
durationMs: systemClock() - attemptStartedAt,
|
|
477
|
+
execRoot(startedAt, traceparent).end(answer.exitCode === 0 ? "ok" : "error", {
|
|
478
|
+
exitCode: answer.exitCode,
|
|
479
|
+
...(answer.exitCode === 124 ? { timedOut: true } : {}),
|
|
397
480
|
});
|
|
481
|
+
finish(answer);
|
|
398
482
|
})
|
|
399
483
|
.catch((err: unknown) => {
|
|
400
|
-
//
|
|
401
|
-
|
|
484
|
+
// The Durable Object threw: a failure its own classification did
|
|
485
|
+
// not name, seen here after the RPC boundary (name and message kept,
|
|
486
|
+
// prototype dropped), so the shared classifiers read the shape.
|
|
487
|
+
const root = execRoot(startedAt, traceparent);
|
|
402
488
|
root.fail(classifyError(new Error("sandbox exec failed"), { kind: "infra" }));
|
|
403
489
|
root.end("error");
|
|
404
490
|
const shape = thrownShape(err);
|
|
405
|
-
// The text that leaves the Worker is never empty (item 3): a
|
|
406
|
-
// message-less SDK error is named, with the rollout hint. The
|
|
407
|
-
// classifiers below still read the raw `shape`.
|
|
408
491
|
const raw = thrownText(shape);
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
// kept so an older executor still renders it as exit 127.
|
|
492
|
+
if (isRuntimeUnreachableError(err)) {
|
|
493
|
+
finish(runtimeUnreachableExecAnswer(raw));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
414
496
|
if (isFleetBusyError(err)) {
|
|
415
497
|
finish(fleetBusyExecAnswer(raw));
|
|
416
498
|
return;
|
|
417
499
|
}
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
// when a recycle-shaped text arrives minutes into this attempt. Say
|
|
421
|
-
// so, and that /workspace is gone. Still exit 127: the workspace
|
|
422
|
-
// really is gone.
|
|
500
|
+
// A recycle the Durable Object did not catch by type: by name, or
|
|
501
|
+
// a recycle-shaped text minutes into the attempt (item 9).
|
|
423
502
|
const certain = shape.name !== undefined && isRecycleError({ name: shape.name });
|
|
424
|
-
const msg = recycledMidCommandMessage(systemClock() -
|
|
425
|
-
|
|
426
|
-
// a silent-success window: a new executor throws on `error`, and
|
|
427
|
-
// an executor that predates in-body errors (only checks exitCode)
|
|
428
|
-
// still renders "exit 127: <message>" instead of "(no output)".
|
|
429
|
-
finish({ error: msg, stdout: "", stderr: msg, exitCode: 127 });
|
|
503
|
+
const msg = recycledMidCommandMessage(systemClock() - startedAt, raw, certain);
|
|
504
|
+
finish({ error: msg, stdout: "", stderr: msg, exitCode: 127 } satisfies ExecFailure);
|
|
430
505
|
});
|
|
431
506
|
},
|
|
432
507
|
});
|
|
433
508
|
return new Response(stream, { headers: { "content-type": "application/json" } });
|
|
434
509
|
}
|
|
435
510
|
|
|
511
|
+
/** A file route's refusal as the fetch handler answers it: the text, and the
|
|
512
|
+
* machine token when the Durable Object named one — the executor matches
|
|
513
|
+
* `reason`, not the text (docs/reference/specs/execution.md items 9 and 14). */
|
|
514
|
+
function refused(r: FileRefusal): Response {
|
|
515
|
+
return json(r.reason ? { error: r.error, reason: r.reason } : { error: r.error }, r.status);
|
|
516
|
+
}
|
|
517
|
+
|
|
436
518
|
function abs(p: string): string {
|
|
437
519
|
if (!p) throw new Error("missing path");
|
|
438
520
|
const path = p.startsWith("/") ? p : `${WORKDIR}/${p}`;
|