@evermore.work/plugin-cloudflare-sandbox 0.1.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 (47) hide show
  1. package/README.md +48 -0
  2. package/bridge-template/Dockerfile +14 -0
  3. package/bridge-template/README.md +50 -0
  4. package/bridge-template/package.json +21 -0
  5. package/bridge-template/src/auth.test.ts +30 -0
  6. package/bridge-template/src/auth.ts +40 -0
  7. package/bridge-template/src/exec.test.ts +151 -0
  8. package/bridge-template/src/exec.ts +147 -0
  9. package/bridge-template/src/helpers.ts +39 -0
  10. package/bridge-template/src/index.ts +25 -0
  11. package/bridge-template/src/routes.test.ts +143 -0
  12. package/bridge-template/src/routes.ts +468 -0
  13. package/bridge-template/src/sandboxes.test.ts +32 -0
  14. package/bridge-template/src/sandboxes.ts +57 -0
  15. package/bridge-template/src/sessions.ts +84 -0
  16. package/bridge-template/tsconfig.json +11 -0
  17. package/bridge-template/vitest.config.ts +8 -0
  18. package/bridge-template/wrangler.jsonc +28 -0
  19. package/dist/bridge-client.d.ts +39 -0
  20. package/dist/bridge-client.d.ts.map +1 -0
  21. package/dist/bridge-client.js +232 -0
  22. package/dist/bridge-client.js.map +1 -0
  23. package/dist/config.d.ts +4 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +71 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/index.d.ts +3 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +3 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/manifest.d.ts +4 -0
  32. package/dist/manifest.d.ts.map +1 -0
  33. package/dist/manifest.js +91 -0
  34. package/dist/manifest.js.map +1 -0
  35. package/dist/plugin.d.ts +3 -0
  36. package/dist/plugin.d.ts.map +1 -0
  37. package/dist/plugin.js +267 -0
  38. package/dist/plugin.js.map +1 -0
  39. package/dist/types.d.ts +91 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/worker.d.ts +3 -0
  44. package/dist/worker.d.ts.map +1 -0
  45. package/dist/worker.js +5 -0
  46. package/dist/worker.js.map +1 -0
  47. package/package.json +51 -0
