@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.
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.mjs +18 -0
- package/dist/constants.d.ts +1 -0
- package/dist/constants.mjs +7 -0
- package/dist/daytona-allow-list.d.ts +5 -0
- package/dist/daytona-allow-list.mjs +9 -0
- package/dist/defaults.d.ts +3 -0
- package/dist/defaults.mjs +14 -0
- package/dist/driver-catalog.d.ts +62 -0
- package/dist/driver-catalog.mjs +206 -0
- package/dist/drivers/cloudflare-egress.d.ts +40 -0
- package/dist/drivers/cloudflare-egress.mjs +115 -0
- package/dist/drivers/cloudflare.d.ts +37 -0
- package/dist/drivers/cloudflare.mjs +151 -0
- package/dist/drivers/daytona.d.ts +94 -0
- package/dist/drivers/daytona.mjs +235 -0
- package/dist/drivers/docker.d.ts +36 -0
- package/dist/drivers/docker.mjs +329 -0
- package/dist/drivers/fetch-bridge.d.ts +10 -0
- package/dist/drivers/fetch-bridge.mjs +104 -0
- package/dist/drivers/modal.d.ts +68 -0
- package/dist/drivers/modal.mjs +227 -0
- package/dist/drivers/vercel.d.ts +109 -0
- package/dist/drivers/vercel.mjs +297 -0
- package/dist/image-name.d.ts +7 -0
- package/dist/image.d.ts +8 -0
- package/dist/image.mjs +124 -0
- package/dist/index.d.ts +316 -0
- package/dist/index.mjs +330 -0
- package/dist/lease-policy.d.ts +49 -0
- package/dist/lease-policy.mjs +126 -0
- package/dist/lease-types.d.ts +89 -0
- package/dist/lease-types.mjs +1 -0
- package/dist/runtime-env.d.ts +24 -0
- package/dist/runtime-env.mjs +35 -0
- package/dist/runtime-version.d.ts +1 -0
- package/dist/runtime-version.mjs +24 -0
- package/dist/service-url-exposure.d.ts +10 -0
- package/dist/service-url-exposure.mjs +46 -0
- package/dist/stores/memory.d.ts +6 -0
- package/dist/stores/memory.mjs +117 -0
- 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.
|
package/dist/command.mjs
ADDED
|
@@ -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,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,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;
|