@claxedo/sandbox-manager 0.5.1

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 (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/command.d.ts +3 -0
  4. package/dist/command.mjs +18 -0
  5. package/dist/constants.d.ts +1 -0
  6. package/dist/constants.mjs +7 -0
  7. package/dist/daytona-allow-list.d.ts +5 -0
  8. package/dist/daytona-allow-list.mjs +9 -0
  9. package/dist/defaults.d.ts +3 -0
  10. package/dist/defaults.mjs +14 -0
  11. package/dist/driver-catalog.d.ts +62 -0
  12. package/dist/driver-catalog.mjs +206 -0
  13. package/dist/drivers/cloudflare-egress.d.ts +40 -0
  14. package/dist/drivers/cloudflare-egress.mjs +115 -0
  15. package/dist/drivers/cloudflare.d.ts +37 -0
  16. package/dist/drivers/cloudflare.mjs +151 -0
  17. package/dist/drivers/daytona.d.ts +94 -0
  18. package/dist/drivers/daytona.mjs +235 -0
  19. package/dist/drivers/docker.d.ts +36 -0
  20. package/dist/drivers/docker.mjs +329 -0
  21. package/dist/drivers/fetch-bridge.d.ts +10 -0
  22. package/dist/drivers/fetch-bridge.mjs +104 -0
  23. package/dist/drivers/modal.d.ts +68 -0
  24. package/dist/drivers/modal.mjs +227 -0
  25. package/dist/drivers/vercel.d.ts +109 -0
  26. package/dist/drivers/vercel.mjs +297 -0
  27. package/dist/image-name.d.ts +7 -0
  28. package/dist/image.d.ts +8 -0
  29. package/dist/image.mjs +124 -0
  30. package/dist/index.d.ts +316 -0
  31. package/dist/index.mjs +330 -0
  32. package/dist/lease-policy.d.ts +49 -0
  33. package/dist/lease-policy.mjs +126 -0
  34. package/dist/lease-types.d.ts +89 -0
  35. package/dist/lease-types.mjs +1 -0
  36. package/dist/runtime-env.d.ts +24 -0
  37. package/dist/runtime-env.mjs +35 -0
  38. package/dist/runtime-version.d.ts +1 -0
  39. package/dist/runtime-version.mjs +24 -0
  40. package/dist/service-url-exposure.d.ts +10 -0
  41. package/dist/service-url-exposure.mjs +46 -0
  42. package/dist/stores/memory.d.ts +6 -0
  43. package/dist/stores/memory.mjs +117 -0
  44. package/package.json +178 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Claxedo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @claxedo/sandbox-manager
2
+
3
+ Open-sourceable sandbox placement and lease management for running `@claxedo/workspace-runtime` inside provider sandboxes.
4
+
5
+ The package owns the generic pieces:
6
+
7
+ - `SandboxManager`
8
+ - `SandboxLease`
9
+ - `SandboxTarget`
10
+ - `SandboxLeaseStore`
11
+ - `SandboxDriver`
12
+ - provider drivers for Cloudflare, Daytona, Docker, fetch bridge, Modal, and Vercel
13
+
14
+ It deliberately does not own Claxedo product auth, billing, Convex schema, SQLite app storage, routes, or relay tokens. Applications provide those through adapters and call `createSandboxManager`.
15
+
16
+ ## Credentials & secrets
17
+
18
+ There are two distinct channels for getting values into a sandbox, chosen by
19
+ whether the code inside the sandbox is trusted with the raw value.
20
+
21
+ ### `env` — readable, for trusted credentials
22
+
23
+ `SandboxManagerInput.env` (and the driver-level `env`) sets ordinary
24
+ environment variables, readable inside the sandbox via `process.env`. Reserve
25
+ this for credentials the agent is *meant* to hold — e.g. the user's own model
26
+ subscription/API key, which the agent needs to call the model directly.
27
+
28
+ ### `secrets` — brokered, never readable inside the sandbox
29
+
30
+ `SandboxManagerInput.secrets: SandboxBrokeredSecret[]` is for credentials the
31
+ sandbox must be able to *use* on outbound requests but must never be able to
32
+ *read* or exfiltrate (connection tokens, deploy tokens). The raw value never
33
+ enters the sandbox: the provider injects it on egress to an allowlist of
34
+ `hosts` only.
35
+
36
+ ```ts
37
+ await manager.ensure(workspaceId, {
38
+ homeRegion: "us-east",
39
+ secrets: [{
40
+ name: "NOTION_TOKEN",
41
+ value: notionToken, // never enters the sandbox in plaintext
42
+ hosts: ["api.notion.com"], // substituted/injected only for these hosts
43
+ header: "Authorization", // required for Vercel; optional for Daytona
44
+ }],
45
+ })
46
+ ```
47
+
48
+ Per-provider mechanism (from each provider's official docs):
49
+
50
+ | Driver | `secretBrokering` | Mechanism |
51
+ | --- | --- | --- |
52
+ | Daytona | `native` | `daytona.secret.create({name, value, hosts})`; the sandbox env holds an opaque `dtn_secret_…` placeholder and an egress proxy substitutes the real value only for allowlisted hosts ([docs](https://www.daytona.io/docs/en/secrets/)). |
53
+ | Vercel | `native` | Firewall header-transform (`updateNetworkPolicy`): the value is spliced onto egress to the allowlisted hosts as `header`; the sandbox makes an unauthenticated request, egress restricted to brokered hosts ([docs](https://vercel.com/docs/sandbox/concepts/firewall)). |
54
+ | Cloudflare | `proxy` | Implemented via the official [Worker-proxy pattern](https://developers.cloudflare.com/sandbox/guides/proxy-requests/): the driver sends the value to the sandbox Worker (API-token-gated, never into the container); the Worker stores it in KV, mints a short-lived per-sandbox JWT, and sets `CLAXEDO_EGRESS_PROXY_URL`/`CLAXEDO_EGRESS_TOKEN`/`CLAXEDO_EGRESS_HOSTS` in the container. The sandbox routes brokered-host egress through the Worker, which validates the JWT and injects the credential as `header` — the raw value never enters the sandbox. The reusable core is `@claxedo/sandbox-manager/drivers/cloudflare-egress` (mint/verify + `handleEgressRequest`). |
55
+ | Modal | `none` | Modal [Secrets](https://modal.com/docs/guide/secrets) are an encrypted by-reference store, but exposed as **readable env vars** inside the sandbox — they cannot satisfy the never-readable contract. |
56
+ | Docker, fetch | `none` | Plaintext env only. |
57
+
58
+ **Fail-closed contract.** A brokered secret is never silently downgraded to
59
+ readable env. `native` (transparent egress) and `proxy` (Cloudflare Worker
60
+ egress proxy) both keep the value out of the sandbox and provision normally;
61
+ only a `none` driver makes the manager refuse to provision (`status:
62
+ "unavailable", error: "secret_brokering_unsupported"`) rather than expose or
63
+ drop the credential. Brokered secret values are also never written to labels,
64
+ never logged, and never captured in a driver snapshot.
65
+
66
+ > Consumption note: `native` brokering is transparent (existing code reaches
67
+ > the real host and the platform injects). Cloudflare `proxy` is NOT
68
+ > transparent — the sandbox must route brokered-host requests through
69
+ > `CLAXEDO_EGRESS_PROXY_URL` (with the `CLAXEDO_EGRESS_TOKEN` bearer and the
70
+ > `x-claxedo-egress-target` header). Modal stays `none`; its secret store
71
+ > improves storage hygiene for *readable* credentials but does not hide them
72
+ > from the sandbox.
73
+
74
+ > SDK conformance: the Daytona secret API and Vercel network-policy transform
75
+ > shapes follow the providers' official docs and are validated structurally
76
+ > against the pinned SDK types; like all provider calls in this package they
77
+ > are exercised against mocks in unit tests, with live-SDK integration
78
+ > verified at deploy time.
@@ -0,0 +1,3 @@
1
+ export declare function shell(input: string): string;
2
+ export declare function file(...parts: string[]): string;
3
+ export declare function env(input: Record<string, string>): string;
@@ -0,0 +1,18 @@
1
+ import { createRequire as __createRequire } from 'module';var require = __createRequire(import.meta.url);
2
+
3
+ // src/command.ts
4
+ import path from "path";
5
+ function shell(input) {
6
+ return `'${input.replace(/'/g, `'"'"'`)}'`;
7
+ }
8
+ function file(...parts) {
9
+ return shell(path.posix.join(...parts));
10
+ }
11
+ function env(input) {
12
+ return Object.entries(input).map(([k, v]) => `export ${k}=${shell(v)}`).join(" && ");
13
+ }
14
+ export {
15
+ env,
16
+ file,
17
+ shell
18
+ };
@@ -0,0 +1 @@
1
+ export declare const DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
@@ -0,0 +1,7 @@
1
+ import { createRequire as __createRequire } from 'module';var require = __createRequire(import.meta.url);
2
+
3
+ // src/constants.ts
4
+ var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
5
+ export {
6
+ DEFAULT_WORKSPACE_RUNTIME_PORT
7
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Format CIDRs for Daytona SDK `networkAllowList` parameter.
3
+ * Daytona currently accepts at most 10 entries.
4
+ */
5
+ export declare function formatDaytonaAllowList(cidrs: string[]): string;
@@ -0,0 +1,9 @@
1
+ import { createRequire as __createRequire } from 'module';var require = __createRequire(import.meta.url);
2
+
3
+ // src/daytona-allow-list.ts
4
+ function formatDaytonaAllowList(cidrs) {
5
+ return cidrs.slice(0, 10).join(",");
6
+ }
7
+ export {
8
+ formatDaytonaAllowList
9
+ };
@@ -0,0 +1,3 @@
1
+ export declare const WORKSPACE_RUNTIME_PORT = 2593;
2
+ export declare const WORKSPACE_DIR = "/workspace";
3
+ export declare const RUNTIME_DIR = "/opt/workspace-runtime";
@@ -0,0 +1,14 @@
1
+ import { createRequire as __createRequire } from 'module';var require = __createRequire(import.meta.url);
2
+
3
+ // src/constants.ts
4
+ var DEFAULT_WORKSPACE_RUNTIME_PORT = 2593;
5
+
6
+ // src/defaults.ts
7
+ var WORKSPACE_RUNTIME_PORT = DEFAULT_WORKSPACE_RUNTIME_PORT;
8
+ var WORKSPACE_DIR = "/workspace";
9
+ var RUNTIME_DIR = "/opt/workspace-runtime";
10
+ export {
11
+ RUNTIME_DIR,
12
+ WORKSPACE_DIR,
13
+ WORKSPACE_RUNTIME_PORT
14
+ };
@@ -0,0 +1,62 @@
1
+ import type { SandboxDriverMetadata } from "./index";
2
+ export declare const sandboxDriverIds: readonly ["daytona", "modal", "vercel", "cloudflare", "docker"];
3
+ export type SandboxDriverID = (typeof sandboxDriverIds)[number];
4
+ export type SandboxDriverAuth = {
5
+ daytona?: {
6
+ api_key?: string;
7
+ };
8
+ modal?: {
9
+ token_id?: string;
10
+ token_secret?: string;
11
+ };
12
+ vercel?: {
13
+ access_token?: string;
14
+ team_id?: string;
15
+ project_id?: string;
16
+ };
17
+ cloudflare?: {
18
+ api_token?: string;
19
+ worker_url?: string;
20
+ };
21
+ docker?: {
22
+ image?: string;
23
+ };
24
+ };
25
+ export type SandboxDriverConfig = {
26
+ default_driver?: SandboxDriverID;
27
+ auth?: SandboxDriverAuth;
28
+ };
29
+ export type SandboxDriverCatalogEntry = {
30
+ id: SandboxDriverID;
31
+ label: string;
32
+ metadata: SandboxDriverMetadata;
33
+ credentialFields: Array<{
34
+ key: string;
35
+ label: string;
36
+ secret?: boolean;
37
+ }>;
38
+ };
39
+ export declare const sandboxDriverCatalog: Record<SandboxDriverID, SandboxDriverCatalogEntry>;
40
+ export declare function isSandboxDriverID(input: string | undefined): input is SandboxDriverID;
41
+ export declare function sandboxDriverId(input: string | undefined, cfg?: SandboxDriverConfig, env?: SandboxDriverEnv): "daytona" | "modal" | "vercel" | "cloudflare" | "docker" | undefined;
42
+ export declare function defaultSandboxDriverID(cfg?: SandboxDriverConfig, env?: SandboxDriverEnv): SandboxDriverID;
43
+ export declare function sandboxDriverAuth<T extends SandboxDriverID>(cfg: SandboxDriverConfig | undefined, id: T, env?: SandboxDriverEnv): SandboxDriverAuth[T] | undefined;
44
+ export declare function hasSandboxDriverAuth(cfg: SandboxDriverConfig | undefined, id: SandboxDriverID, env?: SandboxDriverEnv): boolean;
45
+ export declare function dockerSandboxDriverEnabled(env?: SandboxDriverEnv): boolean;
46
+ export declare function listSandboxDrivers(cfg?: SandboxDriverConfig, env?: SandboxDriverEnv, managedDriverIds?: ReadonlySet<string>): {
47
+ default_driver: "daytona" | "modal" | "vercel" | "cloudflare" | "docker";
48
+ drivers: {
49
+ id: "daytona" | "modal" | "vercel" | "cloudflare" | "docker";
50
+ label: string;
51
+ fields: {
52
+ key: string;
53
+ label: string;
54
+ secret?: boolean;
55
+ }[];
56
+ configured: boolean;
57
+ source: "none" | "config" | "managed";
58
+ default: boolean;
59
+ }[];
60
+ };
61
+ type SandboxDriverEnv = Record<string, string | undefined>;
62
+ export {};
@@ -0,0 +1,206 @@
1
+ import { createRequire as __createRequire } from 'module';var require = __createRequire(import.meta.url);
2
+
3
+ // src/runtime-version.ts
4
+ import { createRequire } from "module";
5
+ var require2 = createRequire(import.meta.url);
6
+ var versionCache;
7
+ function workspaceRuntimeVersion() {
8
+ if (versionCache) return versionCache;
9
+ const envVersion = process.env.WORKSPACE_RUNTIME_VERSION;
10
+ if (envVersion) {
11
+ versionCache = envVersion;
12
+ return versionCache;
13
+ }
14
+ try {
15
+ versionCache = require2("@claxedo/workspace-runtime/package.json").version;
16
+ return versionCache;
17
+ } catch {
18
+ }
19
+ versionCache = "0.0.0";
20
+ return versionCache;
21
+ }
22
+
23
+ // src/image-name.ts
24
+ var SNAPSHOT_SCHEMA_VERSION = 8;
25
+ var DEFAULT_SANDBOX_IMAGE_REPOSITORY = "ghcr.io/kyashrathore/claxedo-sandbox";
26
+ function snapshotVersion(value) {
27
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
28
+ }
29
+ function sandboxImageRepository(env = process.env) {
30
+ const repository = env.CLAXEDO_SANDBOX_IMAGE_REPOSITORY?.trim();
31
+ return repository ? repository : DEFAULT_SANDBOX_IMAGE_REPOSITORY;
32
+ }
33
+ function buildIdSuffix(buildId, env) {
34
+ const id = (buildId ?? env.CLAXEDO_SANDBOX_BUILD_ID)?.trim();
35
+ return id ? `-${snapshotVersion(id)}` : "";
36
+ }
37
+ function defaultSandboxImage(version = workspaceRuntimeVersion(), buildId, env = process.env) {
38
+ return `${sandboxImageRepository(env)}:workspace-runtime-${snapshotVersion(version)}${buildIdSuffix(buildId, env)}-v${SNAPSHOT_SCHEMA_VERSION}`;
39
+ }
40
+
41
+ // src/driver-catalog.ts
42
+ var sandboxDriverIds = ["daytona", "modal", "vercel", "cloudflare", "docker"];
43
+ var sandboxDriverCatalog = {
44
+ daytona: {
45
+ id: "daytona",
46
+ label: "Daytona",
47
+ credentialFields: [{ key: "api_key", label: "API Key", secret: true }],
48
+ metadata: {
49
+ driverRunsIn: ["worker", "node"],
50
+ hostStopBehavior: "suspends-host",
51
+ hostResumeBehavior: "same-host",
52
+ targetAccess: "relay",
53
+ secretBrokering: "native"
54
+ }
55
+ },
56
+ modal: {
57
+ id: "modal",
58
+ label: "Modal",
59
+ credentialFields: [
60
+ { key: "token_id", label: "Token ID", secret: true },
61
+ { key: "token_secret", label: "Token Secret", secret: true }
62
+ ],
63
+ metadata: {
64
+ driverRunsIn: ["node"],
65
+ hostStopBehavior: "terminates-host",
66
+ hostResumeBehavior: "replacement-host",
67
+ targetAccess: "relay",
68
+ secretBrokering: "none"
69
+ }
70
+ },
71
+ vercel: {
72
+ id: "vercel",
73
+ label: "Vercel",
74
+ credentialFields: [
75
+ { key: "access_token", label: "Access Token", secret: true },
76
+ { key: "team_id", label: "Team ID" },
77
+ { key: "project_id", label: "Project ID" }
78
+ ],
79
+ metadata: {
80
+ driverRunsIn: ["node"],
81
+ hostStopBehavior: "terminates-host",
82
+ hostResumeBehavior: "replacement-host",
83
+ targetAccess: "relay",
84
+ secretBrokering: "native"
85
+ }
86
+ },
87
+ cloudflare: {
88
+ id: "cloudflare",
89
+ label: "Cloudflare",
90
+ credentialFields: [
91
+ { key: "api_token", label: "API Token", secret: true },
92
+ { key: "worker_url", label: "Worker URL" }
93
+ ],
94
+ metadata: {
95
+ driverRunsIn: ["worker"],
96
+ hostStopBehavior: "not-supported",
97
+ hostResumeBehavior: "same-host",
98
+ targetAccess: "relay",
99
+ secretBrokering: "proxy"
100
+ }
101
+ },
102
+ docker: {
103
+ id: "docker",
104
+ label: "Docker",
105
+ credentialFields: [{ key: "image", label: "Image" }],
106
+ metadata: {
107
+ driverRunsIn: ["local"],
108
+ hostStopBehavior: "terminates-host",
109
+ hostResumeBehavior: "same-host",
110
+ targetAccess: "loopback",
111
+ secretBrokering: "none"
112
+ }
113
+ }
114
+ };
115
+ function isSandboxDriverID(input) {
116
+ return !!input && sandboxDriverIds.includes(input);
117
+ }
118
+ function sandboxDriverId(input, cfg, env = process.env) {
119
+ if (!isSandboxDriverID(input)) return;
120
+ if (input === "docker" && !sandboxDriverAuth(cfg, input, env)) return;
121
+ return input;
122
+ }
123
+ function defaultSandboxDriverID(cfg, env = process.env) {
124
+ return sandboxDriverId(cfg?.default_driver, cfg, env) ?? (dockerSandboxDefault(env) && sandboxDriverAuth(cfg, "docker", env) ? "docker" : "daytona");
125
+ }
126
+ function sandboxDriverAuth(cfg, id, env = process.env) {
127
+ if (id === "daytona") return authDaytona(cfg);
128
+ if (id === "modal") return authModal(cfg, env);
129
+ if (id === "vercel") return authVercel(cfg, env);
130
+ if (id === "cloudflare") return authCloudflare(cfg, env);
131
+ return authDocker(cfg, env);
132
+ }
133
+ function hasSandboxDriverAuth(cfg, id, env = process.env) {
134
+ return !!sandboxDriverAuth(cfg, id, env);
135
+ }
136
+ function dockerSandboxDriverEnabled(env = process.env) {
137
+ return dockerSandboxEnabled(env);
138
+ }
139
+ function listSandboxDrivers(cfg, env = process.env, managedDriverIds = /* @__PURE__ */ new Set()) {
140
+ const def = defaultSandboxDriverID(cfg, env);
141
+ return {
142
+ default_driver: def,
143
+ drivers: sandboxDriverIds.filter((id) => id !== "docker" || !!sandboxDriverAuth(cfg, "docker", env)).map((id) => {
144
+ const configuredFromConfigOrEnv = !!sandboxDriverAuth(cfg, id, env);
145
+ const configured = id === "docker" ? configuredFromConfigOrEnv : configuredFromConfigOrEnv || managedDriverIds.has(id);
146
+ return {
147
+ id,
148
+ label: sandboxDriverCatalog[id].label,
149
+ fields: sandboxDriverCatalog[id].credentialFields,
150
+ configured,
151
+ source: configuredFromConfigOrEnv ? "config" : configured ? "managed" : "none",
152
+ default: id === def
153
+ };
154
+ })
155
+ };
156
+ }
157
+ function clean(input) {
158
+ const txt = input?.trim();
159
+ return txt ? txt : void 0;
160
+ }
161
+ function enabled(input) {
162
+ return ["1", "true", "yes", "on"].includes(input?.trim().toLowerCase() ?? "");
163
+ }
164
+ function dockerSandboxEnabled(env) {
165
+ return enabled(env.CLAXEDO_ENABLE_DOCKER_SANDBOX) || enabled(env.CLAXEDO_DEV_DOCKER_SANDBOX);
166
+ }
167
+ function dockerSandboxDefault(env) {
168
+ return enabled(env.CLAXEDO_DOCKER_SANDBOX_DEFAULT);
169
+ }
170
+ function authDaytona(cfg) {
171
+ const api_key = clean(cfg?.auth?.daytona?.api_key);
172
+ return api_key ? { api_key } : void 0;
173
+ }
174
+ function authModal(cfg, env) {
175
+ const token_id = clean(cfg?.auth?.modal?.token_id) ?? clean(env.MODAL_TOKEN_ID);
176
+ const token_secret = clean(cfg?.auth?.modal?.token_secret) ?? clean(env.MODAL_TOKEN_SECRET);
177
+ return token_id && token_secret ? { token_id, token_secret } : void 0;
178
+ }
179
+ function authVercel(cfg, env) {
180
+ const access_token = clean(cfg?.auth?.vercel?.access_token) ?? clean(env.VERCEL_TOKEN);
181
+ const team_id = clean(cfg?.auth?.vercel?.team_id) ?? clean(env.VERCEL_TEAM_ID);
182
+ const project_id = clean(cfg?.auth?.vercel?.project_id) ?? clean(env.VERCEL_PROJECT_ID);
183
+ return access_token && team_id && project_id ? { access_token, team_id, project_id } : void 0;
184
+ }
185
+ function authCloudflare(cfg, env) {
186
+ const api_token = clean(cfg?.auth?.cloudflare?.api_token) ?? clean(env.CLOUDFLARE_API_TOKEN);
187
+ const worker_url = clean(cfg?.auth?.cloudflare?.worker_url) ?? clean(env.CLOUDFLARE_SANDBOX_WORKER_URL);
188
+ return api_token && worker_url ? { api_token, worker_url } : void 0;
189
+ }
190
+ function authDocker(cfg, env) {
191
+ if (!dockerSandboxEnabled(env)) return;
192
+ return {
193
+ image: clean(cfg?.auth?.docker?.image) ?? clean(env.CLAXEDO_DOCKER_SANDBOX_IMAGE) ?? clean(env.CLAXEDO_SANDBOX_IMAGE) ?? defaultSandboxImage(workspaceRuntimeVersion(), void 0, env)
194
+ };
195
+ }
196
+ export {
197
+ defaultSandboxDriverID,
198
+ dockerSandboxDriverEnabled,
199
+ hasSandboxDriverAuth,
200
+ isSandboxDriverID,
201
+ listSandboxDrivers,
202
+ sandboxDriverAuth,
203
+ sandboxDriverCatalog,
204
+ sandboxDriverId,
205
+ sandboxDriverIds
206
+ };
@@ -0,0 +1,40 @@
1
+ export type EgressTokenClaims = {
2
+ /** Sandbox the token is bound to. */
3
+ sub: string;
4
+ /** Hosts the sandbox may reach through the broker. */
5
+ hosts: string[];
6
+ /** Expiry (epoch seconds). */
7
+ exp: number;
8
+ };
9
+ /** Mint a short-lived HS256 token binding a sandbox to its brokered hosts. Carries NO secret values. */
10
+ export declare function mintEgressToken(input: {
11
+ sandboxId: string;
12
+ hosts: string[];
13
+ signingSecret: string;
14
+ ttlSeconds?: number;
15
+ now?: () => number;
16
+ }): Promise<string>;
17
+ /** Verify a token and return its claims, or null if invalid/expired. */
18
+ export declare function verifyEgressToken(token: string, signingSecret: string, now?: () => number): Promise<EgressTokenClaims | null>;
19
+ /** How the sandbox names its egress target: header carries the absolute upstream URL. */
20
+ export declare const EGRESS_TARGET_HEADER = "x-claxedo-egress-target";
21
+ /** Resolves the brokered credential for a (sandbox, host) pair — Worker-side only, never the sandbox. */
22
+ export type EgressSecretResolver = (sandboxId: string, host: string) => Promise<{
23
+ header: string;
24
+ value: string;
25
+ } | undefined> | {
26
+ header: string;
27
+ value: string;
28
+ } | undefined;
29
+ /**
30
+ * Handle one egress-proxy request from a sandbox: validate the JWT, confirm
31
+ * the target host is allowed, inject the brokered credential as its header,
32
+ * and forward. The credential is read from `resolveSecret` (Worker store) and
33
+ * never round-trips through the sandbox.
34
+ */
35
+ export declare function handleEgressRequest(request: Request, options: {
36
+ signingSecret: string;
37
+ resolveSecret: EgressSecretResolver;
38
+ fetchImpl?: typeof fetch;
39
+ now?: () => number;
40
+ }): Promise<Response>;
@@ -0,0 +1,115 @@
1
+ import { createRequire as __createRequire } from 'module';var require = __createRequire(import.meta.url);
2
+
3
+ // src/drivers/cloudflare-egress.ts
4
+ function b64urlEncode(bytes) {
5
+ let binary = "";
6
+ for (const byte of bytes) binary += String.fromCharCode(byte);
7
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
8
+ }
9
+ function b64urlDecode(input) {
10
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - input.length % 4) % 4);
11
+ const binary = atob(padded);
12
+ const bytes = new Uint8Array(binary.length);
13
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
14
+ return bytes;
15
+ }
16
+ function encodeJson(value) {
17
+ return b64urlEncode(new TextEncoder().encode(JSON.stringify(value)));
18
+ }
19
+ async function hmacKey(secret) {
20
+ return crypto.subtle.importKey(
21
+ "raw",
22
+ new TextEncoder().encode(secret),
23
+ { name: "HMAC", hash: "SHA-256" },
24
+ false,
25
+ ["sign", "verify"]
26
+ );
27
+ }
28
+ function timingSafeEqual(a, b) {
29
+ if (a.length !== b.length) return false;
30
+ let diff = 0;
31
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
32
+ return diff === 0;
33
+ }
34
+ async function mintEgressToken(input) {
35
+ const now = input.now ?? Date.now;
36
+ const header = encodeJson({ alg: "HS256", typ: "JWT" });
37
+ const payload = encodeJson({
38
+ sub: input.sandboxId,
39
+ hosts: input.hosts,
40
+ exp: Math.floor(now() / 1e3) + (input.ttlSeconds ?? 15 * 60)
41
+ });
42
+ const key = await hmacKey(input.signingSecret);
43
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${header}.${payload}`)));
44
+ return `${header}.${payload}.${b64urlEncode(sig)}`;
45
+ }
46
+ async function verifyEgressToken(token, signingSecret, now = Date.now) {
47
+ const parts = token.split(".");
48
+ if (parts.length !== 3) return null;
49
+ const [header, payload, sig] = parts;
50
+ const key = await hmacKey(signingSecret);
51
+ const expected = b64urlEncode(
52
+ new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${header}.${payload}`)))
53
+ );
54
+ if (!timingSafeEqual(sig, expected)) return null;
55
+ let claims;
56
+ try {
57
+ claims = JSON.parse(new TextDecoder().decode(b64urlDecode(payload)));
58
+ } catch {
59
+ return null;
60
+ }
61
+ if (typeof claims.sub !== "string" || !Array.isArray(claims.hosts) || typeof claims.exp !== "number") return null;
62
+ if (claims.exp <= Math.floor(now() / 1e3)) return null;
63
+ return claims;
64
+ }
65
+ var EGRESS_TARGET_HEADER = "x-claxedo-egress-target";
66
+ async function handleEgressRequest(request, options) {
67
+ const fetchImpl = options.fetchImpl ?? fetch;
68
+ const auth = request.headers.get("authorization");
69
+ const token = auth?.toLowerCase().startsWith("bearer ") ? auth.slice(7) : void 0;
70
+ if (!token) return egressError(401, "missing egress token");
71
+ const claims = await verifyEgressToken(token, options.signingSecret, options.now);
72
+ if (!claims) return egressError(401, "invalid or expired egress token");
73
+ const targetRaw = request.headers.get(EGRESS_TARGET_HEADER);
74
+ if (!targetRaw) return egressError(400, "missing egress target");
75
+ let target;
76
+ try {
77
+ target = new URL(targetRaw);
78
+ } catch {
79
+ return egressError(400, "malformed egress target");
80
+ }
81
+ if (target.protocol !== "https:") return egressError(400, "egress target must be https");
82
+ if (!claims.hosts.includes(target.host)) return egressError(403, "host not permitted for this sandbox");
83
+ const secret = await options.resolveSecret(claims.sub, target.host);
84
+ if (!secret) return egressError(403, "no brokered credential for host");
85
+ const headers = new Headers(request.headers);
86
+ headers.delete("authorization");
87
+ headers.delete(EGRESS_TARGET_HEADER);
88
+ headers.set(secret.header, secret.value);
89
+ headers.set("host", target.host);
90
+ try {
91
+ const upstream = await fetchImpl(target.toString(), {
92
+ method: request.method,
93
+ headers,
94
+ body: request.method === "GET" || request.method === "HEAD" ? void 0 : request.body,
95
+ redirect: "manual"
96
+ });
97
+ const outHeaders = new Headers(upstream.headers);
98
+ outHeaders.delete(secret.header);
99
+ return new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
100
+ } catch {
101
+ return egressError(502, "upstream request failed");
102
+ }
103
+ }
104
+ function egressError(status, message) {
105
+ return new Response(JSON.stringify({ error: message }), {
106
+ status,
107
+ headers: { "content-type": "application/json" }
108
+ });
109
+ }
110
+ export {
111
+ EGRESS_TARGET_HEADER,
112
+ handleEgressRequest,
113
+ mintEgressToken,
114
+ verifyEgressToken
115
+ };
@@ -0,0 +1,37 @@
1
+ import type { SandboxDriver, SandboxDriverEnsureInput } from "..";
2
+ export type CloudflareSandboxDriverOptions = {
3
+ /** Base URL of the deployed Cloudflare Sandbox Worker (e.g. https://sbx.example.com). */
4
+ workerUrl: string;
5
+ /** Bearer token the Worker validates (its `API_TOKEN` secret). */
6
+ apiToken: string;
7
+ /** Port the workspace-runtime listens on inside the sandbox (exposed publicly). */
8
+ runtimePort?: number;
9
+ /** Command that launches the workspace-runtime inside the image. */
10
+ runtimeCommand?: string;
11
+ /** Workspace directory inside the sandbox. */
12
+ workspaceDir?: string;
13
+ /**
14
+ * Static control-plane config injected into every sandbox so the runtime can
15
+ * verify relay-proxied requests. Never carries per-workspace secrets — those
16
+ * (if any) belong to the future egress broker.
17
+ * relayJwksUrl → WORKSPACE_RUNTIME_RELAY_JWKS_URL (relay public key for RAT verify)
18
+ * relayVerifyPem → WORKSPACE_RUNTIME_RELAY_HOST_VERIFY_PEM (fallback to JWKS)
19
+ * managementJwksUrl → WORKSPACE_RUNTIME_MANAGEMENT_JWKS_URL (config apply)
20
+ */
21
+ controlEnv?: {
22
+ relayJwksUrl?: string;
23
+ relayVerifyPem?: string;
24
+ managementJwksUrl?: string;
25
+ };
26
+ /** Default runner injected as WORKSPACE_RUNTIME_RUNNER, e.g. "opencode" | "claude-acp" | "codex-acp". */
27
+ runner?: string;
28
+ /** Dynamic runtime env that needs the sandbox id or current lease. */
29
+ env?: (input: SandboxDriverEnsureInput, sandbox: {
30
+ id: string;
31
+ }) => Record<string, string> | Promise<Record<string, string>>;
32
+ /** Injected for tests. */
33
+ fetch?: typeof fetch;
34
+ /** ensure-runtime/HTTP timeout. */
35
+ timeoutMs?: number;
36
+ };
37
+ export declare function createCloudflareSandboxDriver(options: CloudflareSandboxDriverOptions): SandboxDriver;