@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.
Files changed (105) hide show
  1. package/package.json +45 -0
  2. package/src/auth/middleware.ts +38 -0
  3. package/src/backends/claude/args.ts +88 -0
  4. package/src/backends/claude/index.ts +193 -0
  5. package/src/backends/claude/permission-hook.ts +152 -0
  6. package/src/backends/claude/tool-bridge.ts +211 -0
  7. package/src/backends/claude/translator.ts +209 -0
  8. package/src/backends/claude/wrapper-script.ts +45 -0
  9. package/src/backends/codex/args.ts +69 -0
  10. package/src/backends/codex/auth.ts +35 -0
  11. package/src/backends/codex/index.ts +57 -0
  12. package/src/backends/codex/setup.ts +37 -0
  13. package/src/backends/codex/translator.ts +223 -0
  14. package/src/backends/codex/wrapper-script.ts +26 -0
  15. package/src/backends/factory/args.ts +45 -0
  16. package/src/backends/factory/auth.ts +30 -0
  17. package/src/backends/factory/index.ts +56 -0
  18. package/src/backends/factory/setup.ts +34 -0
  19. package/src/backends/factory/translator.ts +139 -0
  20. package/src/backends/factory/wrapper-script.ts +33 -0
  21. package/src/backends/gemini/args.ts +44 -0
  22. package/src/backends/gemini/auth.ts +30 -0
  23. package/src/backends/gemini/index.ts +53 -0
  24. package/src/backends/gemini/setup.ts +34 -0
  25. package/src/backends/gemini/translator.ts +139 -0
  26. package/src/backends/gemini/wrapper-script.ts +26 -0
  27. package/src/backends/opencode/args.ts +53 -0
  28. package/src/backends/opencode/auth.ts +53 -0
  29. package/src/backends/opencode/index.ts +70 -0
  30. package/src/backends/opencode/mcp.ts +67 -0
  31. package/src/backends/opencode/setup.ts +54 -0
  32. package/src/backends/opencode/translator.ts +168 -0
  33. package/src/backends/opencode/wrapper-script.ts +46 -0
  34. package/src/backends/registry.ts +38 -0
  35. package/src/backends/shared/ndjson.ts +29 -0
  36. package/src/backends/shared/translator-types.ts +69 -0
  37. package/src/backends/shared/wrap-prompt.ts +17 -0
  38. package/src/backends/types.ts +85 -0
  39. package/src/config/index.ts +95 -0
  40. package/src/db/agents.ts +185 -0
  41. package/src/db/api_keys.ts +78 -0
  42. package/src/db/batch.ts +142 -0
  43. package/src/db/client.ts +81 -0
  44. package/src/db/environments.ts +127 -0
  45. package/src/db/events.ts +208 -0
  46. package/src/db/memory.ts +143 -0
  47. package/src/db/migrations.ts +295 -0
  48. package/src/db/proxy.ts +37 -0
  49. package/src/db/sessions.ts +295 -0
  50. package/src/db/vaults.ts +110 -0
  51. package/src/errors.ts +53 -0
  52. package/src/handlers/agents.ts +194 -0
  53. package/src/handlers/batch.ts +41 -0
  54. package/src/handlers/docs.ts +87 -0
  55. package/src/handlers/environments.ts +154 -0
  56. package/src/handlers/events.ts +234 -0
  57. package/src/handlers/index.ts +12 -0
  58. package/src/handlers/memory.ts +141 -0
  59. package/src/handlers/openapi.ts +14 -0
  60. package/src/handlers/sessions.ts +223 -0
  61. package/src/handlers/stream.ts +76 -0
  62. package/src/handlers/threads.ts +26 -0
  63. package/src/handlers/ui/app.js +984 -0
  64. package/src/handlers/ui/index.html +112 -0
  65. package/src/handlers/ui/style.css +164 -0
  66. package/src/handlers/ui.ts +1281 -0
  67. package/src/handlers/vaults.ts +99 -0
  68. package/src/http.ts +35 -0
  69. package/src/index.ts +104 -0
  70. package/src/init.ts +227 -0
  71. package/src/openapi/registry.ts +8 -0
  72. package/src/openapi/schemas.ts +625 -0
  73. package/src/openapi/spec.ts +691 -0
  74. package/src/providers/apple.ts +220 -0
  75. package/src/providers/daytona.ts +217 -0
  76. package/src/providers/docker.ts +264 -0
  77. package/src/providers/e2b.ts +203 -0
  78. package/src/providers/fly.ts +276 -0
  79. package/src/providers/modal.ts +222 -0
  80. package/src/providers/podman.ts +206 -0
  81. package/src/providers/registry.ts +28 -0
  82. package/src/providers/shared.ts +11 -0
  83. package/src/providers/sprites.ts +55 -0
  84. package/src/providers/types.ts +73 -0
  85. package/src/providers/vercel.ts +208 -0
  86. package/src/proxy/forward.ts +111 -0
  87. package/src/queue/index.ts +111 -0
  88. package/src/sessions/actor.ts +53 -0
  89. package/src/sessions/bus.ts +155 -0
  90. package/src/sessions/driver.ts +818 -0
  91. package/src/sessions/grader.ts +120 -0
  92. package/src/sessions/interrupt.ts +14 -0
  93. package/src/sessions/sweeper.ts +136 -0
  94. package/src/sessions/threads.ts +126 -0
  95. package/src/sessions/tools.ts +50 -0
  96. package/src/shutdown.ts +78 -0
  97. package/src/sprite/client.ts +294 -0
  98. package/src/sprite/exec.ts +161 -0
  99. package/src/sprite/lifecycle.ts +339 -0
  100. package/src/sprite/pool.ts +65 -0
  101. package/src/sprite/setup.ts +159 -0
  102. package/src/state.ts +61 -0
  103. package/src/types.ts +339 -0
  104. package/src/util/clock.ts +7 -0
  105. 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
+ };