@@ -0,0 +1,143 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("@cloudflare/sandbox", () => ({
4
+ getSandbox: vi.fn(),
5
+ }));
6
+
7
+ import { handleBridgeRequest } from "./routes.js";
8
+ import { resolveSandbox } from "./sandboxes.js";
9
+
10
+ vi.mock("./sandboxes.js", async () => {
11
+ const actual = await vi.importActual<typeof import("./sandboxes.js")>("./sandboxes.js");
12
+ return {
13
+ ...actual,
14
+ resolveSandbox: vi.fn(),
15
+ };
16
+ });
17
+
18
+ function bridgeRequest(pathname: string, body: unknown): Request {
19
+ return new Request(`https://bridge.example.test${pathname}`, {
20
+ method: "POST",
21
+ headers: {
22
+ Authorization: "Bearer secret-token",
23
+ "Content-Type": "application/json",
24
+ },
25
+ body: JSON.stringify(body),
26
+ });
27
+ }
28
+
29
+ describe("bridge routes", () => {
30
+ beforeEach(() => {
31
+ vi.mocked(resolveSandbox).mockReset();
32
+ });
33
+
34
+ it("writes lease sentinels through the named-session exec target", async () => {
35
+ const sessionExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
36
+ const sandbox = {
37
+ getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
38
+ createSession: vi.fn(),
39
+ writeFile: vi.fn(),
40
+ deleteFile: vi.fn(),
41
+ setKeepAlive: vi.fn().mockResolvedValue(undefined),
42
+ };
43
+ vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
44
+
45
+ const response = await handleBridgeRequest(
46
+ bridgeRequest("/api/evermore-sandbox/v1/leases/acquire", {
47
+ environmentId: "env-1",
48
+ runId: "run-1",
49
+ requestedCwd: "/workspace/evermore",
50
+ sessionStrategy: "named",
51
+ sessionId: "evermore",
52
+ }),
53
+ { BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
54
+ );
55
+
56
+ expect(response.status).toBe(200);
57
+ // Sentinel write must NOT use sandbox.writeFile (sandbox-level race);
58
+ // it goes through the same session as the mkdir.
59
+ expect(sandbox.writeFile).not.toHaveBeenCalled();
60
+
61
+ // Both calls use a single command string — the SDK's exec API ignores
62
+ // any `args` or `stdin` option, so the bridge folds them into the
63
+ // command line itself.
64
+ expect(sessionExec).toHaveBeenCalledTimes(2);
65
+ for (const call of sessionExec.mock.calls) {
66
+ const [commandArg, optionsArg] = call;
67
+ expect(typeof commandArg).toBe("string");
68
+ expect(commandArg).toMatch(/^sh -lc /);
69
+ expect(optionsArg).toEqual({ cwd: "/", timeout: expect.any(Number) });
70
+ expect(optionsArg).not.toHaveProperty("args");
71
+ expect(optionsArg).not.toHaveProperty("stdin");
72
+ }
73
+ expect(sessionExec.mock.calls[0]?.[0]).toContain("mkdir");
74
+ expect(sessionExec.mock.calls[0]?.[0]).toContain("/workspace/evermore");
75
+ expect(sessionExec.mock.calls[1]?.[0]).toContain("/workspace/evermore/.evermore-lease.json");
76
+ });
77
+
78
+ it("checks lease sentinels through the named-session exec target on resume", async () => {
79
+ const sessionExec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
80
+ const sandbox = {
81
+ getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
82
+ createSession: vi.fn(),
83
+ readFile: vi.fn(),
84
+ writeFile: vi.fn(),
85
+ deleteFile: vi.fn(),
86
+ setKeepAlive: vi.fn().mockResolvedValue(undefined),
87
+ };
88
+ vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
89
+
90
+ const response = await handleBridgeRequest(
91
+ bridgeRequest("/api/evermore-sandbox/v1/leases/resume", {
92
+ providerLeaseId: "pc-run-1-abcd1234",
93
+ requestedCwd: "/workspace/evermore",
94
+ sessionStrategy: "named",
95
+ sessionId: "evermore",
96
+ }),
97
+ { BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
98
+ );
99
+
100
+ expect(response.status).toBe(200);
101
+ expect(sandbox.readFile).not.toHaveBeenCalled();
102
+ const [commandArg, optionsArg] = sessionExec.mock.calls[0] ?? [];
103
+ expect(typeof commandArg).toBe("string");
104
+ expect(commandArg).toMatch(/^sh -lc /);
105
+ expect(commandArg).toContain("test -s");
106
+ expect(commandArg).toContain("/workspace/evermore/.evermore-lease.json");
107
+ expect(optionsArg).toEqual({ cwd: "/", timeout: expect.any(Number) });
108
+ expect(optionsArg).not.toHaveProperty("args");
109
+ });
110
+
111
+ it("streams exec stdout and completion metadata when requested", async () => {
112
+ const sessionExec = vi.fn().mockImplementation(async (_command, options) => {
113
+ await options?.onOutput?.("stdout", "hello\n");
114
+ return { exitCode: 0, stdout: "hello\n", stderr: "" };
115
+ });
116
+ const sandbox = {
117
+ getSession: vi.fn().mockResolvedValue({ exec: sessionExec }),
118
+ createSession: vi.fn(),
119
+ writeFile: vi.fn(),
120
+ deleteFile: vi.fn(),
121
+ setKeepAlive: vi.fn().mockResolvedValue(undefined),
122
+ };
123
+ vi.mocked(resolveSandbox).mockResolvedValue(sandbox as never);
124
+
125
+ const response = await handleBridgeRequest(
126
+ bridgeRequest("/api/evermore-sandbox/v1/exec", {
127
+ providerLeaseId: "pc-run-1-abcd1234",
128
+ command: "echo",
129
+ args: ["hello"],
130
+ sessionStrategy: "named",
131
+ sessionId: "evermore",
132
+ streamOutput: true,
133
+ }),
134
+ { BRIDGE_AUTH_TOKEN: "secret-token", Sandbox: {} as never },
135
+ );
136
+
137
+ expect(response.status).toBe(200);
138
+ expect(response.headers.get("Content-Type")).toContain("text/event-stream");
139
+ const body = await response.text();
140
+ expect(body).toContain("event: stdout");
141
+ expect(body).toContain("event: complete");
142
+ });
143
+ });
@@ -0,0 +1,468 @@
1
+ import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
2
+ import { isAuthorizedRequest } from "./auth.js";
3
+ import { executeInSandbox } from "./exec.js";
4
+ import { shellQuote } from "./helpers.js";
5
+ import {
6
+ buildLeaseSandboxId,
7
+ buildSentinelPath,
8
+ DEFAULT_REMOTE_CWD,
9
+ DEFAULT_SESSION_ID,
10
+ DEFAULT_TIMEOUT_MS,
11
+ resolveSandbox,
12
+ applySandboxKeepAlive,
13
+ toErrorResponse,
14
+ toJsonResponse,
15
+ type BridgeEnv,
16
+ } from "./sandboxes.js";
17
+ import type { SessionStrategy } from "./sessions.js";
18
+
19
+ interface ProbeRequestBody {
20
+ requestedCwd?: string;
21
+ keepAlive?: boolean;
22
+ sleepAfter?: string;
23
+ normalizeId?: boolean;
24
+ sessionStrategy?: SessionStrategy;
25
+ sessionId?: string;
26
+ timeoutMs?: number;
27
+ }
28
+
29
+ interface AcquireLeaseRequestBody extends ProbeRequestBody {
30
+ environmentId?: string;
31
+ runId?: string;
32
+ issueId?: string | null;
33
+ reuseLease?: boolean;
34
+ }
35
+
36
+ interface ResumeLeaseRequestBody extends ProbeRequestBody {
37
+ providerLeaseId?: string;
38
+ }
39
+
40
+ interface ReleaseLeaseRequestBody {
41
+ providerLeaseId?: string;
42
+ reuseLease?: boolean;
43
+ keepAlive?: boolean;
44
+ }
45
+
46
+ interface ExecuteRequestBody {
47
+ providerLeaseId?: string;
48
+ command?: string;
49
+ args?: string[];
50
+ cwd?: string;
51
+ env?: Record<string, string>;
52
+ stdin?: string | null;
53
+ timeoutMs?: number;
54
+ streamOutput?: boolean;
55
+ sessionStrategy?: SessionStrategy;
56
+ sessionId?: string;
57
+ }
58
+
59
+ function readBoolean(value: unknown, fallback: boolean): boolean {
60
+ return value === undefined ? fallback : value === true;
61
+ }
62
+
63
+ function readString(value: unknown, fallback: string): string {
64
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : fallback;
65
+ }
66
+
67
+ function readInteger(value: unknown, fallback: number): number {
68
+ const parsed = Number(value);
69
+ return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
70
+ }
71
+
72
+ function readSessionStrategy(value: unknown): SessionStrategy {
73
+ return value === "default" ? "default" : "named";
74
+ }
75
+
76
+ async function readJson<T>(request: Request): Promise<T> {
77
+ return await request.json() as T;
78
+ }
79
+
80
+ function encodeSseEvent(type: string, payload: unknown): string {
81
+ return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
82
+ }
83
+
84
+ function toSseResponse(stream: ReadableStream<Uint8Array>): Response {
85
+ return new Response(stream, {
86
+ headers: {
87
+ "Content-Type": "text/event-stream",
88
+ "Cache-Control": "no-cache",
89
+ },
90
+ });
91
+ }
92
+
93
+ async function execLeaseUtility(
94
+ sandbox: CloudflareSandbox,
95
+ options: {
96
+ remoteCwd: string;
97
+ sessionStrategy: SessionStrategy;
98
+ sessionId: string;
99
+ timeoutMs: number;
100
+ },
101
+ command: string,
102
+ args: string[],
103
+ cwd = "/",
104
+ ) {
105
+ return await executeInSandbox({
106
+ sandbox,
107
+ command,
108
+ args,
109
+ cwd,
110
+ timeoutMs: options.timeoutMs,
111
+ sessionStrategy: options.sessionStrategy,
112
+ sessionId: options.sessionId,
113
+ });
114
+ }
115
+
116
+ function requireZeroExit(action: string, result: { exitCode: number | null; timedOut: boolean; stderr: string }) {
117
+ if (result.timedOut) {
118
+ throw new Error(`${action} timed out: ${result.stderr.trim()}`);
119
+ }
120
+ if (result.exitCode !== 0) {
121
+ throw new Error(
122
+ `${action} failed with exit code ${result.exitCode ?? "null"}${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`,
123
+ );
124
+ }
125
+ }
126
+
127
+ async function ensureWorkspace(
128
+ sandbox: CloudflareSandbox,
129
+ options: {
130
+ remoteCwd: string;
131
+ sessionStrategy: SessionStrategy;
132
+ sessionId: string;
133
+ timeoutMs: number;
134
+ },
135
+ ) {
136
+ const result = await execLeaseUtility(sandbox, options, "mkdir", ["-p", options.remoteCwd], "/");
137
+ requireZeroExit(`ensure workspace ${options.remoteCwd}`, result);
138
+ }
139
+
140
+ async function writeSentinel(
141
+ sandbox: CloudflareSandbox,
142
+ input: {
143
+ providerLeaseId: string;
144
+ remoteCwd: string;
145
+ sessionStrategy: SessionStrategy;
146
+ sessionId: string;
147
+ keepAlive: boolean;
148
+ sleepAfter: string;
149
+ normalizeId: boolean;
150
+ resumedLease: boolean;
151
+ timeoutMs: number;
152
+ },
153
+ ) {
154
+ const sentinelPayload = JSON.stringify({
155
+ provider: "cloudflare",
156
+ providerLeaseId: input.providerLeaseId,
157
+ remoteCwd: input.remoteCwd,
158
+ sessionStrategy: input.sessionStrategy,
159
+ sessionId: input.sessionId,
160
+ keepAlive: input.keepAlive,
161
+ sleepAfter: input.sleepAfter,
162
+ normalizeId: input.normalizeId,
163
+ resumedLease: input.resumedLease,
164
+ updatedAt: new Date().toISOString(),
165
+ }, null, 2);
166
+ const sentinelPath = buildSentinelPath(input.remoteCwd);
167
+ const result = await execLeaseUtility(
168
+ sandbox,
169
+ input,
170
+ "sh",
171
+ [
172
+ "-c",
173
+ `mkdir -p ${shellQuote(input.remoteCwd)} && printf '%s\\n' ${shellQuote(sentinelPayload)} > ${shellQuote(sentinelPath)}`,
174
+ ],
175
+ "/",
176
+ );
177
+ requireZeroExit(`write sentinel ${sentinelPath}`, result);
178
+ }
179
+
180
+ async function verifySentinel(
181
+ sandbox: CloudflareSandbox,
182
+ input: {
183
+ remoteCwd: string;
184
+ sessionStrategy: SessionStrategy;
185
+ sessionId: string;
186
+ timeoutMs: number;
187
+ },
188
+ ): Promise<boolean> {
189
+ const result = await execLeaseUtility(
190
+ sandbox,
191
+ input,
192
+ "sh",
193
+ ["-c", `test -s ${shellQuote(buildSentinelPath(input.remoteCwd))}`],
194
+ "/",
195
+ );
196
+ return !result.timedOut && (result.exitCode ?? 0) === 0;
197
+ }
198
+
199
+ export async function handleBridgeRequest(request: Request, env: BridgeEnv): Promise<Response> {
200
+ if (!(await isAuthorizedRequest(request, env.BRIDGE_AUTH_TOKEN))) {
201
+ return toErrorResponse(401, "unauthorized", "Missing or invalid bridge bearer token.");
202
+ }
203
+
204
+ const url = new URL(request.url);
205
+ const pathname = url.pathname.replace(/\/+$/, "");
206
+
207
+ if (request.method === "GET" && pathname === "/api/evermore-sandbox/v1/health") {
208
+ return toJsonResponse({
209
+ ok: true,
210
+ provider: "cloudflare",
211
+ bridgeVersion: "0.1.0",
212
+ capabilities: {
213
+ reuseLease: true,
214
+ namedSessions: true,
215
+ previewUrls: false,
216
+ },
217
+ });
218
+ }
219
+
220
+ if (request.method === "POST" && pathname === "/api/evermore-sandbox/v1/probe") {
221
+ const body = await readJson<ProbeRequestBody>(request);
222
+ const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
223
+ const keepAlive = readBoolean(body.keepAlive, false);
224
+ const sleepAfter = readString(body.sleepAfter, "10m");
225
+ const normalizeId = readBoolean(body.normalizeId, true);
226
+ const sessionStrategy = readSessionStrategy(body.sessionStrategy);
227
+ const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
228
+ const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
229
+ const sandboxId = buildLeaseSandboxId({
230
+ environmentId: "probe",
231
+ runId: `probe-${Date.now()}`,
232
+ reuseLease: false,
233
+ normalizeId,
234
+ });
235
+
236
+ const sandbox = await resolveSandbox(env, sandboxId, { keepAlive, sleepAfter, normalizeId });
237
+ await applySandboxKeepAlive(sandbox, keepAlive);
238
+ try {
239
+ await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
240
+ const result = await executeInSandbox({
241
+ sandbox,
242
+ command: "pwd",
243
+ cwd: remoteCwd,
244
+ timeoutMs,
245
+ sessionStrategy,
246
+ sessionId,
247
+ });
248
+ return toJsonResponse({
249
+ ok: true,
250
+ summary: "Connected to Cloudflare sandbox bridge.",
251
+ metadata: {
252
+ provider: "cloudflare",
253
+ remoteCwd,
254
+ namedSessions: sessionStrategy === "named",
255
+ stdout: result.stdout,
256
+ },
257
+ });
258
+ } finally {
259
+ await sandbox.destroy().catch(() => undefined);
260
+ }
261
+ }
262
+
263
+ if (request.method === "POST" && pathname === "/api/evermore-sandbox/v1/leases/acquire") {
264
+ const body = await readJson<AcquireLeaseRequestBody>(request);
265
+ if (!body.environmentId || !body.runId) {
266
+ return toErrorResponse(400, "invalid_request", "environmentId and runId are required.");
267
+ }
268
+
269
+ const reuseLease = readBoolean(body.reuseLease, false);
270
+ const keepAlive = readBoolean(body.keepAlive, false);
271
+ const sleepAfter = readString(body.sleepAfter, "10m");
272
+ const normalizeId = readBoolean(body.normalizeId, true);
273
+ const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
274
+ const sessionStrategy = readSessionStrategy(body.sessionStrategy);
275
+ const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
276
+ const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
277
+ const providerLeaseId = buildLeaseSandboxId({
278
+ environmentId: body.environmentId,
279
+ runId: body.runId,
280
+ reuseLease,
281
+ normalizeId,
282
+ });
283
+ const sandbox = await resolveSandbox(env, providerLeaseId, { keepAlive, sleepAfter, normalizeId });
284
+ // Guard against orphaning a keepAlive sandbox if workspace setup throws
285
+ // after creation: Evermore never sees the lease ID in that case, so it
286
+ // can't clean up. Destroy here unless this is a reuseLease handshake
287
+ // (where the sandbox may have been created by a prior acquire and we
288
+ // shouldn't destroy it on a transient setup failure during reattachment).
289
+ try {
290
+ await applySandboxKeepAlive(sandbox, keepAlive);
291
+ await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
292
+ await writeSentinel(sandbox, {
293
+ providerLeaseId,
294
+ remoteCwd,
295
+ sessionStrategy,
296
+ sessionId,
297
+ keepAlive,
298
+ sleepAfter,
299
+ normalizeId,
300
+ resumedLease: false,
301
+ timeoutMs,
302
+ });
303
+ } catch (err) {
304
+ if (!reuseLease) {
305
+ await sandbox.destroy().catch(() => undefined);
306
+ }
307
+ throw err;
308
+ }
309
+
310
+ return toJsonResponse({
311
+ providerLeaseId,
312
+ metadata: {
313
+ provider: "cloudflare",
314
+ remoteCwd,
315
+ sandboxId: providerLeaseId,
316
+ sessionStrategy,
317
+ sessionId,
318
+ keepAlive,
319
+ sleepAfter,
320
+ normalizeId,
321
+ resumedLease: false,
322
+ },
323
+ });
324
+ }
325
+
326
+ if (request.method === "POST" && pathname === "/api/evermore-sandbox/v1/leases/resume") {
327
+ const body = await readJson<ResumeLeaseRequestBody>(request);
328
+ if (!body.providerLeaseId) {
329
+ return toErrorResponse(400, "invalid_request", "providerLeaseId is required.");
330
+ }
331
+ const keepAlive = readBoolean(body.keepAlive, false);
332
+ const sleepAfter = readString(body.sleepAfter, "10m");
333
+ const normalizeId = readBoolean(body.normalizeId, true);
334
+ const remoteCwd = readString(body.requestedCwd, DEFAULT_REMOTE_CWD);
335
+ const sessionStrategy = readSessionStrategy(body.sessionStrategy);
336
+ const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
337
+ const timeoutMs = readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS);
338
+ const sandbox = await resolveSandbox(env, body.providerLeaseId, { keepAlive, sleepAfter, normalizeId });
339
+ // Resume always reattaches to a providerLeaseId the operator already
340
+ // owns, so we deliberately do NOT destroy on failure here — the operator
341
+ // has the ID and can issue an explicit release/destroy. Calling
342
+ // `getSandbox` is idempotent on the Sandbox SDK side (no new sandbox is
343
+ // created), so a failed resume doesn't leak a *new* sandbox.
344
+ await applySandboxKeepAlive(sandbox, keepAlive);
345
+
346
+ if (!(await verifySentinel(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs }))) {
347
+ return toErrorResponse(409, "sandbox_state_lost", "Cloudflare sandbox state is no longer available.");
348
+ }
349
+
350
+ await ensureWorkspace(sandbox, { remoteCwd, sessionStrategy, sessionId, timeoutMs });
351
+ await writeSentinel(sandbox, {
352
+ providerLeaseId: body.providerLeaseId,
353
+ remoteCwd,
354
+ sessionStrategy,
355
+ sessionId,
356
+ keepAlive,
357
+ sleepAfter,
358
+ normalizeId,
359
+ resumedLease: true,
360
+ timeoutMs,
361
+ });
362
+
363
+ return toJsonResponse({
364
+ providerLeaseId: body.providerLeaseId,
365
+ metadata: {
366
+ provider: "cloudflare",
367
+ remoteCwd,
368
+ sandboxId: body.providerLeaseId,
369
+ sessionStrategy,
370
+ sessionId,
371
+ keepAlive,
372
+ sleepAfter,
373
+ normalizeId,
374
+ resumedLease: true,
375
+ },
376
+ });
377
+ }
378
+
379
+ if (request.method === "POST" && pathname === "/api/evermore-sandbox/v1/leases/release") {
380
+ const body = await readJson<ReleaseLeaseRequestBody>(request);
381
+ if (!body.providerLeaseId) {
382
+ return toJsonResponse({ ok: true });
383
+ }
384
+ if (readBoolean(body.reuseLease, false)) {
385
+ return toJsonResponse({ ok: true });
386
+ }
387
+ const sandbox = await resolveSandbox(env, body.providerLeaseId, {
388
+ keepAlive: readBoolean(body.keepAlive, false),
389
+ sleepAfter: "10m",
390
+ normalizeId: true,
391
+ });
392
+ await sandbox.destroy().catch(() => undefined);
393
+ return toJsonResponse({ ok: true });
394
+ }
395
+
396
+ if (request.method === "DELETE" && pathname.startsWith("/api/evermore-sandbox/v1/leases/")) {
397
+ const providerLeaseId = decodeURIComponent(pathname.split("/").pop() ?? "");
398
+ if (providerLeaseId.length === 0) {
399
+ return toErrorResponse(400, "invalid_request", "providerLeaseId path parameter is required.");
400
+ }
401
+ const sandbox = await resolveSandbox(env, providerLeaseId, {
402
+ keepAlive: false,
403
+ sleepAfter: "10m",
404
+ normalizeId: true,
405
+ });
406
+ await sandbox.destroy().catch(() => undefined);
407
+ return toJsonResponse({ ok: true });
408
+ }
409
+
410
+ if (request.method === "POST" && pathname === "/api/evermore-sandbox/v1/exec") {
411
+ const body = await readJson<ExecuteRequestBody>(request);
412
+ if (!body.providerLeaseId || !body.command) {
413
+ return toErrorResponse(400, "invalid_request", "providerLeaseId and command are required.");
414
+ }
415
+ const sessionStrategy = readSessionStrategy(body.sessionStrategy);
416
+ const sessionId = readString(body.sessionId, DEFAULT_SESSION_ID);
417
+ const sandbox = await resolveSandbox(env, body.providerLeaseId, {
418
+ keepAlive: false,
419
+ sleepAfter: "10m",
420
+ normalizeId: true,
421
+ });
422
+ if (body.streamOutput === true) {
423
+ const encoder = new TextEncoder();
424
+ const stream = new ReadableStream<Uint8Array>({
425
+ async start(controller) {
426
+ try {
427
+ const result = await executeInSandbox({
428
+ sandbox,
429
+ command: body.command!,
430
+ args: Array.isArray(body.args) ? body.args.filter((value): value is string => typeof value === "string") : [],
431
+ cwd: typeof body.cwd === "string" ? body.cwd : undefined,
432
+ env: body.env,
433
+ stdin: body.stdin ?? null,
434
+ timeoutMs: readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS),
435
+ sessionStrategy,
436
+ sessionId,
437
+ onOutput: async (streamName, data) => {
438
+ controller.enqueue(encoder.encode(encodeSseEvent(streamName, { data })));
439
+ },
440
+ });
441
+ controller.enqueue(encoder.encode(encodeSseEvent("complete", result)));
442
+ } catch (error) {
443
+ controller.enqueue(encoder.encode(encodeSseEvent("error", {
444
+ error: error instanceof Error ? error.message : String(error),
445
+ })));
446
+ } finally {
447
+ controller.close();
448
+ }
449
+ },
450
+ });
451
+ return toSseResponse(stream);
452
+ }
453
+ const result = await executeInSandbox({
454
+ sandbox,
455
+ command: body.command,
456
+ args: Array.isArray(body.args) ? body.args.filter((value): value is string => typeof value === "string") : [],
457
+ cwd: typeof body.cwd === "string" ? body.cwd : undefined,
458
+ env: body.env,
459
+ stdin: body.stdin ?? null,
460
+ timeoutMs: readInteger(body.timeoutMs, DEFAULT_TIMEOUT_MS),
461
+ sessionStrategy,
462
+ sessionId,
463
+ });
464
+ return toJsonResponse(result);
465
+ }
466
+
467
+ return toErrorResponse(404, "not_found", `No bridge route matched ${request.method} ${pathname}.`);
468
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildLeaseSandboxId, buildSentinelPath, isTimeoutError } from "./helpers.js";
3
+
4
+ describe("bridge sandbox helpers", () => {
5
+ it("builds reusable lease IDs from environment IDs", () => {
6
+ expect(buildLeaseSandboxId({
7
+ environmentId: "Env_123",
8
+ runId: "run-ignored",
9
+ reuseLease: true,
10
+ normalizeId: true,
11
+ })).toBe("pc-env-env-123");
12
+ });
13
+
14
+ it("builds ephemeral lease IDs from run IDs", () => {
15
+ expect(buildLeaseSandboxId({
16
+ environmentId: "env-1",
17
+ runId: "Run_123",
18
+ reuseLease: false,
19
+ normalizeId: true,
20
+ randomId: "ABCD1234",
21
+ })).toBe("pc-run-123-abcd1234");
22
+ });
23
+
24
+ it("builds the workspace sentinel path", () => {
25
+ expect(buildSentinelPath("/workspace/evermore/")).toBe("/workspace/evermore/.evermore-lease.json");
26
+ });
27
+
28
+ it("detects timeout-shaped errors", () => {
29
+ expect(isTimeoutError(new Error("command timed out after 10s"))).toBe(true);
30
+ expect(isTimeoutError(new Error("some other error"))).toBe(false);
31
+ });
32
+ });
@@ -0,0 +1,57 @@
1
+ import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
2
+ import { getSandbox } from "@cloudflare/sandbox";
3
+ import { buildLeaseSandboxId, buildSentinelPath, isTimeoutError } from "./helpers.js";
4
+
5
+ export interface BridgeEnv {
6
+ Sandbox: DurableObjectNamespace<CloudflareSandbox>;
7
+ BRIDGE_AUTH_TOKEN?: string;
8
+ }
9
+
10
+ export interface BridgeLeaseConfig {
11
+ keepAlive: boolean;
12
+ sleepAfter: string;
13
+ normalizeId: boolean;
14
+ }
15
+
16
+ export const DEFAULT_REMOTE_CWD = "/workspace/evermore";
17
+ export const DEFAULT_SESSION_ID = "evermore";
18
+ export const DEFAULT_TIMEOUT_MS = 300_000;
19
+ export const LEASE_SENTINEL_FILE = ".evermore-lease.json";
20
+
21
+ export function toJsonResponse(body: unknown, status = 200): Response {
22
+ return new Response(JSON.stringify(body), {
23
+ status,
24
+ headers: {
25
+ "Content-Type": "application/json",
26
+ },
27
+ });
28
+ }
29
+
30
+ export function toErrorResponse(status: number, error: string, message: string, details?: unknown): Response {
31
+ return toJsonResponse({ error, message, details }, status);
32
+ }
33
+
34
+ export async function resolveSandbox(
35
+ env: BridgeEnv,
36
+ sandboxId: string,
37
+ config: BridgeLeaseConfig,
38
+ ): Promise<CloudflareSandbox> {
39
+ // Pure handle resolution: the constructor accepts keepAlive/sleepAfter so the
40
+ // sandbox is created with the right defaults on first use, but we no longer
41
+ // call `setKeepAlive` here. That side effect now lives in
42
+ // `applySandboxKeepAlive` and is invoked only from lease-management routes,
43
+ // so exec calls don't accidentally overwrite the lease's keepAlive policy.
44
+ return getSandbox(env.Sandbox, sandboxId, {
45
+ keepAlive: config.keepAlive,
46
+ sleepAfter: config.sleepAfter,
47
+ });
48
+ }
49
+
50
+ export async function applySandboxKeepAlive(
51
+ sandbox: CloudflareSandbox,
52
+ keepAlive: boolean,
53
+ ): Promise<void> {
54
+ await sandbox.setKeepAlive(keepAlive);
55
+ }
56
+
57
+ export { buildLeaseSandboxId, buildSentinelPath, isTimeoutError };