@agentstep/agent-sdk 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/package.json +45 -0
- package/src/auth/middleware.ts +38 -0
- package/src/backends/claude/args.ts +88 -0
- package/src/backends/claude/index.ts +193 -0
- package/src/backends/claude/permission-hook.ts +152 -0
- package/src/backends/claude/tool-bridge.ts +211 -0
- package/src/backends/claude/translator.ts +209 -0
- package/src/backends/claude/wrapper-script.ts +45 -0
- package/src/backends/codex/args.ts +69 -0
- package/src/backends/codex/auth.ts +35 -0
- package/src/backends/codex/index.ts +57 -0
- package/src/backends/codex/setup.ts +37 -0
- package/src/backends/codex/translator.ts +223 -0
- package/src/backends/codex/wrapper-script.ts +26 -0
- package/src/backends/factory/args.ts +45 -0
- package/src/backends/factory/auth.ts +30 -0
- package/src/backends/factory/index.ts +56 -0
- package/src/backends/factory/setup.ts +34 -0
- package/src/backends/factory/translator.ts +139 -0
- package/src/backends/factory/wrapper-script.ts +33 -0
- package/src/backends/gemini/args.ts +44 -0
- package/src/backends/gemini/auth.ts +30 -0
- package/src/backends/gemini/index.ts +53 -0
- package/src/backends/gemini/setup.ts +34 -0
- package/src/backends/gemini/translator.ts +139 -0
- package/src/backends/gemini/wrapper-script.ts +26 -0
- package/src/backends/opencode/args.ts +53 -0
- package/src/backends/opencode/auth.ts +53 -0
- package/src/backends/opencode/index.ts +70 -0
- package/src/backends/opencode/mcp.ts +67 -0
- package/src/backends/opencode/setup.ts +54 -0
- package/src/backends/opencode/translator.ts +168 -0
- package/src/backends/opencode/wrapper-script.ts +46 -0
- package/src/backends/registry.ts +38 -0
- package/src/backends/shared/ndjson.ts +29 -0
- package/src/backends/shared/translator-types.ts +69 -0
- package/src/backends/shared/wrap-prompt.ts +17 -0
- package/src/backends/types.ts +85 -0
- package/src/config/index.ts +95 -0
- package/src/db/agents.ts +185 -0
- package/src/db/api_keys.ts +78 -0
- package/src/db/batch.ts +142 -0
- package/src/db/client.ts +81 -0
- package/src/db/environments.ts +127 -0
- package/src/db/events.ts +208 -0
- package/src/db/memory.ts +143 -0
- package/src/db/migrations.ts +295 -0
- package/src/db/proxy.ts +37 -0
- package/src/db/sessions.ts +295 -0
- package/src/db/vaults.ts +110 -0
- package/src/errors.ts +53 -0
- package/src/handlers/agents.ts +194 -0
- package/src/handlers/batch.ts +41 -0
- package/src/handlers/docs.ts +87 -0
- package/src/handlers/environments.ts +154 -0
- package/src/handlers/events.ts +234 -0
- package/src/handlers/index.ts +12 -0
- package/src/handlers/memory.ts +141 -0
- package/src/handlers/openapi.ts +14 -0
- package/src/handlers/sessions.ts +223 -0
- package/src/handlers/stream.ts +76 -0
- package/src/handlers/threads.ts +26 -0
- package/src/handlers/ui/app.js +984 -0
- package/src/handlers/ui/index.html +112 -0
- package/src/handlers/ui/style.css +164 -0
- package/src/handlers/ui.ts +1281 -0
- package/src/handlers/vaults.ts +99 -0
- package/src/http.ts +35 -0
- package/src/index.ts +104 -0
- package/src/init.ts +227 -0
- package/src/openapi/registry.ts +8 -0
- package/src/openapi/schemas.ts +625 -0
- package/src/openapi/spec.ts +691 -0
- package/src/providers/apple.ts +220 -0
- package/src/providers/daytona.ts +217 -0
- package/src/providers/docker.ts +264 -0
- package/src/providers/e2b.ts +203 -0
- package/src/providers/fly.ts +276 -0
- package/src/providers/modal.ts +222 -0
- package/src/providers/podman.ts +206 -0
- package/src/providers/registry.ts +28 -0
- package/src/providers/shared.ts +11 -0
- package/src/providers/sprites.ts +55 -0
- package/src/providers/types.ts +73 -0
- package/src/providers/vercel.ts +208 -0
- package/src/proxy/forward.ts +111 -0
- package/src/queue/index.ts +111 -0
- package/src/sessions/actor.ts +53 -0
- package/src/sessions/bus.ts +155 -0
- package/src/sessions/driver.ts +818 -0
- package/src/sessions/grader.ts +120 -0
- package/src/sessions/interrupt.ts +14 -0
- package/src/sessions/sweeper.ts +136 -0
- package/src/sessions/threads.ts +126 -0
- package/src/sessions/tools.ts +50 -0
- package/src/shutdown.ts +78 -0
- package/src/sprite/client.ts +294 -0
- package/src/sprite/exec.ts +161 -0
- package/src/sprite/lifecycle.ts +339 -0
- package/src/sprite/pool.ts +65 -0
- package/src/sprite/setup.ts +159 -0
- package/src/state.ts +61 -0
- package/src/types.ts +339 -0
- package/src/util/clock.ts +7 -0
- package/src/util/ids.ts +11 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2B sandbox provider.
|
|
3
|
+
*
|
|
4
|
+
* Uses the `@e2b/sdk` npm package to create and manage cloud sandboxes.
|
|
5
|
+
* The SDK is optional — if not installed, a clear error is thrown on first use.
|
|
6
|
+
*
|
|
7
|
+
* Container lifecycle:
|
|
8
|
+
* create → Sandbox.create({ template })
|
|
9
|
+
* exec → sandbox.commands.run(cmd)
|
|
10
|
+
* delete → sandbox.kill()
|
|
11
|
+
*
|
|
12
|
+
* Env vars: E2B_API_KEY, E2B_TEMPLATE (default: "base")
|
|
13
|
+
*/
|
|
14
|
+
import type { ContainerProvider, ExecOptions, ExecSession } from "./types";
|
|
15
|
+
import { shellEscape } from "./shared";
|
|
16
|
+
|
|
17
|
+
// Lazy-loaded SDK types. The actual import happens at runtime via loadSdk().
|
|
18
|
+
type E2BSandbox = {
|
|
19
|
+
commands: {
|
|
20
|
+
run(
|
|
21
|
+
cmd: string,
|
|
22
|
+
opts?: {
|
|
23
|
+
onStdout?: (data: string) => void;
|
|
24
|
+
onStderr?: (data: string) => void;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
},
|
|
27
|
+
): Promise<{ exitCode: number; stdout: string; stderr: string }>;
|
|
28
|
+
};
|
|
29
|
+
kill(): Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type E2BSandboxClass = {
|
|
33
|
+
create(opts: {
|
|
34
|
+
template?: string;
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
}): Promise<E2BSandbox>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let SandboxClass: E2BSandboxClass | null = null;
|
|
40
|
+
|
|
41
|
+
async function loadSdk(): Promise<E2BSandboxClass> {
|
|
42
|
+
if (SandboxClass) return SandboxClass;
|
|
43
|
+
try {
|
|
44
|
+
// @ts-ignore — optional dependency, may not be installed
|
|
45
|
+
const mod = await import("@e2b/sdk");
|
|
46
|
+
SandboxClass = (mod.Sandbox ?? mod.default?.Sandbox ?? mod.default) as E2BSandboxClass;
|
|
47
|
+
if (!SandboxClass?.create) {
|
|
48
|
+
throw new Error("Sandbox.create not found in @e2b/sdk exports");
|
|
49
|
+
}
|
|
50
|
+
return SandboxClass;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (
|
|
53
|
+
err instanceof Error &&
|
|
54
|
+
(err.message.includes("Cannot find module") ||
|
|
55
|
+
err.message.includes("MODULE_NOT_FOUND") ||
|
|
56
|
+
err.message.includes("ERR_MODULE_NOT_FOUND"))
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
"E2B provider requires the @e2b/sdk package. Install it with: npm install @e2b/sdk",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
throw err;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// HMR-safe sandbox instance map
|
|
67
|
+
type GlobalWithE2B = typeof globalThis & { __caE2BSandboxes?: Map<string, E2BSandbox> };
|
|
68
|
+
const g = globalThis as GlobalWithE2B;
|
|
69
|
+
if (!g.__caE2BSandboxes) g.__caE2BSandboxes = new Map();
|
|
70
|
+
const sandboxes = g.__caE2BSandboxes;
|
|
71
|
+
|
|
72
|
+
const DEFAULT_TEMPLATE = process.env.E2B_TEMPLATE ?? "base";
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Provider
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
export const e2bProvider: ContainerProvider = {
|
|
79
|
+
name: "e2b",
|
|
80
|
+
stripControlChars: false,
|
|
81
|
+
|
|
82
|
+
async checkAvailability() {
|
|
83
|
+
try { await loadSdk(); } catch {
|
|
84
|
+
return { available: false, message: "E2B requires the @e2b/sdk package. Install with: npm install @e2b/sdk" };
|
|
85
|
+
}
|
|
86
|
+
if (!process.env.E2B_API_KEY) {
|
|
87
|
+
return { available: false, message: "E2B requires E2B_API_KEY to be set" };
|
|
88
|
+
}
|
|
89
|
+
return { available: true };
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async create({ name }) {
|
|
93
|
+
const Sandbox = await loadSdk();
|
|
94
|
+
const apiKey = process.env.E2B_API_KEY;
|
|
95
|
+
if (!apiKey) throw new Error("E2B_API_KEY environment variable is required");
|
|
96
|
+
|
|
97
|
+
const sandbox = await Sandbox.create({
|
|
98
|
+
template: DEFAULT_TEMPLATE,
|
|
99
|
+
apiKey,
|
|
100
|
+
});
|
|
101
|
+
sandboxes.set(name, sandbox);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async delete(name) {
|
|
105
|
+
const sandbox = sandboxes.get(name);
|
|
106
|
+
if (!sandbox) return;
|
|
107
|
+
try {
|
|
108
|
+
await sandbox.kill();
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort — sandbox may already be gone
|
|
111
|
+
}
|
|
112
|
+
sandboxes.delete(name);
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async list(opts) {
|
|
116
|
+
// NOTE: @e2b/sdk does not expose a Sandbox.list() method to enumerate
|
|
117
|
+
// running sandboxes server-side. After a server restart the in-memory map
|
|
118
|
+
// is empty, so previously-created sandboxes will not appear here. This is
|
|
119
|
+
// an accepted limitation — callers must handle missing containers
|
|
120
|
+
// gracefully (e.g. re-create on demand).
|
|
121
|
+
const prefix = opts?.prefix ?? "ca-sess-";
|
|
122
|
+
return Array.from(sandboxes.keys())
|
|
123
|
+
.filter((n) => n.startsWith(prefix))
|
|
124
|
+
.map((name) => ({ name }));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async exec(name, argv, opts) {
|
|
128
|
+
const sandbox = sandboxes.get(name);
|
|
129
|
+
if (!sandbox) throw new Error(`E2B sandbox not found: ${name}`);
|
|
130
|
+
|
|
131
|
+
const cmd = argv.map((a) => shellEscape(a)).join(" ");
|
|
132
|
+
// If stdin is provided, pipe it via shell: echo '...' | cmd
|
|
133
|
+
const fullCmd = opts?.stdin
|
|
134
|
+
? `echo ${shellEscape(opts.stdin)} | ${cmd}`
|
|
135
|
+
: cmd;
|
|
136
|
+
|
|
137
|
+
const result = await sandbox.commands.run(fullCmd, {
|
|
138
|
+
timeout: opts?.timeoutMs,
|
|
139
|
+
});
|
|
140
|
+
return {
|
|
141
|
+
stdout: result.stdout,
|
|
142
|
+
stderr: result.stderr,
|
|
143
|
+
exit_code: result.exitCode,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async startExec(name, opts) {
|
|
148
|
+
const sandbox = sandboxes.get(name);
|
|
149
|
+
if (!sandbox) throw new Error(`E2B sandbox not found: ${name}`);
|
|
150
|
+
|
|
151
|
+
const cmd = opts.argv.map((a) => shellEscape(a)).join(" ");
|
|
152
|
+
const fullCmd = opts.stdin
|
|
153
|
+
? `echo ${shellEscape(opts.stdin)} | ${cmd}`
|
|
154
|
+
: cmd;
|
|
155
|
+
|
|
156
|
+
const encoder = new TextEncoder();
|
|
157
|
+
let streamController: ReadableStreamDefaultController<Uint8Array>;
|
|
158
|
+
|
|
159
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
160
|
+
start(controller) {
|
|
161
|
+
streamController = controller;
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const resultPromise = sandbox.commands.run(fullCmd, {
|
|
166
|
+
onStdout: (data: string) => {
|
|
167
|
+
try {
|
|
168
|
+
streamController.enqueue(encoder.encode(data));
|
|
169
|
+
} catch {
|
|
170
|
+
// Stream may be closed
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
onStderr: (_data: string) => {
|
|
174
|
+
// stderr is not streamed to stdout
|
|
175
|
+
},
|
|
176
|
+
timeout: opts.timeoutMs,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const exit = resultPromise.then((result) => {
|
|
180
|
+
try {
|
|
181
|
+
streamController.close();
|
|
182
|
+
} catch {
|
|
183
|
+
// Already closed
|
|
184
|
+
}
|
|
185
|
+
return { code: result.exitCode };
|
|
186
|
+
}).catch((err) => {
|
|
187
|
+
try {
|
|
188
|
+
streamController.error(err);
|
|
189
|
+
} catch {
|
|
190
|
+
// Already closed
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
stdout,
|
|
197
|
+
exit,
|
|
198
|
+
async kill() {
|
|
199
|
+
// E2B doesn't support killing individual commands — best-effort no-op
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fly.io Machines provider using REST API.
|
|
3
|
+
*
|
|
4
|
+
* Uses the Fly Machines API to create and manage containers on Fly.io's
|
|
5
|
+
* infrastructure. No SDK required — communicates via plain HTTP fetch.
|
|
6
|
+
*
|
|
7
|
+
* Container lifecycle:
|
|
8
|
+
* create → POST /v1/apps/{app}/machines
|
|
9
|
+
* exec → POST /v1/apps/{app}/machines/{id}/exec
|
|
10
|
+
* delete → DELETE /v1/apps/{app}/machines/{id}?force=true
|
|
11
|
+
*
|
|
12
|
+
* Fly Machines are identified by machine IDs (not names), so we maintain
|
|
13
|
+
* a name→id map to conform to the ContainerProvider interface.
|
|
14
|
+
*
|
|
15
|
+
* Env vars: FLY_API_TOKEN, FLY_APP_NAME, FLY_IMAGE (default: "node:22")
|
|
16
|
+
*/
|
|
17
|
+
import type { ContainerProvider, ExecOptions, ExecSession } from "./types";
|
|
18
|
+
import { shellEscape } from "./shared";
|
|
19
|
+
|
|
20
|
+
const BASE_URL = "https://api.machines.dev";
|
|
21
|
+
|
|
22
|
+
function getToken(): string {
|
|
23
|
+
const token = process.env.FLY_API_TOKEN;
|
|
24
|
+
if (!token) throw new Error("FLY_API_TOKEN environment variable is required");
|
|
25
|
+
return token;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getAppName(): string {
|
|
29
|
+
const app = process.env.FLY_APP_NAME;
|
|
30
|
+
if (!app) throw new Error("FLY_APP_NAME environment variable is required");
|
|
31
|
+
return app;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function headers(): Record<string, string> {
|
|
35
|
+
return {
|
|
36
|
+
Authorization: `Bearer ${getToken()}`,
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// HMR-safe name→machineId map
|
|
42
|
+
type GlobalWithFly = typeof globalThis & { __caFlyMachines?: Map<string, string> };
|
|
43
|
+
const g = globalThis as GlobalWithFly;
|
|
44
|
+
if (!g.__caFlyMachines) g.__caFlyMachines = new Map();
|
|
45
|
+
const machines = g.__caFlyMachines;
|
|
46
|
+
|
|
47
|
+
const DEFAULT_IMAGE = process.env.FLY_IMAGE ?? "node:22";
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Provider
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
export const flyProvider: ContainerProvider = {
|
|
54
|
+
name: "fly",
|
|
55
|
+
stripControlChars: false,
|
|
56
|
+
|
|
57
|
+
async checkAvailability() {
|
|
58
|
+
if (!process.env.FLY_API_TOKEN) {
|
|
59
|
+
return { available: false, message: "Fly.io requires FLY_API_TOKEN to be set" };
|
|
60
|
+
}
|
|
61
|
+
if (!process.env.FLY_APP_NAME) {
|
|
62
|
+
return { available: false, message: "Fly.io requires FLY_APP_NAME to be set" };
|
|
63
|
+
}
|
|
64
|
+
return { available: true };
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
async create({ name }) {
|
|
68
|
+
const app = getAppName();
|
|
69
|
+
const res = await fetch(`${BASE_URL}/v1/apps/${app}/machines`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: headers(),
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
name,
|
|
74
|
+
config: {
|
|
75
|
+
image: DEFAULT_IMAGE,
|
|
76
|
+
auto_destroy: true,
|
|
77
|
+
processes: [
|
|
78
|
+
{ cmd: ["sleep", "infinity"] },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
const body = await res.text().catch(() => "");
|
|
86
|
+
throw new Error(`Fly create failed (${res.status}): ${body}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const data = (await res.json()) as { id: string };
|
|
90
|
+
machines.set(name, data.id);
|
|
91
|
+
|
|
92
|
+
// Wait for the machine to start
|
|
93
|
+
const machineId = data.id;
|
|
94
|
+
const startRes = await fetch(
|
|
95
|
+
`${BASE_URL}/v1/apps/${app}/machines/${machineId}/wait?state=started&timeout=60`,
|
|
96
|
+
{ headers: headers() },
|
|
97
|
+
);
|
|
98
|
+
if (!startRes.ok) {
|
|
99
|
+
const body = await startRes.text().catch(() => "");
|
|
100
|
+
console.warn(`Fly machine wait-for-start warning (${startRes.status}): ${body}`);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async delete(name) {
|
|
105
|
+
const machineId = machines.get(name);
|
|
106
|
+
if (!machineId) return;
|
|
107
|
+
const app = getAppName();
|
|
108
|
+
try {
|
|
109
|
+
// Stop first, then destroy
|
|
110
|
+
await fetch(`${BASE_URL}/v1/apps/${app}/machines/${machineId}/stop`, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: headers(),
|
|
113
|
+
}).catch(() => {});
|
|
114
|
+
|
|
115
|
+
const res = await fetch(
|
|
116
|
+
`${BASE_URL}/v1/apps/${app}/machines/${machineId}?force=true`,
|
|
117
|
+
{
|
|
118
|
+
method: "DELETE",
|
|
119
|
+
headers: headers(),
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
if (!res.ok && res.status !== 404) {
|
|
123
|
+
const body = await res.text().catch(() => "");
|
|
124
|
+
console.warn(`Fly delete failed (${res.status}): ${body}`);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Best-effort
|
|
128
|
+
}
|
|
129
|
+
machines.delete(name);
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async list(opts) {
|
|
133
|
+
const app = getAppName();
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(`${BASE_URL}/v1/apps/${app}/machines`, {
|
|
136
|
+
headers: headers(),
|
|
137
|
+
});
|
|
138
|
+
if (!res.ok) return [];
|
|
139
|
+
const data = (await res.json()) as Array<{ id: string; name?: string }>;
|
|
140
|
+
const prefix = opts?.prefix ?? "ca-sess-";
|
|
141
|
+
|
|
142
|
+
// Update our name→id map from the API response
|
|
143
|
+
for (const m of data) {
|
|
144
|
+
if (m.name) machines.set(m.name, m.id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return data
|
|
148
|
+
.filter((m) => m.name?.startsWith(prefix))
|
|
149
|
+
.map((m) => ({ name: m.name! }));
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async exec(name, argv, opts) {
|
|
156
|
+
const machineId = machines.get(name);
|
|
157
|
+
if (!machineId) throw new Error(`Fly machine not found for name: ${name}`);
|
|
158
|
+
const app = getAppName();
|
|
159
|
+
|
|
160
|
+
const cmd = argv.map((a) => shellEscape(a)).join(" ");
|
|
161
|
+
const execBody: Record<string, unknown> = {
|
|
162
|
+
cmd: ["bash", "-c", opts?.stdin ? `echo ${shellEscape(opts.stdin)} | ${cmd}` : cmd],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const res = await fetch(
|
|
166
|
+
`${BASE_URL}/v1/apps/${app}/machines/${machineId}/exec`,
|
|
167
|
+
{
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: headers(),
|
|
170
|
+
body: JSON.stringify(execBody),
|
|
171
|
+
signal: opts?.timeoutMs
|
|
172
|
+
? AbortSignal.timeout(opts.timeoutMs)
|
|
173
|
+
: undefined,
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
if (!res.ok) {
|
|
178
|
+
const text = await res.text().catch(() => "");
|
|
179
|
+
throw new Error(`Fly exec failed (${res.status}): ${text}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const result = (await res.json()) as {
|
|
183
|
+
stdout?: string;
|
|
184
|
+
stderr?: string;
|
|
185
|
+
exit_code?: number;
|
|
186
|
+
exitCode?: number;
|
|
187
|
+
};
|
|
188
|
+
return {
|
|
189
|
+
stdout: result.stdout ?? "",
|
|
190
|
+
stderr: result.stderr ?? "",
|
|
191
|
+
exit_code: result.exit_code ?? result.exitCode ?? 0,
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async startExec(name, opts) {
|
|
196
|
+
const machineId = machines.get(name);
|
|
197
|
+
if (!machineId) throw new Error(`Fly machine not found for name: ${name}`);
|
|
198
|
+
const app = getAppName();
|
|
199
|
+
|
|
200
|
+
const cmd = opts.argv.map((a) => shellEscape(a)).join(" ");
|
|
201
|
+
const execBody: Record<string, unknown> = {
|
|
202
|
+
cmd: ["bash", "-c", opts.stdin ? `echo ${shellEscape(opts.stdin)} | ${cmd}` : cmd],
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const controller = new AbortController();
|
|
206
|
+
if (opts.signal) {
|
|
207
|
+
if (opts.signal.aborted) {
|
|
208
|
+
controller.abort();
|
|
209
|
+
} else {
|
|
210
|
+
opts.signal.addEventListener("abort", () => controller.abort());
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (opts.timeoutMs) {
|
|
214
|
+
setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const res = await fetch(
|
|
218
|
+
`${BASE_URL}/v1/apps/${app}/machines/${machineId}/exec`,
|
|
219
|
+
{
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: headers(),
|
|
222
|
+
body: JSON.stringify(execBody),
|
|
223
|
+
signal: controller.signal,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
const text = await res.text().catch(() => "");
|
|
229
|
+
throw new Error(`Fly exec failed (${res.status}): ${text}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// If the response has a streaming body, tee it
|
|
233
|
+
if (res.body) {
|
|
234
|
+
const [streamForCaller, streamForExit] = res.body.tee();
|
|
235
|
+
|
|
236
|
+
const exit = (async () => {
|
|
237
|
+
const reader = streamForExit.getReader();
|
|
238
|
+
try {
|
|
239
|
+
for (;;) {
|
|
240
|
+
const { done } = await reader.read();
|
|
241
|
+
if (done) break;
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
// Stream error
|
|
245
|
+
}
|
|
246
|
+
return { code: 0 };
|
|
247
|
+
})();
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
stdout: streamForCaller,
|
|
251
|
+
exit,
|
|
252
|
+
async kill() {
|
|
253
|
+
controller.abort();
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Fallback: wrap full response in a ReadableStream
|
|
259
|
+
const fullBody = await res.text();
|
|
260
|
+
const encoder = new TextEncoder();
|
|
261
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
262
|
+
start(c) {
|
|
263
|
+
c.enqueue(encoder.encode(fullBody));
|
|
264
|
+
c.close();
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
stdout,
|
|
270
|
+
exit: Promise.resolve({ code: 0 }),
|
|
271
|
+
async kill() {
|
|
272
|
+
controller.abort();
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
},
|
|
276
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Modal provider using REST API (buffered exec only).
|
|
3
|
+
*
|
|
4
|
+
* Uses Modal's sandbox API to create and manage sandboxes.
|
|
5
|
+
* No SDK required — communicates via plain HTTP fetch.
|
|
6
|
+
* Exec is buffered (no streaming) — startExec wraps the full
|
|
7
|
+
* response in a ReadableStream that emits all at once.
|
|
8
|
+
*
|
|
9
|
+
* Container lifecycle:
|
|
10
|
+
* create → POST /v1/sandboxes
|
|
11
|
+
* exec → POST /v1/sandboxes/{id}/exec
|
|
12
|
+
* delete → DELETE /v1/sandboxes/{id}
|
|
13
|
+
*
|
|
14
|
+
* Env vars: MODAL_TOKEN_ID, MODAL_TOKEN_SECRET
|
|
15
|
+
*/
|
|
16
|
+
import type { ContainerProvider, ExecOptions, ExecSession } from "./types";
|
|
17
|
+
import { shellEscape } from "./shared";
|
|
18
|
+
|
|
19
|
+
const BASE_URL = "https://api.modal.com";
|
|
20
|
+
|
|
21
|
+
function getAuth(): { tokenId: string; tokenSecret: string } {
|
|
22
|
+
const tokenId = process.env.MODAL_TOKEN_ID;
|
|
23
|
+
const tokenSecret = process.env.MODAL_TOKEN_SECRET;
|
|
24
|
+
if (!tokenId) throw new Error("MODAL_TOKEN_ID environment variable is required");
|
|
25
|
+
if (!tokenSecret) throw new Error("MODAL_TOKEN_SECRET environment variable is required");
|
|
26
|
+
return { tokenId, tokenSecret };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function headers(): Record<string, string> {
|
|
30
|
+
const { tokenId, tokenSecret } = getAuth();
|
|
31
|
+
return {
|
|
32
|
+
Authorization: `Bearer ${tokenId}:${tokenSecret}`,
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// HMR-safe name→sandboxId map
|
|
38
|
+
type GlobalWithModal = typeof globalThis & { __caModalSandboxes?: Map<string, string> };
|
|
39
|
+
const g = globalThis as GlobalWithModal;
|
|
40
|
+
if (!g.__caModalSandboxes) g.__caModalSandboxes = new Map();
|
|
41
|
+
const sandboxes = g.__caModalSandboxes;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Provider
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
export const modalProvider: ContainerProvider = {
|
|
48
|
+
name: "modal",
|
|
49
|
+
stripControlChars: false,
|
|
50
|
+
|
|
51
|
+
async checkAvailability() {
|
|
52
|
+
if (!process.env.MODAL_TOKEN_ID) {
|
|
53
|
+
return { available: false, message: "Modal requires MODAL_TOKEN_ID to be set" };
|
|
54
|
+
}
|
|
55
|
+
if (!process.env.MODAL_TOKEN_SECRET) {
|
|
56
|
+
return { available: false, message: "Modal requires MODAL_TOKEN_SECRET to be set" };
|
|
57
|
+
}
|
|
58
|
+
return { available: true };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async create({ name }) {
|
|
62
|
+
const res = await fetch(`${BASE_URL}/v1/sandboxes`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: headers(),
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
name,
|
|
67
|
+
image: "node:22",
|
|
68
|
+
command: ["sleep", "infinity"],
|
|
69
|
+
}),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
const body = await res.text().catch(() => "");
|
|
74
|
+
throw new Error(`Modal create failed (${res.status}): ${body}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const data = (await res.json()) as { id?: string; sandbox_id?: string };
|
|
78
|
+
const sandboxId = data.id ?? data.sandbox_id;
|
|
79
|
+
if (!sandboxId) throw new Error("Modal create response missing sandbox id");
|
|
80
|
+
sandboxes.set(name, sandboxId);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
async delete(name) {
|
|
84
|
+
const sandboxId = sandboxes.get(name);
|
|
85
|
+
if (!sandboxId) return;
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`${BASE_URL}/v1/sandboxes/${sandboxId}`, {
|
|
88
|
+
method: "DELETE",
|
|
89
|
+
headers: headers(),
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok && res.status !== 404) {
|
|
92
|
+
const body = await res.text().catch(() => "");
|
|
93
|
+
console.warn(`Modal delete failed (${res.status}): ${body}`);
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
// Best-effort
|
|
97
|
+
}
|
|
98
|
+
sandboxes.delete(name);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async list(opts) {
|
|
102
|
+
const prefix = opts?.prefix ?? "ca-sess-";
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${BASE_URL}/v1/sandboxes`, {
|
|
105
|
+
headers: headers(),
|
|
106
|
+
});
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
// Fall back to in-memory map on API failure
|
|
109
|
+
return Array.from(sandboxes.keys())
|
|
110
|
+
.filter((n) => n.startsWith(prefix))
|
|
111
|
+
.map((name) => ({ name }));
|
|
112
|
+
}
|
|
113
|
+
const data = (await res.json()) as Array<{
|
|
114
|
+
id?: string;
|
|
115
|
+
sandbox_id?: string;
|
|
116
|
+
name?: string;
|
|
117
|
+
}>;
|
|
118
|
+
|
|
119
|
+
// Re-populate the in-memory name→id map from the API response
|
|
120
|
+
for (const s of data) {
|
|
121
|
+
const id = s.id ?? s.sandbox_id;
|
|
122
|
+
if (s.name && id) sandboxes.set(s.name, id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return data
|
|
126
|
+
.filter((s) => s.name?.startsWith(prefix))
|
|
127
|
+
.map((s) => ({ name: s.name! }));
|
|
128
|
+
} catch {
|
|
129
|
+
// Fall back to in-memory map on network error
|
|
130
|
+
return Array.from(sandboxes.keys())
|
|
131
|
+
.filter((n) => n.startsWith(prefix))
|
|
132
|
+
.map((name) => ({ name }));
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async exec(name, argv, opts) {
|
|
137
|
+
const sandboxId = sandboxes.get(name);
|
|
138
|
+
if (!sandboxId) throw new Error(`Modal sandbox not found for name: ${name}`);
|
|
139
|
+
|
|
140
|
+
const cmd = argv.map((a) => shellEscape(a)).join(" ");
|
|
141
|
+
const execBody: Record<string, unknown> = {
|
|
142
|
+
command: opts?.stdin ? `echo ${shellEscape(opts.stdin)} | ${cmd}` : cmd,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const res = await fetch(`${BASE_URL}/v1/sandboxes/${sandboxId}/exec`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: headers(),
|
|
148
|
+
body: JSON.stringify(execBody),
|
|
149
|
+
signal: opts?.timeoutMs
|
|
150
|
+
? AbortSignal.timeout(opts.timeoutMs)
|
|
151
|
+
: undefined,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!res.ok) {
|
|
155
|
+
const text = await res.text().catch(() => "");
|
|
156
|
+
throw new Error(`Modal exec failed (${res.status}): ${text}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = (await res.json()) as {
|
|
160
|
+
stdout?: string;
|
|
161
|
+
stderr?: string;
|
|
162
|
+
exit_code?: number;
|
|
163
|
+
exitCode?: number;
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
stdout: result.stdout ?? "",
|
|
167
|
+
stderr: result.stderr ?? "",
|
|
168
|
+
exit_code: result.exit_code ?? result.exitCode ?? 0,
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async startExec(name, opts) {
|
|
173
|
+
const sandboxId = sandboxes.get(name);
|
|
174
|
+
if (!sandboxId) throw new Error(`Modal sandbox not found for name: ${name}`);
|
|
175
|
+
|
|
176
|
+
const cmd = opts.argv.map((a) => shellEscape(a)).join(" ");
|
|
177
|
+
const execBody: Record<string, unknown> = {
|
|
178
|
+
command: opts.stdin ? `echo ${shellEscape(opts.stdin)} | ${cmd}` : cmd,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const res = await fetch(`${BASE_URL}/v1/sandboxes/${sandboxId}/exec`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: headers(),
|
|
184
|
+
body: JSON.stringify(execBody),
|
|
185
|
+
signal: opts.signal,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const text = await res.text().catch(() => "");
|
|
190
|
+
throw new Error(`Modal exec failed (${res.status}): ${text}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Modal exec is buffered — wrap the complete response in a stream
|
|
194
|
+
const result = (await res.json()) as {
|
|
195
|
+
stdout?: string;
|
|
196
|
+
stderr?: string;
|
|
197
|
+
exit_code?: number;
|
|
198
|
+
exitCode?: number;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const encoder = new TextEncoder();
|
|
202
|
+
const stdoutText = result.stdout ?? "";
|
|
203
|
+
const exitCode = result.exit_code ?? result.exitCode ?? 0;
|
|
204
|
+
|
|
205
|
+
const stdout = new ReadableStream<Uint8Array>({
|
|
206
|
+
start(controller) {
|
|
207
|
+
if (stdoutText) {
|
|
208
|
+
controller.enqueue(encoder.encode(stdoutText));
|
|
209
|
+
}
|
|
210
|
+
controller.close();
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
stdout,
|
|
216
|
+
exit: Promise.resolve({ code: exitCode }),
|
|
217
|
+
async kill() {
|
|
218
|
+
// Buffered exec — nothing to kill
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
},
|
|
222
|
+
};
|