@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
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # `@evermore.work/plugin-cloudflare-sandbox`
2
+
3
+ Published Cloudflare sandbox provider plugin for Evermore.
4
+
5
+ This package lives in the Evermore monorepo, but it is intentionally excluded from the root `pnpm` workspace and shaped to publish and install like a standalone npm package. Operators can install it from the Plugins page by package name, and the host will fetch its dependencies at install time without adding lockfile churn to the Evermore repo.
6
+
7
+ ## Install
8
+
9
+ From an Evermore instance, install:
10
+
11
+ ```text
12
+ @evermore.work/plugin-cloudflare-sandbox
13
+ ```
14
+
15
+ Configure Cloudflare from `Company Settings -> Environments`, not from the plugin's instance settings page.
16
+
17
+ ## Configuration
18
+
19
+ The environment uses core `driver: "sandbox"` with `provider: "cloudflare"`.
20
+
21
+ Required fields:
22
+
23
+ - `bridgeBaseUrl`
24
+ - `bridgeAuthToken`
25
+
26
+ Important validation rules:
27
+
28
+ - `reuseLease: true` requires `keepAlive: true`
29
+ - non-local `bridgeBaseUrl` values must be `https://`
30
+ - `sessionId` is required when `sessionStrategy` is `named`
31
+
32
+ Pasted auth tokens are stored by Evermore as company secrets because the manifest marks `bridgeAuthToken` as a `secret-ref` field.
33
+
34
+ ## Bridge template
35
+
36
+ The package includes an operator-facing Cloudflare Worker scaffold under [bridge-template](./bridge-template). That template uses `@cloudflare/sandbox`, a `Sandbox` Durable Object binding, and a small JSON HTTP surface under `/api/evermore-sandbox/v1`.
37
+
38
+ ## Local development
39
+
40
+ ```bash
41
+ cd packages/plugins/sandbox-providers/cloudflare
42
+ pnpm install --ignore-workspace --no-lockfile
43
+ pnpm build
44
+ pnpm test
45
+ pnpm typecheck
46
+ ```
47
+
48
+ These commands assume the repo root has already been installed once so the local `@evermore.work/plugin-sdk` workspace package is available to the compiler during development.
@@ -0,0 +1,14 @@
1
+ FROM docker.io/cloudflare/sandbox:0.7.0
2
+
3
+ RUN apt-get update \
4
+ && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
5
+ bash \
6
+ ca-certificates \
7
+ coreutils \
8
+ curl \
9
+ findutils \
10
+ git \
11
+ tar \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /workspace
@@ -0,0 +1,50 @@
1
+ # Cloudflare Sandbox Bridge Template
2
+
3
+ This Worker is the operator-facing bridge used by `@evermore.work/plugin-cloudflare-sandbox`.
4
+
5
+ It exposes a small authenticated JSON API under `/api/evermore-sandbox/v1` and translates Evermore lease and command requests into Cloudflare Sandbox SDK calls.
6
+
7
+ ## What it does
8
+
9
+ - health and probe
10
+ - acquire, resume, release, and destroy leases
11
+ - execute commands in a sandbox session
12
+ - clean up timed-out sessions so Evermore does not inherit wedged background processes
13
+
14
+ ## Prerequisites
15
+
16
+ 1. Cloudflare account with Sandbox / Containers access
17
+ 2. `wrangler` configured for that account
18
+ 3. Docker running locally for `wrangler deploy`
19
+ 4. A bridge auth token set as a Worker secret:
20
+
21
+ ```bash
22
+ npx wrangler secret put BRIDGE_AUTH_TOKEN
23
+ ```
24
+
25
+ ## Local development
26
+
27
+ ```bash
28
+ cd bridge-template
29
+ pnpm install --ignore-workspace --no-lockfile
30
+ pnpm test
31
+ pnpm typecheck
32
+ pnpm dev
33
+ ```
34
+
35
+ ## Deploy
36
+
37
+ ```bash
38
+ pnpm deploy
39
+ ```
40
+
41
+ After deploy, configure Evermore with:
42
+
43
+ - `bridgeBaseUrl`: your Worker URL
44
+ - `bridgeAuthToken`: the same bearer token value stored in `BRIDGE_AUTH_TOKEN`
45
+
46
+ ## Notes
47
+
48
+ - `reuseLease: true` should only be used together with `keepAlive: true`
49
+ - `.workers.dev` is fine for bridge HTTP traffic, but preview/wildcard host flows are intentionally out of scope here
50
+ - keep the Docker image aligned with the installed `@cloudflare/sandbox` version
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "evermore-cloudflare-sandbox-bridge-template",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "wrangler dev",
7
+ "deploy": "wrangler deploy",
8
+ "build": "tsc --noEmit",
9
+ "typecheck": "tsc --noEmit",
10
+ "test": "vitest run --config vitest.config.ts"
11
+ },
12
+ "dependencies": {
13
+ "@cloudflare/sandbox": "^0.7.0"
14
+ },
15
+ "devDependencies": {
16
+ "@cloudflare/workers-types": "^4.20260501.0",
17
+ "typescript": "^5.7.3",
18
+ "vitest": "^3.2.4",
19
+ "wrangler": "^4.15.0"
20
+ }
21
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isAuthorizedRequest, readBearerToken } from "./auth.js";
3
+
4
+ describe("bridge auth", () => {
5
+ it("extracts bearer tokens from Authorization headers", () => {
6
+ const request = new Request("https://bridge.example.test", {
7
+ headers: { Authorization: "Bearer secret-token" },
8
+ });
9
+ expect(readBearerToken(request)).toBe("secret-token");
10
+ });
11
+
12
+ it("rejects mismatched tokens", async () => {
13
+ const request = new Request("https://bridge.example.test", {
14
+ headers: { Authorization: "Bearer wrong-token" },
15
+ });
16
+ await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(false);
17
+ });
18
+
19
+ it("accepts matching tokens", async () => {
20
+ const request = new Request("https://bridge.example.test", {
21
+ headers: { Authorization: "Bearer expected-token" },
22
+ });
23
+ await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(true);
24
+ });
25
+
26
+ it("rejects requests without an Authorization header", async () => {
27
+ const request = new Request("https://bridge.example.test");
28
+ await expect(isAuthorizedRequest(request, "expected-token")).resolves.toBe(false);
29
+ });
30
+ });
@@ -0,0 +1,40 @@
1
+ export function readBearerToken(request: Request): string | null {
2
+ const header = request.headers.get("Authorization");
3
+ if (!header) return null;
4
+ const match = /^Bearer\s+(.+)$/i.exec(header);
5
+ return match?.[1]?.trim() || null;
6
+ }
7
+
8
+ // Compare two strings in constant time so an attacker can't infer the expected
9
+ // token character-by-character via response-latency timing. We hash both sides
10
+ // to SHA-256 first so the byte-by-byte comparison length is fixed (and doesn't
11
+ // leak the token's length), then walk the buffers with a constant-time XOR
12
+ // reduction. This avoids `crypto.subtle.timingSafeEqual` because that helper
13
+ // is not portable: it exists on Cloudflare Workers but is missing from Node's
14
+ // `crypto.subtle` (which would break unit tests). The manual XOR reduction on
15
+ // a fixed-length hash output is the same algorithm the helper uses internally.
16
+ async function timingSafeStringEqual(a: string, b: string): Promise<boolean> {
17
+ const encoder = new TextEncoder();
18
+ const [aHashBuf, bHashBuf] = await Promise.all([
19
+ crypto.subtle.digest("SHA-256", encoder.encode(a)),
20
+ crypto.subtle.digest("SHA-256", encoder.encode(b)),
21
+ ]);
22
+ const aBytes = new Uint8Array(aHashBuf);
23
+ const bBytes = new Uint8Array(bHashBuf);
24
+ if (aBytes.length !== bBytes.length) return false;
25
+ let diff = 0;
26
+ for (let i = 0; i < aBytes.length; i++) {
27
+ diff |= aBytes[i] ^ bBytes[i];
28
+ }
29
+ return diff === 0;
30
+ }
31
+
32
+ export async function isAuthorizedRequest(
33
+ request: Request,
34
+ expectedToken: string | undefined,
35
+ ): Promise<boolean> {
36
+ if (!expectedToken || expectedToken.trim().length === 0) return false;
37
+ const presented = readBearerToken(request);
38
+ if (!presented) return false;
39
+ return timingSafeStringEqual(presented, expectedToken);
40
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ vi.mock("@cloudflare/sandbox", () => ({
4
+ getSandbox: vi.fn(),
5
+ }));
6
+
7
+ import { buildLoginShellScript, executeInSandbox } from "./exec.js";
8
+
9
+ describe("bridge exec", () => {
10
+ it("invokes target.exec with a single shell command string and no args option", async () => {
11
+ const exec = vi.fn().mockResolvedValue({
12
+ exitCode: 0,
13
+ stdout: "claude 1.0.0\n",
14
+ stderr: "",
15
+ });
16
+ const sandbox = {
17
+ getSession: vi.fn().mockResolvedValue({ exec }),
18
+ writeFile: vi.fn(),
19
+ deleteFile: vi.fn(),
20
+ } as const;
21
+
22
+ await executeInSandbox({
23
+ sandbox: sandbox as never,
24
+ command: "claude",
25
+ args: ["--version"],
26
+ cwd: "/workspace/evermore",
27
+ env: { EVERMORE_TEST_FLAG: "1" },
28
+ sessionStrategy: "named",
29
+ sessionId: "evermore",
30
+ timeoutMs: 12_345,
31
+ });
32
+
33
+ expect(exec).toHaveBeenCalledTimes(1);
34
+ const [commandArg, optionsArg] = exec.mock.calls[0] ?? [];
35
+ expect(typeof commandArg).toBe("string");
36
+ expect(commandArg).toMatch(/^sh -lc /);
37
+ expect(optionsArg).toEqual({ cwd: "/", timeout: 12_345 });
38
+ expect(optionsArg).not.toHaveProperty("args");
39
+ expect(optionsArg).not.toHaveProperty("stdin");
40
+ expect(commandArg).toContain('. /etc/profile');
41
+ expect(commandArg).toContain("cd ");
42
+ expect(commandArg).toContain("/workspace/evermore");
43
+ expect(commandArg).toContain("EVERMORE_TEST_FLAG");
44
+ expect(commandArg).toContain("claude");
45
+ expect(commandArg).toContain("--version");
46
+ });
47
+
48
+ it("requests streaming callbacks when bridge output forwarding is enabled", async () => {
49
+ const exec = vi.fn().mockImplementation(async (_command, options) => {
50
+ await options?.onOutput?.("stdout", "hello\n");
51
+ return {
52
+ exitCode: 0,
53
+ stdout: "hello\n",
54
+ stderr: "",
55
+ };
56
+ });
57
+ const sandbox = {
58
+ getSession: vi.fn().mockResolvedValue({ exec }),
59
+ writeFile: vi.fn(),
60
+ deleteFile: vi.fn(),
61
+ } as const;
62
+ const onOutput = vi.fn();
63
+
64
+ await executeInSandbox({
65
+ sandbox: sandbox as never,
66
+ command: "echo",
67
+ args: ["hello"],
68
+ sessionStrategy: "named",
69
+ sessionId: "evermore",
70
+ timeoutMs: 5_000,
71
+ onOutput,
72
+ });
73
+
74
+ expect(exec).toHaveBeenCalledTimes(1);
75
+ expect(exec.mock.calls[0]?.[1]).toMatchObject({
76
+ cwd: "/",
77
+ timeout: 5_000,
78
+ stream: true,
79
+ onOutput: expect.any(Function),
80
+ });
81
+ expect(onOutput).toHaveBeenCalledWith("stdout", "hello\n");
82
+ });
83
+
84
+ it("stages stdin through a sandbox temp file and redirects from it", async () => {
85
+ const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
86
+ const writeFile = vi.fn().mockResolvedValue(undefined);
87
+ const deleteFile = vi.fn().mockResolvedValue(undefined);
88
+ // sessionStrategy: "default" routes through the sandbox itself (no
89
+ // getSession wrapper), so exec must live directly on the sandbox.
90
+ const sandbox = {
91
+ exec,
92
+ getSession: vi.fn(),
93
+ writeFile,
94
+ deleteFile,
95
+ } as const;
96
+
97
+ await executeInSandbox({
98
+ sandbox: sandbox as never,
99
+ command: "cat",
100
+ args: [],
101
+ sessionStrategy: "default",
102
+ timeoutMs: 5_000,
103
+ stdin: "payload-bytes",
104
+ });
105
+
106
+ expect(writeFile).toHaveBeenCalledTimes(1);
107
+ const [stdinPath, stdinPayload] = writeFile.mock.calls[0] ?? [];
108
+ expect(typeof stdinPath).toBe("string");
109
+ expect(stdinPath).toMatch(/^\/tmp\/\.evermore-bridge-stdin-/);
110
+ expect(stdinPayload).toBe("payload-bytes");
111
+
112
+ const commandArg = exec.mock.calls[0]?.[0];
113
+ expect(commandArg).toContain(stdinPath);
114
+ expect(commandArg).toMatch(/<\s*['"]/);
115
+
116
+ expect(deleteFile).toHaveBeenCalledWith(stdinPath);
117
+ });
118
+
119
+ it("does not write a stdin file or redirect when stdin is empty", async () => {
120
+ const exec = vi.fn().mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" });
121
+ const writeFile = vi.fn();
122
+ const deleteFile = vi.fn();
123
+ const sandbox = {
124
+ getSession: vi.fn().mockResolvedValue({ exec }),
125
+ writeFile,
126
+ deleteFile,
127
+ } as const;
128
+
129
+ await executeInSandbox({
130
+ sandbox: sandbox as never,
131
+ command: "pwd",
132
+ sessionStrategy: "named",
133
+ sessionId: "evermore",
134
+ timeoutMs: 5_000,
135
+ stdin: null,
136
+ });
137
+
138
+ expect(writeFile).not.toHaveBeenCalled();
139
+ expect(deleteFile).not.toHaveBeenCalled();
140
+ const commandArg = exec.mock.calls[0]?.[0];
141
+ expect(commandArg).not.toContain("<");
142
+ });
143
+
144
+ it("rejects invalid environment variable keys in the login-shell wrapper", () => {
145
+ expect(() => buildLoginShellScript({
146
+ command: "pwd",
147
+ args: [],
148
+ env: { "bad-key": "1" },
149
+ })).toThrow("Invalid sandbox environment variable key: bad-key");
150
+ });
151
+ });
@@ -0,0 +1,147 @@
1
+ import type { Sandbox as CloudflareSandbox } from "@cloudflare/sandbox";
2
+ import { shellQuote } from "./helpers.js";
3
+ import { isTimeoutError } from "./sandboxes.js";
4
+ import { cleanupTimedOutExecution, resolveExecutionTarget, type SessionStrategy } from "./sessions.js";
5
+
6
+ export interface BridgeExecuteParams {
7
+ sandbox: CloudflareSandbox;
8
+ command: string;
9
+ args?: string[];
10
+ cwd?: string;
11
+ env?: Record<string, string>;
12
+ stdin?: string | null;
13
+ timeoutMs?: number;
14
+ sessionStrategy: SessionStrategy;
15
+ sessionId?: string;
16
+ onOutput?: (stream: "stdout" | "stderr", data: string) => void | Promise<void>;
17
+ }
18
+
19
+ function isValidShellEnvKey(value: string): boolean {
20
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
21
+ }
22
+
23
+ function randomToken(): string {
24
+ const uuid = globalThis.crypto?.randomUUID?.();
25
+ if (typeof uuid === "string" && uuid.length > 0) return uuid.replace(/[^a-zA-Z0-9-]/g, "");
26
+ return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
27
+ }
28
+
29
+ export function buildLoginShellScript(input: {
30
+ command: string;
31
+ args: string[];
32
+ cwd?: string;
33
+ env?: Record<string, string>;
34
+ stdinFile?: string | null;
35
+ }): string {
36
+ const env = input.env ?? {};
37
+ for (const key of Object.keys(env)) {
38
+ if (!isValidShellEnvKey(key)) {
39
+ throw new Error(`Invalid sandbox environment variable key: ${key}`);
40
+ }
41
+ }
42
+
43
+ const envArgs = Object.entries(env)
44
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string")
45
+ .map(([key, value]) => `${key}=${shellQuote(value)}`);
46
+ const commandParts = [shellQuote(input.command), ...input.args.map(shellQuote)].join(" ");
47
+ const stdinRedirect = input.stdinFile ? ` < ${shellQuote(input.stdinFile)}` : "";
48
+ const lines = [
49
+ 'if [ -f /etc/profile ]; then . /etc/profile >/dev/null 2>&1 || true; fi',
50
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile" >/dev/null 2>&1 || true; fi',
51
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile" >/dev/null 2>&1 || true; elif [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" >/dev/null 2>&1 || true; fi',
52
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile" >/dev/null 2>&1 || true; fi',
53
+ 'export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"',
54
+ '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" >/dev/null 2>&1 || true',
55
+ ];
56
+ if (input.cwd) {
57
+ lines.push(`cd ${shellQuote(input.cwd)}`);
58
+ }
59
+ const execLine = envArgs.length > 0
60
+ ? `exec env ${envArgs.join(" ")} ${commandParts}${stdinRedirect}`
61
+ : `exec ${commandParts}${stdinRedirect}`;
62
+ lines.push(execLine);
63
+ return lines.join(" && ");
64
+ }
65
+
66
+ function coerceExecuteResult(result: {
67
+ success?: boolean;
68
+ stdout?: string;
69
+ stderr?: string;
70
+ exitCode?: number | null;
71
+ }) {
72
+ return {
73
+ exitCode:
74
+ typeof result.exitCode === "number" || result.exitCode === null
75
+ ? result.exitCode
76
+ : result.success === false
77
+ ? 1
78
+ : 0,
79
+ signal: null,
80
+ timedOut: false,
81
+ stdout: result.stdout ?? "",
82
+ stderr: result.stderr ?? "",
83
+ };
84
+ }
85
+
86
+ export async function executeInSandbox(params: BridgeExecuteParams) {
87
+ // The @cloudflare/sandbox SDK's exec() takes a single command string and a
88
+ // narrow option set ({ cwd, env, timeout, ... }) — it does not accept `args`
89
+ // or `stdin`. We compose the full shell command ourselves and stage stdin
90
+ // through a temp file in the sandbox when the caller provides one.
91
+ const stdinPayload = typeof params.stdin === "string" && params.stdin.length > 0
92
+ ? params.stdin
93
+ : null;
94
+ const stdinFile = stdinPayload ? `/tmp/.evermore-bridge-stdin-${randomToken()}` : null;
95
+
96
+ if (stdinFile && stdinPayload) {
97
+ await params.sandbox.writeFile(stdinFile, stdinPayload, { encoding: "utf8" });
98
+ }
99
+
100
+ try {
101
+ const target = await resolveExecutionTarget(params.sandbox, {
102
+ sessionStrategy: params.sessionStrategy,
103
+ sessionId: params.sessionId,
104
+ cwd: params.cwd,
105
+ env: params.env,
106
+ timeoutMs: params.timeoutMs,
107
+ });
108
+ const script = buildLoginShellScript({
109
+ command: params.command,
110
+ args: params.args ?? [],
111
+ cwd: params.cwd,
112
+ env: params.env,
113
+ stdinFile,
114
+ });
115
+ const fullCommand = `sh -lc ${shellQuote(script)}`;
116
+ const result = await target.exec(fullCommand, {
117
+ cwd: "/",
118
+ timeout: params.timeoutMs,
119
+ ...(typeof params.onOutput === "function"
120
+ ? {
121
+ stream: true,
122
+ onOutput: params.onOutput,
123
+ }
124
+ : {}),
125
+ });
126
+ return coerceExecuteResult(result);
127
+ } catch (error) {
128
+ if (isTimeoutError(error)) {
129
+ await cleanupTimedOutExecution(params.sandbox, {
130
+ sessionStrategy: params.sessionStrategy,
131
+ sessionId: params.sessionId,
132
+ });
133
+ return {
134
+ exitCode: null,
135
+ signal: null,
136
+ timedOut: true,
137
+ stdout: typeof (error as { stdout?: unknown }).stdout === "string" ? (error as { stdout: string }).stdout : "",
138
+ stderr: `${error instanceof Error ? error.message : String(error)}\n`,
139
+ };
140
+ }
141
+ throw error;
142
+ } finally {
143
+ if (stdinFile) {
144
+ await params.sandbox.deleteFile?.(stdinFile).catch(() => undefined);
145
+ }
146
+ }
147
+ }
@@ -0,0 +1,39 @@
1
+ export function normalizeLeaseIdPart(input: string): string {
2
+ return input
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9-]+/g, "-")
6
+ .replace(/^-+|-+$/g, "")
7
+ .replace(/-{2,}/g, "-");
8
+ }
9
+
10
+ export function buildLeaseSandboxId(input: {
11
+ environmentId: string;
12
+ runId: string;
13
+ reuseLease: boolean;
14
+ normalizeId: boolean;
15
+ randomId?: string;
16
+ }): string {
17
+ const base = input.reuseLease
18
+ ? `pc-env-${input.environmentId}`
19
+ : `pc-${input.runId}-${input.randomId ?? crypto.randomUUID().slice(0, 8)}`;
20
+ return input.normalizeId ? normalizeLeaseIdPart(base) : base;
21
+ }
22
+
23
+ export function buildSentinelPath(remoteCwd: string): string {
24
+ return `${remoteCwd.replace(/\/+$/, "")}/.evermore-lease.json`;
25
+ }
26
+
27
+ export function isTimeoutError(error: unknown): boolean {
28
+ const name = (error as { name?: string } | null)?.name ?? "";
29
+ const message = error instanceof Error ? error.message : String(error);
30
+ return /timeout/i.test(name) || /timed out|timeout/i.test(message);
31
+ }
32
+
33
+ // Single-quote `value` for safe inclusion in a `sh -c` script. Single
34
+ // quotes inside the value are escaped via the standard `'"'"'` dance.
35
+ // Used by both `routes.ts` and `exec.ts` — keep one copy here so updates
36
+ // (e.g. handling additional shell special characters) stay in sync.
37
+ export function shellQuote(value: string): string {
38
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
39
+ }
@@ -0,0 +1,25 @@
1
+ import { Sandbox } from "@cloudflare/sandbox";
2
+ import { handleBridgeRequest, } from "./routes.js";
3
+ import type { BridgeEnv } from "./sandboxes.js";
4
+
5
+ export { Sandbox };
6
+
7
+ export default {
8
+ async fetch(request: Request, env: BridgeEnv): Promise<Response> {
9
+ try {
10
+ return await handleBridgeRequest(request, env);
11
+ } catch (error) {
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ return new Response(
14
+ JSON.stringify({
15
+ error: "internal_error",
16
+ message,
17
+ }),
18
+ {
19
+ status: 500,
20
+ headers: { "Content-Type": "application/json" },
21
+ },
22
+ );
23
+ }
24
+ },
25
+ };