@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.
- package/README.md +56 -0
- package/assets/defaults/spf.config.yaml +75 -0
- package/assets/skill/references/config.md +98 -4
- package/dist/chains/index.d.ts +2 -0
- package/dist/chains/index.js +4 -0
- package/dist/cli/commands/doctor.js +339 -2
- package/dist/cli/commands/fanout.d.ts +7 -14
- package/dist/cli/commands/fanout.js +45 -39
- package/dist/cli/commands/loop.d.ts +2 -0
- package/dist/cli/commands/loop.js +198 -0
- package/dist/cli/commands/run.js +14 -4
- package/dist/cli/commands/watch.d.ts +29 -1
- package/dist/cli/commands/watch.js +219 -64
- package/dist/cli/index.js +14 -0
- package/dist/core/agent_cc.d.ts +11 -0
- package/dist/core/agent_cc.js +25 -2
- package/dist/core/agent_flue.js +14 -5
- package/dist/core/agents.d.ts +61 -1
- package/dist/core/agents.js +363 -6
- package/dist/core/data_types.d.ts +316 -0
- package/dist/core/data_types.js +143 -0
- package/dist/core/loop.d.ts +230 -0
- package/dist/core/loop.js +290 -0
- package/dist/core/quality.d.ts +1 -2
- package/dist/core/sandbox.d.ts +236 -0
- package/dist/core/sandbox.js +655 -0
- package/dist/core/sandbox_cloudflare.d.ts +137 -0
- package/dist/core/sandbox_cloudflare.js +505 -0
- package/dist/core/sandbox_opensandbox.d.ts +59 -0
- package/dist/core/sandbox_opensandbox.js +484 -0
- package/dist/core/sandbox_sdk_types.d.ts +171 -0
- package/dist/core/sandbox_sdk_types.js +20 -0
- package/dist/core/watch.d.ts +56 -0
- package/dist/core/watch.js +354 -51
- package/dist/core/worktree_data.d.ts +1 -0
- package/dist/core/worktree_data.js +37 -0
- 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
|
+
}
|