@gr8ful/spf 0.9.2 → 0.10.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 (37) hide show
  1. package/README.md +56 -0
  2. package/assets/defaults/spf.config.yaml +75 -0
  3. package/assets/skill/references/config.md +98 -4
  4. package/dist/chains/index.d.ts +2 -0
  5. package/dist/chains/index.js +4 -0
  6. package/dist/cli/commands/doctor.js +339 -2
  7. package/dist/cli/commands/fanout.d.ts +7 -14
  8. package/dist/cli/commands/fanout.js +45 -39
  9. package/dist/cli/commands/loop.d.ts +2 -0
  10. package/dist/cli/commands/loop.js +198 -0
  11. package/dist/cli/commands/run.js +14 -4
  12. package/dist/cli/commands/watch.d.ts +29 -1
  13. package/dist/cli/commands/watch.js +219 -64
  14. package/dist/cli/index.js +14 -0
  15. package/dist/core/agent_cc.d.ts +11 -0
  16. package/dist/core/agent_cc.js +25 -2
  17. package/dist/core/agent_flue.js +14 -5
  18. package/dist/core/agents.d.ts +61 -1
  19. package/dist/core/agents.js +363 -6
  20. package/dist/core/data_types.d.ts +316 -0
  21. package/dist/core/data_types.js +143 -0
  22. package/dist/core/loop.d.ts +230 -0
  23. package/dist/core/loop.js +290 -0
  24. package/dist/core/quality.d.ts +1 -2
  25. package/dist/core/sandbox.d.ts +236 -0
  26. package/dist/core/sandbox.js +655 -0
  27. package/dist/core/sandbox_cloudflare.d.ts +137 -0
  28. package/dist/core/sandbox_cloudflare.js +505 -0
  29. package/dist/core/sandbox_opensandbox.d.ts +59 -0
  30. package/dist/core/sandbox_opensandbox.js +484 -0
  31. package/dist/core/sandbox_sdk_types.d.ts +171 -0
  32. package/dist/core/sandbox_sdk_types.js +20 -0
  33. package/dist/core/watch.d.ts +56 -0
  34. package/dist/core/watch.js +354 -51
  35. package/dist/core/worktree_data.d.ts +1 -0
  36. package/dist/core/worktree_data.js +37 -0
  37. package/package.json +1 -1
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Cloudflare backend adapter (SPF #15, design doc §4.2).
3
+ *
4
+ * ============================================================================
5
+ * DEVIATION FROM THE DESIGN DOC, DISCOVERED DURING IMPLEMENTATION — read
6
+ * before touching this file. §4.2's pseudocode calls
7
+ * `@flue/runtime/cloudflare`'s `cloudflareSandbox(stub, {cwd}): SandboxFactory`
8
+ * directly. That function IS real (verified: `dist/cloudflare/index.d.mts:
9
+ * 13-68`, 7-method structural stub `exec`/`readFile`/`writeFile`/`exists`/
10
+ * `mkdir`/`deleteFile`/`getState`) — but the MODULE it lives in cannot be
11
+ * loaded in a plain Node.js process AT ALL, statically or dynamically:
12
+ * `@flue/runtime/cloudflare` unconditionally imports `cloudflare:workers` at
13
+ * its own top level (`dist/tracing-mRWmVbdr.mjs:4`, pulled in transitively
14
+ * through `dist/cloudflare/index.mjs`), and Node's default ESM loader has no
15
+ * handler for that URL scheme outside a Cloudflare Worker. Reproduced
16
+ * directly against this repo's installed package:
17
+ *
18
+ * node -e "import('@flue/runtime/cloudflare').then(()=>console.log('OK'))
19
+ * .catch(e=>console.log('FAIL', e.message))"
20
+ * -> FAIL Only URLs with a scheme in: file, data, and node are supported
21
+ * by the default ESM loader. Received protocol 'cloudflare:'
22
+ *
23
+ * A dynamic `import()` does not help — Node still has to load and execute
24
+ * the module graph the first time it's awaited, so the crash just moves
25
+ * from "every `spf` command" (a top-level import) to "the first time a
26
+ * `cloudflare`-backend spec is created" (still a hard failure, just later).
27
+ * §4.2's own O-2 anticipated bridge-wire uncertainty, but not this: the
28
+ * design's premise that `cloudflareSandbox()` is callable from "SPF is a
29
+ * Node CLI" does not hold for the currently-installed `@flue/runtime`.
30
+ *
31
+ * RESOLUTION SHIPPED HERE: bypass `cloudflareSandbox()` and go one layer
32
+ * lower, to `sandboxFromDriver(driver: SandboxDriver, cwd): Sandbox` — flue's
33
+ * own first-party primitive, exported from the MAIN `@flue/runtime` package
34
+ * (confirmed Node-safe: no `cloudflare:workers` in its own import chain),
35
+ * and the SAME primitive the OpenSandbox adapter is built on (design doc
36
+ * §4.1). `driverFromCloudflareStub()` below maps a `CloudflareSandboxStub`
37
+ * (still built exactly per the 7-method shape above, via `bridgeStub()`) onto
38
+ * the 9-verb `SandboxDriver` — a byte-faithful reproduction of what
39
+ * `cloudflareSandbox()`'s own internal `cfSandboxToSandbox` does
40
+ * (`dist/cloudflare/index.mjs:84-152`, read and copied verb-for-verb below),
41
+ * MINUS its `raceContainerDeath` liveness poller, which depends on a working
42
+ * `getState()` that this adapter cannot honor either (see `bridgeStub`'s own
43
+ * comment) — so a hung command now relies on the HTTP call's own timeout
44
+ * instead of a liveness probe, a real but explicitly-accepted narrowing.
45
+ * This still satisfies "wired via flue's first-party sandbox machinery" in
46
+ * substance — `sandboxFromDriver` is that machinery — but is a real
47
+ * deviation from the doc's literal `cloudflareSandbox()` call, made because
48
+ * the literal call is not possible in this environment. Flagged prominently
49
+ * rather than silently worked around, per this task's own instructions.
50
+ * ============================================================================
51
+ *
52
+ * `bridgeStub()` builds the `CloudflareSandboxStub` shape over
53
+ * `@cloudflare/sandbox`'s self-deployed HTTP bridge (design doc §4.2, open
54
+ * question O-2): every one of its 7 methods becomes a fetch call against
55
+ * `sandbox.cloudflare.bridge_url`, bearer-authenticated from
56
+ * `process.env[sandbox.cloudflare.api_token_env]`. The exact bridge wire
57
+ * contract (endpoint paths, the exec SSE event framing) is O-2 — verified
58
+ * against developers.cloudflare.com/sandbox/bridge/http-api/ at
59
+ * implementation time, isolated below so it is easy to correct once a real
60
+ * bridge is available to test against; the CF unit tests exercise SPF's own
61
+ * mapping (arguments, the exitCode fallback, base64 chunking, the seed/
62
+ * preflight orchestration) against an in-process FAKE bridge built to this
63
+ * same contract — never the real network — matching the design's own
64
+ * "argument lane vs. wire lane" split for the OpenSandbox adapter (§8.3a/b),
65
+ * applied here by analogy. Live e2e against a real bridge is gated on
66
+ * `CLOUDFLARE_API_TOKEN`/`SPF_E2E_CF_BRIDGE_URL` and is out of this PR
67
+ * slice's test scope (§8.4's stated skip rule).
68
+ */
69
+ import type { SandboxDriver, SandboxFactory } from "@flue/runtime";
70
+ import type { CloudflareSandboxStub } from "@flue/runtime/cloudflare";
71
+ import type { SandboxSpec } from "./data_types.ts";
72
+ import type { CredentialBroker, SandboxTransport } from "./sandbox.ts";
73
+ /** The richer shape `bridgeStub` actually returns — a real `CloudflareSandboxStub` (so `cloudflareSandbox()` accepts it unmodified) plus the two bridge-lifecycle operations flue's stub type has no room for. Exported for the hermetic unit tests (§8.1) — `cloudflareFactory` is the only caller in production code. */
74
+ export interface CloudflareBridgeStub extends CloudflareSandboxStub {
75
+ /** Bridge-assigned sandbox id, creating it on first use. Memoized: every stub method resolves the SAME id. */
76
+ ensureSandboxId(): Promise<string>;
77
+ /** `DELETE {bridge}/v1/sandbox/:id` — SPF's own responsibility; unlike the Worker/Durable-Object case `cloudflareSandbox()`'s own doc comment shows, nothing else here ever tears the bridge-side sandbox down. */
78
+ destroy(): Promise<void>;
79
+ }
80
+ /**
81
+ * Builds the `CloudflareSandboxStub` `cloudflareSandbox()` wants, over the
82
+ * `@cloudflare/sandbox` bridge's HTTP API (design doc §4.2, O-2): `POST
83
+ * /v1/sandbox` (create, `{id}`), `DELETE /v1/sandbox/:id` (destroy), `POST
84
+ * /v1/sandbox/:id/exec` (the event-stream above), `GET`/`PUT
85
+ * /v1/sandbox/:id/file/*` (raw bytes). Bearer auth on every `/v1/sandbox/*`
86
+ * route. There is no direct `exists`/`mkdir`/`deleteFile` endpoint at this
87
+ * layer, so those three (and `size`/`readFileBuffer` in `stubTransport`
88
+ * below) are bridged through `exec` — the one verb the bridge unambiguously
89
+ * exposes for arbitrary shell work, the same "fall back to exec" move
90
+ * flue's own reference `cf-sandbox.mjs` makes for `stat`/`readdir`/recursive
91
+ * `rm` against the real Durable Object stub.
92
+ *
93
+ * `getState` has no bridge equivalent richer than a coarse `{running}`
94
+ * boolean (`GET /v1/sandbox/:id/running`) — not the `status` STRING
95
+ * `getState`'s own contract promises (`"stopped"`/`"stopped_with_code"`/…),
96
+ * and inventing one of those values from a boolean would be exactly the
97
+ * fabrication this design refuses elsewhere (§4.2 point 2a's `exitCode`
98
+ * fallback is careful for the same reason). So `getState` throws the named
99
+ * `SandboxOperationUnsupportedError` the design specifies for this exact
100
+ * gap — harmless to flue's own liveness poller (`raceContainerDeath`),
101
+ * which treats a rejected `getState()` as "poll again later" and simply
102
+ * falls back to the real RPC's own resolution, never hanging on it.
103
+ */
104
+ export declare function bridgeStub(spec: SandboxSpec): CloudflareBridgeStub;
105
+ /**
106
+ * The surface `SandboxLease.transport` declares (§4.3), over the 7-method
107
+ * `CloudflareSandboxStub`. `size`/`readFileBuffer` are exec-backed — `wc
108
+ * -c`/`base64 -w0`, exactly as §8.1 specifies — the same convention the
109
+ * OpenSandbox driver uses for the flue `stat` verb it likewise cannot map
110
+ * onto natively (§4.1): `CloudflareSandboxStub` has no `stat` member at all
111
+ * (verified `dist/cloudflare/index.d.mts:13-48`), so this is what proves
112
+ * `size` is implementable where `stat` was not.
113
+ */
114
+ export declare function stubTransport(stub: CloudflareSandboxStub): SandboxTransport;
115
+ /**
116
+ * A byte-faithful reproduction of `cloudflareSandbox()`'s own internal
117
+ * `cfSandboxToSandbox` driver mapping (`dist/cloudflare/index.mjs:84-152`,
118
+ * read in full — see this module's header comment for why reproducing it
119
+ * is necessary rather than calling the original), MINUS the
120
+ * `raceContainerDeath` liveness wrapper: this adapter's `getState()` always
121
+ * throws (`bridgeStub`'s own comment), so wrapping every call in a poller
122
+ * that depends on it would only add latency for no signal. A hung command
123
+ * relies on the HTTP call's own timeout instead.
124
+ */
125
+ export declare function driverFromCloudflareStub(stub: CloudflareSandboxStub): SandboxDriver;
126
+ /**
127
+ * `broker` defaults to `staticBroker` — the only broker this build
128
+ * registers. NOTE: unlike the OpenSandbox adapter, this bridge does not
129
+ * currently thread ANY `env` into sandbox creation or `exec` (a pre-existing
130
+ * gap in this file, not something PR B's credential-broker seam changes —
131
+ * see this module's own header comment for its other Cloudflare-specific
132
+ * deviations). `broker.issue(spec)` is still called here, on the same MISS
133
+ * branch, so the credential lifecycle (grant + ordered revoke) is uniform
134
+ * across backends even though this backend has nothing to hand the grant's
135
+ * `env` to yet.
136
+ */
137
+ export declare function cloudflareFactory(spec: SandboxSpec, broker?: CredentialBroker): SandboxFactory;
@@ -0,0 +1,505 @@
1
+ /**
2
+ * Cloudflare backend adapter (SPF #15, design doc §4.2).
3
+ *
4
+ * ============================================================================
5
+ * DEVIATION FROM THE DESIGN DOC, DISCOVERED DURING IMPLEMENTATION — read
6
+ * before touching this file. §4.2's pseudocode calls
7
+ * `@flue/runtime/cloudflare`'s `cloudflareSandbox(stub, {cwd}): SandboxFactory`
8
+ * directly. That function IS real (verified: `dist/cloudflare/index.d.mts:
9
+ * 13-68`, 7-method structural stub `exec`/`readFile`/`writeFile`/`exists`/
10
+ * `mkdir`/`deleteFile`/`getState`) — but the MODULE it lives in cannot be
11
+ * loaded in a plain Node.js process AT ALL, statically or dynamically:
12
+ * `@flue/runtime/cloudflare` unconditionally imports `cloudflare:workers` at
13
+ * its own top level (`dist/tracing-mRWmVbdr.mjs:4`, pulled in transitively
14
+ * through `dist/cloudflare/index.mjs`), and Node's default ESM loader has no
15
+ * handler for that URL scheme outside a Cloudflare Worker. Reproduced
16
+ * directly against this repo's installed package:
17
+ *
18
+ * node -e "import('@flue/runtime/cloudflare').then(()=>console.log('OK'))
19
+ * .catch(e=>console.log('FAIL', e.message))"
20
+ * -> FAIL Only URLs with a scheme in: file, data, and node are supported
21
+ * by the default ESM loader. Received protocol 'cloudflare:'
22
+ *
23
+ * A dynamic `import()` does not help — Node still has to load and execute
24
+ * the module graph the first time it's awaited, so the crash just moves
25
+ * from "every `spf` command" (a top-level import) to "the first time a
26
+ * `cloudflare`-backend spec is created" (still a hard failure, just later).
27
+ * §4.2's own O-2 anticipated bridge-wire uncertainty, but not this: the
28
+ * design's premise that `cloudflareSandbox()` is callable from "SPF is a
29
+ * Node CLI" does not hold for the currently-installed `@flue/runtime`.
30
+ *
31
+ * RESOLUTION SHIPPED HERE: bypass `cloudflareSandbox()` and go one layer
32
+ * lower, to `sandboxFromDriver(driver: SandboxDriver, cwd): Sandbox` — flue's
33
+ * own first-party primitive, exported from the MAIN `@flue/runtime` package
34
+ * (confirmed Node-safe: no `cloudflare:workers` in its own import chain),
35
+ * and the SAME primitive the OpenSandbox adapter is built on (design doc
36
+ * §4.1). `driverFromCloudflareStub()` below maps a `CloudflareSandboxStub`
37
+ * (still built exactly per the 7-method shape above, via `bridgeStub()`) onto
38
+ * the 9-verb `SandboxDriver` — a byte-faithful reproduction of what
39
+ * `cloudflareSandbox()`'s own internal `cfSandboxToSandbox` does
40
+ * (`dist/cloudflare/index.mjs:84-152`, read and copied verb-for-verb below),
41
+ * MINUS its `raceContainerDeath` liveness poller, which depends on a working
42
+ * `getState()` that this adapter cannot honor either (see `bridgeStub`'s own
43
+ * comment) — so a hung command now relies on the HTTP call's own timeout
44
+ * instead of a liveness probe, a real but explicitly-accepted narrowing.
45
+ * This still satisfies "wired via flue's first-party sandbox machinery" in
46
+ * substance — `sandboxFromDriver` is that machinery — but is a real
47
+ * deviation from the doc's literal `cloudflareSandbox()` call, made because
48
+ * the literal call is not possible in this environment. Flagged prominently
49
+ * rather than silently worked around, per this task's own instructions.
50
+ * ============================================================================
51
+ *
52
+ * `bridgeStub()` builds the `CloudflareSandboxStub` shape over
53
+ * `@cloudflare/sandbox`'s self-deployed HTTP bridge (design doc §4.2, open
54
+ * question O-2): every one of its 7 methods becomes a fetch call against
55
+ * `sandbox.cloudflare.bridge_url`, bearer-authenticated from
56
+ * `process.env[sandbox.cloudflare.api_token_env]`. The exact bridge wire
57
+ * contract (endpoint paths, the exec SSE event framing) is O-2 — verified
58
+ * against developers.cloudflare.com/sandbox/bridge/http-api/ at
59
+ * implementation time, isolated below so it is easy to correct once a real
60
+ * bridge is available to test against; the CF unit tests exercise SPF's own
61
+ * mapping (arguments, the exitCode fallback, base64 chunking, the seed/
62
+ * preflight orchestration) against an in-process FAKE bridge built to this
63
+ * same contract — never the real network — matching the design's own
64
+ * "argument lane vs. wire lane" split for the OpenSandbox adapter (§8.3a/b),
65
+ * applied here by analogy. Live e2e against a real bridge is gated on
66
+ * `CLOUDFLARE_API_TOKEN`/`SPF_E2E_CF_BRIDGE_URL` and is out of this PR
67
+ * slice's test scope (§8.4's stated skip rule).
68
+ */
69
+ import { SandboxOperationUnsupportedError, sandboxFromDriver } from "@flue/runtime";
70
+ import { getLease, preflight, registerLease, seedViaTransport, staticBroker } from "./sandbox.js";
71
+ // ── shell/path helpers (this module's own copy — sandbox.ts's is private,
72
+ // and flue's own cloudflare/index.mjs likewise keeps its own local copy
73
+ // rather than sharing one across the boundary) ──────────────────────────────
74
+ /** Single-quote a value for the container shell; embedded quotes become '\''. */
75
+ function shellQuote(value) {
76
+ return `'${value.replace(/'/g, "'\\''")}'`;
77
+ }
78
+ /** `/file/*`'s wildcard segment, percent-encoded per path SEGMENT so embedded slashes in a filename can't be confused with the route's own separators, while real separators survive untouched. */
79
+ function encodeBridgeFilePath(filePath) {
80
+ return filePath.split("/").map((seg) => (seg === "" ? "" : encodeURIComponent(seg))).join("/");
81
+ }
82
+ function requiredBridgeToken(spec) {
83
+ const key = spec.cloudflare.api_token_env;
84
+ const token = process.env[key];
85
+ if (!token) {
86
+ throw new Error(`sandbox: env var ${JSON.stringify(key)} (sandbox.cloudflare.api_token_env) is not set — required to authenticate to the cloudflare bridge at ${spec.cloudflare.bridge_url}`);
87
+ }
88
+ return token;
89
+ }
90
+ /**
91
+ * `POST {bridge}/v1/sandbox/:id/exec` returns `text/event-stream`: `stdout`/
92
+ * `stderr` events carry base64-encoded chunks, and the stream ends with
93
+ * exactly one terminal event — `exit` (`{"exit_code": N}`) or `error`
94
+ * (`{"error": "...", "code": "..."}`). Parsed here rather than assumed
95
+ * streamed live: `SandboxTransport.exec`/`CloudflareSandboxStub.exec` both
96
+ * declare a single resolved `Promise`, not a callback surface, so collecting
97
+ * the whole stream before resolving is the correct shape either way.
98
+ */
99
+ function parseExecEventStream(text) {
100
+ let stdout = "";
101
+ let stderr = "";
102
+ let exitCode;
103
+ let success = true;
104
+ for (const block of text.split("\n\n")) {
105
+ if (!block.trim())
106
+ continue;
107
+ let event = "message";
108
+ const dataLines = [];
109
+ for (const line of block.split("\n")) {
110
+ if (line.startsWith("event:"))
111
+ event = line.slice("event:".length).trim();
112
+ else if (line.startsWith("data:"))
113
+ dataLines.push(line.slice("data:".length).trim());
114
+ }
115
+ const data = dataLines.join("\n");
116
+ if (event === "stdout")
117
+ stdout += Buffer.from(data, "base64").toString("utf-8");
118
+ else if (event === "stderr")
119
+ stderr += Buffer.from(data, "base64").toString("utf-8");
120
+ else if (event === "exit") {
121
+ try {
122
+ exitCode = JSON.parse(data).exit_code;
123
+ }
124
+ catch {
125
+ /* leave undefined — the success->exitCode fallback (§4.2 point 2a) covers it */
126
+ }
127
+ success = (exitCode ?? 1) === 0;
128
+ }
129
+ else if (event === "error") {
130
+ success = false;
131
+ let message = data;
132
+ try {
133
+ message = JSON.parse(data).error ?? data;
134
+ }
135
+ catch {
136
+ /* keep the raw payload */
137
+ }
138
+ stderr += (stderr ? "\n" : "") + message;
139
+ }
140
+ }
141
+ return { success, stdout, stderr, exitCode };
142
+ }
143
+ function envPrefixedCommand(command, env) {
144
+ if (!env || Object.keys(env).length === 0)
145
+ return command;
146
+ const exports = Object.entries(env)
147
+ .map(([key, value]) => `export ${key}=${shellQuote(value)}`)
148
+ .join("; ");
149
+ return `${exports}; ${command}`;
150
+ }
151
+ /**
152
+ * Builds the `CloudflareSandboxStub` `cloudflareSandbox()` wants, over the
153
+ * `@cloudflare/sandbox` bridge's HTTP API (design doc §4.2, O-2): `POST
154
+ * /v1/sandbox` (create, `{id}`), `DELETE /v1/sandbox/:id` (destroy), `POST
155
+ * /v1/sandbox/:id/exec` (the event-stream above), `GET`/`PUT
156
+ * /v1/sandbox/:id/file/*` (raw bytes). Bearer auth on every `/v1/sandbox/*`
157
+ * route. There is no direct `exists`/`mkdir`/`deleteFile` endpoint at this
158
+ * layer, so those three (and `size`/`readFileBuffer` in `stubTransport`
159
+ * below) are bridged through `exec` — the one verb the bridge unambiguously
160
+ * exposes for arbitrary shell work, the same "fall back to exec" move
161
+ * flue's own reference `cf-sandbox.mjs` makes for `stat`/`readdir`/recursive
162
+ * `rm` against the real Durable Object stub.
163
+ *
164
+ * `getState` has no bridge equivalent richer than a coarse `{running}`
165
+ * boolean (`GET /v1/sandbox/:id/running`) — not the `status` STRING
166
+ * `getState`'s own contract promises (`"stopped"`/`"stopped_with_code"`/…),
167
+ * and inventing one of those values from a boolean would be exactly the
168
+ * fabrication this design refuses elsewhere (§4.2 point 2a's `exitCode`
169
+ * fallback is careful for the same reason). So `getState` throws the named
170
+ * `SandboxOperationUnsupportedError` the design specifies for this exact
171
+ * gap — harmless to flue's own liveness poller (`raceContainerDeath`),
172
+ * which treats a rejected `getState()` as "poll again later" and simply
173
+ * falls back to the real RPC's own resolution, never hanging on it.
174
+ */
175
+ export function bridgeStub(spec) {
176
+ const base = spec.cloudflare.bridge_url.replace(/\/+$/, "");
177
+ let idPromise = null;
178
+ function ensureSandboxId() {
179
+ if (!idPromise) {
180
+ idPromise = (async () => {
181
+ const name = spec.cloudflare.sandbox_name || spec.lease_key.replace(/[^A-Za-z0-9_.-]/g, "_");
182
+ const res = await fetch(`${base}/v1/sandbox`, {
183
+ method: "POST",
184
+ headers: { "content-type": "application/json", authorization: `Bearer ${requiredBridgeToken(spec)}` },
185
+ body: JSON.stringify({ name }),
186
+ });
187
+ if (!res.ok) {
188
+ throw new Error(`sandbox: cloudflare bridge POST /v1/sandbox failed: HTTP ${res.status} ${await res.text().catch(() => "")}`);
189
+ }
190
+ const body = (await res.json());
191
+ if (!body.id)
192
+ throw new Error("sandbox: cloudflare bridge POST /v1/sandbox returned no \"id\"");
193
+ return body.id;
194
+ })();
195
+ idPromise.catch(() => {
196
+ idPromise = null; // a failed create must not poison every later call with the same rejected promise
197
+ });
198
+ }
199
+ return idPromise;
200
+ }
201
+ async function bridgeFetch(pathSuffix, init) {
202
+ const id = await ensureSandboxId();
203
+ const headers = { authorization: `Bearer ${requiredBridgeToken(spec)}`, ...init?.headers };
204
+ return fetch(`${base}/v1/sandbox/${id}${pathSuffix}`, { ...init, headers });
205
+ }
206
+ async function bridgeExec(command, options) {
207
+ // A CONFIG error (no bridge_url, no token env set) must propagate as a
208
+ // real rejection, immediately and unambiguously — never get folded into
209
+ // a resolved {success:false} result the way a NETWORK-level failure
210
+ // below does, or it would surface as a vague "missing git/tar/base64"
211
+ // preflight message instead of the actual misconfiguration. Resolved
212
+ // BEFORE the try/catch on purpose.
213
+ const id = await ensureSandboxId();
214
+ const token = requiredBridgeToken(spec);
215
+ const controller = new AbortController();
216
+ const timer = options?.timeout ? setTimeout(() => controller.abort(), options.timeout) : null;
217
+ try {
218
+ const res = await fetch(`${base}/v1/sandbox/${id}/exec`, {
219
+ method: "POST",
220
+ headers: { "content-type": "application/json", accept: "text/event-stream", authorization: `Bearer ${token}` },
221
+ body: JSON.stringify({
222
+ argv: ["sh", "-lc", envPrefixedCommand(command, options?.env)],
223
+ cwd: options?.cwd,
224
+ timeout_ms: options?.timeout,
225
+ }),
226
+ signal: controller.signal,
227
+ });
228
+ if (!res.ok) {
229
+ return { success: false, stdout: "", stderr: `sandbox: cloudflare bridge exec failed: HTTP ${res.status}`, exitCode: 1 };
230
+ }
231
+ return parseExecEventStream(await res.text());
232
+ }
233
+ catch (error) {
234
+ if (controller.signal.aborted) {
235
+ return { success: false, stdout: "", stderr: `sandbox: command timed out after ${options?.timeout}ms`, exitCode: 124 };
236
+ }
237
+ return { success: false, stdout: "", stderr: error instanceof Error ? error.message : String(error), exitCode: 1 };
238
+ }
239
+ finally {
240
+ if (timer)
241
+ clearTimeout(timer);
242
+ }
243
+ }
244
+ return {
245
+ exec: bridgeExec,
246
+ async readFile(filePath, options) {
247
+ const res = await bridgeFetch(`/file/${encodeBridgeFilePath(filePath)}`);
248
+ if (!res.ok)
249
+ throw new Error(`sandbox: cloudflare bridge GET file ${filePath} failed: HTTP ${res.status}`);
250
+ const bytes = Buffer.from(await res.arrayBuffer());
251
+ return { content: options?.encoding === "base64" ? bytes.toString("base64") : bytes.toString("utf-8") };
252
+ },
253
+ async writeFile(filePath, content, options) {
254
+ const bytes = options?.encoding === "base64" ? Buffer.from(content, "base64") : Buffer.from(content, "utf-8");
255
+ const res = await bridgeFetch(`/file/${encodeBridgeFilePath(filePath)}`, {
256
+ method: "PUT",
257
+ headers: { "content-type": "application/octet-stream" },
258
+ body: bytes,
259
+ });
260
+ if (!res.ok)
261
+ throw new Error(`sandbox: cloudflare bridge PUT file ${filePath} failed: HTTP ${res.status}`);
262
+ return {};
263
+ },
264
+ async exists(filePath) {
265
+ const r = await bridgeExec(`test -e ${shellQuote(filePath)}`);
266
+ return { exists: (r.exitCode ?? (r.success ? 0 : 1)) === 0 };
267
+ },
268
+ async mkdir(dirPath, options) {
269
+ await bridgeExec(`mkdir ${options?.recursive ? "-p " : ""}${shellQuote(dirPath)}`);
270
+ return {};
271
+ },
272
+ async deleteFile(filePath) {
273
+ await bridgeExec(`rm -f ${shellQuote(filePath)}`);
274
+ return {};
275
+ },
276
+ async getState() {
277
+ throw new SandboxOperationUnsupportedError({ operation: "getState", provider: "cloudflare-bridge", options: [] });
278
+ },
279
+ ensureSandboxId,
280
+ async destroy() {
281
+ const id = await ensureSandboxId();
282
+ const res = await fetch(`${base}/v1/sandbox/${id}`, {
283
+ method: "DELETE",
284
+ headers: { authorization: `Bearer ${requiredBridgeToken(spec)}` },
285
+ });
286
+ if (!res.ok && res.status !== 404) {
287
+ throw new Error(`sandbox: cloudflare bridge DELETE /v1/sandbox/${id} failed: HTTP ${res.status}`);
288
+ }
289
+ },
290
+ };
291
+ }
292
+ // ── the {exec, readFile, readFileBuffer, writeFile, size} transport ────────
293
+ /**
294
+ * The surface `SandboxLease.transport` declares (§4.3), over the 7-method
295
+ * `CloudflareSandboxStub`. `size`/`readFileBuffer` are exec-backed — `wc
296
+ * -c`/`base64 -w0`, exactly as §8.1 specifies — the same convention the
297
+ * OpenSandbox driver uses for the flue `stat` verb it likewise cannot map
298
+ * onto natively (§4.1): `CloudflareSandboxStub` has no `stat` member at all
299
+ * (verified `dist/cloudflare/index.d.mts:13-48`), so this is what proves
300
+ * `size` is implementable where `stat` was not.
301
+ */
302
+ export function stubTransport(stub) {
303
+ const execChainExitCode = (r) => r.exitCode ?? (r.success ? 0 : 1);
304
+ return {
305
+ async exec(cmd, opts) {
306
+ const r = await stub.exec(cmd, { cwd: opts.cwd, timeout: opts.timeoutMs });
307
+ // success -> exitCode fallback (§4.2 point 2a): exitCode is the
308
+ // authority when present; a bridge that omits it maps a successful
309
+ // step to 0 and a failed one to a DELIBERATE 1 — never 0 — so every
310
+ // in-sandbox `&&` chain's failure detection still fires correctly.
311
+ return { stdout: r.stdout, stderr: r.stderr, exitCode: execChainExitCode(r) };
312
+ },
313
+ async readFile(filePath) {
314
+ return (await stub.readFile(filePath)).content;
315
+ },
316
+ async readFileBuffer(filePath) {
317
+ // Stdin redirection, not a positional file argument — same rule as
318
+ // the writeFile binary path below (verified live: BSD base64 rejects
319
+ // a positional filename entirely, `-w0` or not).
320
+ const r = await stub.exec(`base64 -w0 < ${shellQuote(filePath)}`);
321
+ if (execChainExitCode(r) !== 0)
322
+ throw new Error(`sandbox: cloudflare readFileBuffer(${filePath}) failed: ${r.stderr}`);
323
+ return Buffer.from(r.stdout.trim(), "base64");
324
+ },
325
+ async writeFile(filePath, data) {
326
+ if (typeof data === "string") {
327
+ // The plain-text path — this is what sandbox.ts's shared transport
328
+ // uses for the code-plane's own base64 TEXT files (seed.patch.b64,
329
+ // out.patch.b64: a base64 STRING is the file's content, decoded by
330
+ // a separate in-sandbox `base64 -d` step in sandbox.ts, not here).
331
+ await stub.writeFile(filePath, data);
332
+ return;
333
+ }
334
+ // Uint8Array: the stub's own `writeFile` is string-only (no
335
+ // Uint8Array, no streaming — §4.2's stated stub limitation). Applies
336
+ // to the handoff mirror's binary pushes (sandbox.ts's
337
+ // pushHandoffMirror), which — unlike the code-plane patches — call
338
+ // this method directly with raw bytes and expect them to land
339
+ // verbatim. Same base64-over-exec discipline §5.2/§4.2 use for the
340
+ // patches, self-contained here: write the base64 TEXT to a scratch
341
+ // sibling, then decode it into real bytes at the real destination.
342
+ const tmpPath = `${filePath}.b64.tmp`;
343
+ await stub.writeFile(tmpPath, Buffer.from(data).toString("base64"));
344
+ // Stdin redirection (`< file`), not a positional file argument — same
345
+ // rule sandbox.ts's applyPatchInSandbox follows: GNU coreutils' base64
346
+ // accepts both spellings, but a BusyBox/BSD base64 only accepts the
347
+ // redirect form (verified live: BSD base64 rejects a positional arg
348
+ // to `-d` with "invalid argument").
349
+ const r = await stub.exec(`base64 -d < ${shellQuote(tmpPath)} > ${shellQuote(filePath)} && rm -f ${shellQuote(tmpPath)}`);
350
+ if (execChainExitCode(r) !== 0)
351
+ throw new Error(`sandbox: cloudflare writeFile(${filePath}) (binary) failed: ${r.stderr}`);
352
+ },
353
+ async size(filePath) {
354
+ const r = await stub.exec(`wc -c < ${shellQuote(filePath)}`);
355
+ if (execChainExitCode(r) !== 0)
356
+ return null;
357
+ // trim is load-bearing: BSD/busybox `wc` pads the number with leading whitespace.
358
+ const n = Number.parseInt(r.stdout.trim(), 10);
359
+ return Number.isFinite(n) ? n : null;
360
+ },
361
+ };
362
+ }
363
+ // ── the SandboxDriver adapter (replaces cloudflareSandbox() — see this
364
+ // module's header comment for why) ──────────────────────────────────────────
365
+ /**
366
+ * A byte-faithful reproduction of `cloudflareSandbox()`'s own internal
367
+ * `cfSandboxToSandbox` driver mapping (`dist/cloudflare/index.mjs:84-152`,
368
+ * read in full — see this module's header comment for why reproducing it
369
+ * is necessary rather than calling the original), MINUS the
370
+ * `raceContainerDeath` liveness wrapper: this adapter's `getState()` always
371
+ * throws (`bridgeStub`'s own comment), so wrapping every call in a poller
372
+ * that depends on it would only add latency for no signal. A hung command
373
+ * relies on the HTTP call's own timeout instead.
374
+ */
375
+ export function driverFromCloudflareStub(stub) {
376
+ return {
377
+ async readFile(filePath) {
378
+ return (await stub.readFile(filePath)).content;
379
+ },
380
+ async readFileBuffer(filePath) {
381
+ const file = await stub.readFile(filePath, { encoding: "base64" });
382
+ return Buffer.from(file.content, "base64");
383
+ },
384
+ async writeFile(filePath, content) {
385
+ if (typeof content === "string") {
386
+ await stub.writeFile(filePath, content);
387
+ }
388
+ else {
389
+ await stub.writeFile(filePath, Buffer.from(content).toString("base64"), { encoding: "base64" });
390
+ }
391
+ },
392
+ async stat(filePath) {
393
+ const quoted = shellQuote(filePath);
394
+ const result = await stub.exec(`stat -L -c '%s/%Y/%F' ${quoted} && stat -c '%F' ${quoted}`);
395
+ if (!result.success)
396
+ throw new Error(`sandbox: cloudflare stat(${filePath}) failed: ${result.stderr}`);
397
+ const [target = "", self = ""] = (result.stdout ?? "").trim().split("\n");
398
+ const [size = "0", mtime = "0", type = ""] = target.split("/");
399
+ return {
400
+ isFile: type.includes("regular"),
401
+ isDirectory: type === "directory",
402
+ isSymbolicLink: self.trim() === "symbolic link",
403
+ size: Number.parseInt(size, 10),
404
+ mtime: new Date(Number.parseInt(mtime, 10) * 1000),
405
+ };
406
+ },
407
+ async readdir(dirPath) {
408
+ const result = await stub.exec(`find ${shellQuote(dirPath)} -mindepth 1 -maxdepth 1 -printf '%f\\0'`);
409
+ if (!result.success)
410
+ throw new Error(`sandbox: cloudflare readdir(${dirPath}) failed: ${result.stderr}`);
411
+ return result.stdout.split("\0").filter((s) => s.length > 0);
412
+ },
413
+ async exists(filePath) {
414
+ return (await stub.exists(filePath)).exists;
415
+ },
416
+ async mkdir(dirPath, opts) {
417
+ await stub.mkdir(dirPath, opts);
418
+ },
419
+ async rm(filePath, opts) {
420
+ if (!opts?.recursive && !opts?.force) {
421
+ await stub.deleteFile(filePath);
422
+ return;
423
+ }
424
+ const result = await stub.exec(`rm ${opts.force ? "-f " : ""}${opts.recursive ? "-r " : ""}-- ${shellQuote(filePath)}`);
425
+ if (!result.success)
426
+ throw new Error(`sandbox: cloudflare rm(${filePath}) failed: ${result.stderr}`);
427
+ },
428
+ async exec(command, execOpts) {
429
+ const result = await stub.exec(command, { cwd: execOpts?.cwd, env: execOpts?.env, timeout: execOpts?.timeoutMs });
430
+ return {
431
+ stdout: result.stdout ?? "",
432
+ stderr: result.stderr ?? "",
433
+ exitCode: result.exitCode ?? (result.success ? 0 : 1),
434
+ };
435
+ },
436
+ };
437
+ }
438
+ // ── the factory ──────────────────────────────────────────────────────────────
439
+ /**
440
+ * Keyed exactly like `sandbox.ts`'s `LEASES` and the OpenSandbox adapter's
441
+ * own `NATIVE_BY_LEASE` (`spec.lease_key`). `factoryFor(spec)` is called
442
+ * fresh on every `agent_flue.ts` send — first prompt, every JSON-repair,
443
+ * every gate correction — and `cloudflareFactory` is SYNC (§4.1's
444
+ * "performs NO I/O" contract), so without this cache each call built a
445
+ * brand-new `bridgeStub` with its own `idPromise = null`. On a lease HIT
446
+ * (`getLease(key)` already set), `createSandbox` skips seeding but STILL
447
+ * drove the sandbox through that new stub, whose first exec lazily
448
+ * allocated a second bridge-side sandbox that was never registered for
449
+ * teardown — up to one extra `POST /v1/sandbox` per send after the first.
450
+ */
451
+ const STUB_BY_LEASE = new Map();
452
+ /**
453
+ * `broker` defaults to `staticBroker` — the only broker this build
454
+ * registers. NOTE: unlike the OpenSandbox adapter, this bridge does not
455
+ * currently thread ANY `env` into sandbox creation or `exec` (a pre-existing
456
+ * gap in this file, not something PR B's credential-broker seam changes —
457
+ * see this module's own header comment for its other Cloudflare-specific
458
+ * deviations). `broker.issue(spec)` is still called here, on the same MISS
459
+ * branch, so the credential lifecycle (grant + ordered revoke) is uniform
460
+ * across backends even though this backend has nothing to hand the grant's
461
+ * `env` to yet.
462
+ */
463
+ export function cloudflareFactory(spec, broker = staticBroker) {
464
+ const key = spec.lease_key;
465
+ let stub = STUB_BY_LEASE.get(key);
466
+ if (!stub) {
467
+ stub = bridgeStub(spec);
468
+ STUB_BY_LEASE.set(key, stub);
469
+ }
470
+ const liveStub = stub;
471
+ return {
472
+ createSandbox: async () => {
473
+ const sb = sandboxFromDriver(driverFromCloudflareStub(liveStub), spec.workspace_dir);
474
+ // no reconcile on the hit branch — send() owns it (§4.1, §5.4)
475
+ if (!getLease(key)) {
476
+ const transport = stubTransport(liveStub);
477
+ await preflight(spec, transport); // git && tar && base64, §4.1 — same three binaries, same named error
478
+ const seeded = await seedViaTransport(spec, transport);
479
+ // Awaited once, on this MISS branch only — never re-issued on a
480
+ // later HIT (design §6.1).
481
+ const grant = await broker.issue(spec);
482
+ try {
483
+ // `ensureSandboxId()` resolved BEFORE `registerLease` on purpose
484
+ // (design §6.1) — a throwing bridge create must not leave a lease
485
+ // registered with no teardown steps to revoke the grant it already
486
+ // holds; a failed `ensureSandboxId()` never creates anything
487
+ // bridge-side either (it clears its own memoized promise), so no
488
+ // `cloudflare:destroy` counterpart is needed here.
489
+ const providerId = await liveStub.ensureSandboxId();
490
+ const lease = registerLease(key, spec, transport);
491
+ lease.seeded = seeded;
492
+ lease.provider_id = providerId;
493
+ // Position 1, 2 (PR B's credentials:revoke — the slot PR A left
494
+ // free), 3.
495
+ lease.teardown.push({ name: "cloudflare:destroy", run: () => liveStub.destroy() }, { name: "credentials:revoke", run: () => grant.revoke() }, { name: "cloudflare:uncache", run: async () => { STUB_BY_LEASE.delete(key); } });
496
+ }
497
+ catch (error) {
498
+ await grant.revoke().catch(() => { });
499
+ throw error;
500
+ }
501
+ }
502
+ return sb;
503
+ },
504
+ };
505
+ }