@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.
- package/README.md +48 -0
- package/bridge-template/Dockerfile +14 -0
- package/bridge-template/README.md +50 -0
- package/bridge-template/package.json +21 -0
- package/bridge-template/src/auth.test.ts +30 -0
- package/bridge-template/src/auth.ts +40 -0
- package/bridge-template/src/exec.test.ts +151 -0
- package/bridge-template/src/exec.ts +147 -0
- package/bridge-template/src/helpers.ts +39 -0
- package/bridge-template/src/index.ts +25 -0
- package/bridge-template/src/routes.test.ts +143 -0
- package/bridge-template/src/routes.ts +468 -0
- package/bridge-template/src/sandboxes.test.ts +32 -0
- package/bridge-template/src/sandboxes.ts +57 -0
- package/bridge-template/src/sessions.ts +84 -0
- package/bridge-template/tsconfig.json +11 -0
- package/bridge-template/vitest.config.ts +8 -0
- package/bridge-template/wrangler.jsonc +28 -0
- package/dist/bridge-client.d.ts +39 -0
- package/dist/bridge-client.d.ts.map +1 -0
- package/dist/bridge-client.js +232 -0
- package/dist/bridge-client.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +71 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +4 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +91 -0
- package/dist/manifest.js.map +1 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +267 -0
- package/dist/plugin.js.map +1 -0
- package/dist/types.d.ts +91 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/worker.d.ts +3 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +5 -0
- package/dist/worker.js.map +1 -0
- 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
|
+
};
|