@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,220 @@
1
+ /**
2
+ * Apple Containers provider (macOS 26+).
3
+ *
4
+ * Runs CLI backends inside Apple's native container runtime instead of
5
+ * Docker or sprites.dev. Uses `child_process.spawn("container", [...])`
6
+ * to interact with Apple's container CLI. Requires macOS 26 (Tahoe)
7
+ * with Apple Silicon.
8
+ *
9
+ * The execution model is identical to Docker:
10
+ * create → container create --name {name} node:22 sleep infinity + container start
11
+ * exec → container exec -i {name} with stdin piped + stdout captured
12
+ * delete → container rm -f {name}
13
+ *
14
+ * Differences from Docker:
15
+ * - CLI binary is `container` not `docker`
16
+ * - List command is `container ls` not `docker ps`
17
+ * - Uses Apple's Virtualization.framework (VM-per-container, not shared kernel)
18
+ * - OCI-compatible: pulls from Docker Hub, uses same image format
19
+ * - arm64 native on Apple Silicon; amd64 via Rosetta 2
20
+ *
21
+ * Ref: https://github.com/apple/container
22
+ */
23
+ import { spawn } from "node:child_process";
24
+ import { Readable } from "node:stream";
25
+ import type { ContainerProvider, ExecOptions, ExecSession } from "./types";
26
+
27
+ const DEFAULT_IMAGE = process.env.APPLE_CONTAINER_IMAGE ?? "node:22";
28
+ const CLI = "container"; // Apple's CLI binary name
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers (same structure as docker.ts, different CLI binary)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function containerRun(
35
+ args: string[],
36
+ opts?: { stdin?: string; timeoutMs?: number },
37
+ ): Promise<string> {
38
+ return new Promise((resolve, reject) => {
39
+ const proc = spawn(CLI, args, { stdio: ["pipe", "pipe", "pipe"] });
40
+
41
+ let stdout = "";
42
+ let stderr = "";
43
+ proc.stdout?.on("data", (buf: Buffer) => { stdout += buf.toString(); });
44
+ proc.stderr?.on("data", (buf: Buffer) => { stderr += buf.toString(); });
45
+
46
+ if (opts?.stdin) {
47
+ proc.stdin?.write(opts.stdin);
48
+ }
49
+ proc.stdin?.end();
50
+
51
+ const timer = opts?.timeoutMs
52
+ ? setTimeout(() => {
53
+ proc.kill("SIGKILL");
54
+ reject(new Error(`${CLI} command timed out after ${opts.timeoutMs}ms`));
55
+ }, opts.timeoutMs)
56
+ : null;
57
+
58
+ proc.on("close", (code) => {
59
+ if (timer) clearTimeout(timer);
60
+ if (code !== 0) {
61
+ reject(new Error(`${CLI} ${args[0]} failed (${code}): ${stderr.trim()}`));
62
+ } else {
63
+ resolve(stdout);
64
+ }
65
+ });
66
+ proc.on("error", (err) => {
67
+ if (timer) clearTimeout(timer);
68
+ reject(err);
69
+ });
70
+ });
71
+ }
72
+
73
+ function containerExecOneShot(
74
+ containerName: string,
75
+ argv: string[],
76
+ stdin?: string,
77
+ timeoutMs?: number,
78
+ ): Promise<{ stdout: string; stderr: string; exit_code: number }> {
79
+ return new Promise((resolve, reject) => {
80
+ const proc = spawn(CLI, ["exec", "-i", containerName, ...argv], {
81
+ stdio: ["pipe", "pipe", "pipe"],
82
+ });
83
+
84
+ let stdout = "";
85
+ let stderr = "";
86
+ proc.stdout?.on("data", (buf: Buffer) => { stdout += buf.toString(); });
87
+ proc.stderr?.on("data", (buf: Buffer) => { stderr += buf.toString(); });
88
+
89
+ if (stdin) proc.stdin?.write(stdin);
90
+ proc.stdin?.end();
91
+
92
+ const timer = timeoutMs
93
+ ? setTimeout(() => {
94
+ proc.kill("SIGKILL");
95
+ reject(new Error(`${CLI} exec timed out after ${timeoutMs}ms`));
96
+ }, timeoutMs)
97
+ : null;
98
+
99
+ proc.on("close", (code) => {
100
+ if (timer) clearTimeout(timer);
101
+ resolve({ stdout, stderr, exit_code: code ?? 1 });
102
+ });
103
+ proc.on("error", (err) => {
104
+ if (timer) clearTimeout(timer);
105
+ reject(err);
106
+ });
107
+ });
108
+ }
109
+
110
+ function containerExecStreaming(
111
+ containerName: string,
112
+ opts: ExecOptions,
113
+ ): ExecSession {
114
+ const proc = spawn(CLI, ["exec", "-i", containerName, ...opts.argv], {
115
+ stdio: ["pipe", "pipe", "pipe"],
116
+ });
117
+
118
+ if (opts.stdin) proc.stdin?.write(opts.stdin);
119
+ proc.stdin?.end();
120
+
121
+ const stdout = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
122
+
123
+ let exitResolve: (v: { code: number }) => void;
124
+ let exitReject: (e: unknown) => void;
125
+ const exit = new Promise<{ code: number }>((res, rej) => {
126
+ exitResolve = res;
127
+ exitReject = rej;
128
+ });
129
+
130
+ proc.on("close", (code) => exitResolve({ code: code ?? 0 }));
131
+ proc.on("error", (err) => exitReject(err));
132
+
133
+ let timer: NodeJS.Timeout | null = null;
134
+ if (opts.timeoutMs) {
135
+ timer = setTimeout(() => {
136
+ proc.kill("SIGKILL");
137
+ exitReject(new Error(`${CLI} exec timed out after ${opts.timeoutMs}ms`));
138
+ }, opts.timeoutMs);
139
+ }
140
+ exit.finally(() => { if (timer) clearTimeout(timer); });
141
+
142
+ if (opts.signal) {
143
+ if (opts.signal.aborted) {
144
+ proc.kill("SIGTERM");
145
+ } else {
146
+ opts.signal.addEventListener("abort", () => {
147
+ proc.kill("SIGTERM");
148
+ setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 3000);
149
+ });
150
+ }
151
+ }
152
+
153
+ return {
154
+ stdout,
155
+ exit,
156
+ async kill() {
157
+ proc.kill("SIGTERM");
158
+ setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 3000);
159
+ },
160
+ };
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Provider
165
+ // ---------------------------------------------------------------------------
166
+
167
+ export const appleProvider: ContainerProvider = {
168
+ name: "apple",
169
+ stripControlChars: false,
170
+
171
+ async checkAvailability() {
172
+ if (process.platform !== "darwin") {
173
+ return { available: false, message: "Apple Containers requires macOS" };
174
+ }
175
+ try {
176
+ await containerRun(["--version"], { timeoutMs: 3000 });
177
+ return { available: true };
178
+ } catch (err) {
179
+ const msg = err instanceof Error ? err.message : String(err);
180
+ if (msg.includes("ENOENT")) {
181
+ return { available: false, message: "Apple Containers CLI not found. Requires macOS 26+" };
182
+ }
183
+ return { available: false, message: `Apple Containers not accessible: ${msg}` };
184
+ }
185
+ },
186
+
187
+ async create({ name }) {
188
+ await containerRun(["create", "--name", name, DEFAULT_IMAGE, "sleep", "infinity"]);
189
+ await containerRun(["start", name]);
190
+ },
191
+
192
+ async delete(name) {
193
+ await containerRun(["rm", "-f", name]).catch(() => {});
194
+ },
195
+
196
+ async list(opts) {
197
+ try {
198
+ // Apple uses `container ls` not `docker ps`
199
+ const out = await containerRun([
200
+ "ls",
201
+ "-a",
202
+ "--filter",
203
+ `name=${opts?.prefix ?? "ca-sess-"}`,
204
+ "--format",
205
+ "{{.Names}}",
206
+ ]);
207
+ return out.trim().split("\n").filter(Boolean).map((name) => ({ name }));
208
+ } catch {
209
+ return [];
210
+ }
211
+ },
212
+
213
+ async exec(name, argv, opts) {
214
+ return containerExecOneShot(name, argv, opts?.stdin, opts?.timeoutMs);
215
+ },
216
+
217
+ startExec(name, opts) {
218
+ return Promise.resolve(containerExecStreaming(name, opts));
219
+ },
220
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Daytona provider using REST API via fetch.
3
+ *
4
+ * Uses Daytona's workspace API to create and manage dev environments.
5
+ * No SDK required — communicates via plain HTTP fetch.
6
+ *
7
+ * Container lifecycle:
8
+ * create → POST /workspaces
9
+ * exec → POST /workspaces/{name}/exec
10
+ * delete → DELETE /workspaces/{name}
11
+ *
12
+ * Env vars: DAYTONA_API_URL, DAYTONA_API_KEY
13
+ */
14
+ import type { ContainerProvider, ExecOptions, ExecSession } from "./types";
15
+ import { shellEscape } from "./shared";
16
+
17
+ function getApiUrl(): string {
18
+ const url = process.env.DAYTONA_API_URL;
19
+ if (!url) throw new Error("DAYTONA_API_URL environment variable is required");
20
+ return url.replace(/\/+$/, "");
21
+ }
22
+
23
+ function getApiKey(): string {
24
+ const key = process.env.DAYTONA_API_KEY;
25
+ if (!key) throw new Error("DAYTONA_API_KEY environment variable is required");
26
+ return key;
27
+ }
28
+
29
+ function headers(): Record<string, string> {
30
+ return {
31
+ Authorization: `Bearer ${getApiKey()}`,
32
+ "Content-Type": "application/json",
33
+ };
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Provider
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export const daytonaProvider: ContainerProvider = {
41
+ name: "daytona",
42
+ stripControlChars: false,
43
+
44
+ async checkAvailability() {
45
+ if (!process.env.DAYTONA_API_URL) {
46
+ return { available: false, message: "Daytona requires DAYTONA_API_URL to be set" };
47
+ }
48
+ if (!process.env.DAYTONA_API_KEY) {
49
+ return { available: false, message: "Daytona requires DAYTONA_API_KEY to be set" };
50
+ }
51
+ return { available: true };
52
+ },
53
+
54
+ async create({ name }) {
55
+ const url = getApiUrl();
56
+ const res = await fetch(`${url}/workspaces`, {
57
+ method: "POST",
58
+ headers: headers(),
59
+ body: JSON.stringify({
60
+ name,
61
+ target: "local",
62
+ }),
63
+ });
64
+ if (!res.ok) {
65
+ const body = await res.text().catch(() => "");
66
+ throw new Error(`Daytona create failed (${res.status}): ${body}`);
67
+ }
68
+ },
69
+
70
+ async delete(name) {
71
+ const url = getApiUrl();
72
+ try {
73
+ const res = await fetch(`${url}/workspaces/${encodeURIComponent(name)}`, {
74
+ method: "DELETE",
75
+ headers: headers(),
76
+ });
77
+ if (!res.ok && res.status !== 404) {
78
+ const body = await res.text().catch(() => "");
79
+ console.warn(`Daytona delete failed (${res.status}): ${body}`);
80
+ }
81
+ } catch {
82
+ // Best-effort — workspace may already be gone
83
+ }
84
+ },
85
+
86
+ async list(opts) {
87
+ const url = getApiUrl();
88
+ try {
89
+ const res = await fetch(`${url}/workspaces`, {
90
+ headers: headers(),
91
+ });
92
+ if (!res.ok) return [];
93
+ const data = (await res.json()) as Array<{ name: string }>;
94
+ const prefix = opts?.prefix ?? "ca-sess-";
95
+ return data
96
+ .filter((w) => w.name.startsWith(prefix))
97
+ .map((w) => ({ name: w.name }));
98
+ } catch {
99
+ return [];
100
+ }
101
+ },
102
+
103
+ async exec(name, argv, opts) {
104
+ const url = getApiUrl();
105
+ const cmd = argv.map((a) => shellEscape(a)).join(" ");
106
+ const body: Record<string, unknown> = { command: cmd };
107
+ if (opts?.stdin) body.stdin = opts.stdin;
108
+
109
+ const res = await fetch(
110
+ `${url}/workspaces/${encodeURIComponent(name)}/exec`,
111
+ {
112
+ method: "POST",
113
+ headers: headers(),
114
+ body: JSON.stringify(body),
115
+ signal: opts?.timeoutMs
116
+ ? AbortSignal.timeout(opts.timeoutMs)
117
+ : undefined,
118
+ },
119
+ );
120
+
121
+ if (!res.ok) {
122
+ const text = await res.text().catch(() => "");
123
+ throw new Error(`Daytona exec failed (${res.status}): ${text}`);
124
+ }
125
+
126
+ const result = (await res.json()) as {
127
+ stdout?: string;
128
+ stderr?: string;
129
+ exit_code?: number;
130
+ exitCode?: number;
131
+ };
132
+ return {
133
+ stdout: result.stdout ?? "",
134
+ stderr: result.stderr ?? "",
135
+ exit_code: result.exit_code ?? result.exitCode ?? 0,
136
+ };
137
+ },
138
+
139
+ async startExec(name, opts) {
140
+ const url = getApiUrl();
141
+ const cmd = opts.argv.map((a) => shellEscape(a)).join(" ");
142
+ const body: Record<string, unknown> = { command: cmd };
143
+ if (opts.stdin) body.stdin = opts.stdin;
144
+
145
+ const controller = new AbortController();
146
+ if (opts.signal) {
147
+ if (opts.signal.aborted) {
148
+ controller.abort();
149
+ } else {
150
+ opts.signal.addEventListener("abort", () => controller.abort());
151
+ }
152
+ }
153
+ if (opts.timeoutMs) {
154
+ setTimeout(() => controller.abort(), opts.timeoutMs);
155
+ }
156
+
157
+ const res = await fetch(
158
+ `${url}/workspaces/${encodeURIComponent(name)}/exec`,
159
+ {
160
+ method: "POST",
161
+ headers: headers(),
162
+ body: JSON.stringify(body),
163
+ signal: controller.signal,
164
+ },
165
+ );
166
+
167
+ if (!res.ok) {
168
+ const text = await res.text().catch(() => "");
169
+ throw new Error(`Daytona exec failed (${res.status}): ${text}`);
170
+ }
171
+
172
+ // If the response has a streaming body, pass it through directly.
173
+ // Otherwise, wrap the full text response in a ReadableStream.
174
+ if (res.body) {
175
+ const [streamForCaller, streamForExit] = res.body.tee();
176
+
177
+ const exit = (async () => {
178
+ const reader = streamForExit.getReader();
179
+ try {
180
+ for (;;) {
181
+ const { done } = await reader.read();
182
+ if (done) break;
183
+ }
184
+ } catch {
185
+ // Stream error
186
+ }
187
+ return { code: 0 };
188
+ })();
189
+
190
+ return {
191
+ stdout: streamForCaller,
192
+ exit,
193
+ async kill() {
194
+ controller.abort();
195
+ },
196
+ };
197
+ }
198
+
199
+ // Fallback: no streaming body — read entire response and wrap
200
+ const fullBody = await res.text();
201
+ const encoder = new TextEncoder();
202
+ const stdout = new ReadableStream<Uint8Array>({
203
+ start(c) {
204
+ c.enqueue(encoder.encode(fullBody));
205
+ c.close();
206
+ },
207
+ });
208
+
209
+ return {
210
+ stdout,
211
+ exit: Promise.resolve({ code: 0 }),
212
+ async kill() {
213
+ controller.abort();
214
+ },
215
+ };
216
+ },
217
+ };
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Docker container provider.
3
+ *
4
+ * Runs CLI backends inside local Docker containers instead of sprites.dev.
5
+ * Uses `child_process.spawn("docker", [...])` to interact with the Docker
6
+ * daemon via CLI. Requires Docker to be installed and accessible on the host.
7
+ *
8
+ * Container lifecycle:
9
+ * create → docker create --name {name} node:22 sleep infinity + docker start
10
+ * exec → docker exec -i {name} with stdin piped + stdout captured
11
+ * delete → docker rm -f {name}
12
+ *
13
+ * The `sleep infinity` entrypoint keeps the container alive for repeated
14
+ * `docker exec` calls throughout the session's lifetime (same model as
15
+ * sprites.dev: one container per session, multiple exec calls).
16
+ */
17
+ import { spawn, type ChildProcess } from "node:child_process";
18
+ import { Readable } from "node:stream";
19
+ import type { ContainerProvider, ExecOptions, ExecSession } from "./types";
20
+
21
+ const DEFAULT_IMAGE = process.env.DOCKER_IMAGE ?? "node:22";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helpers
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Run a docker CLI command and return stdout as a string. */
28
+ function dockerRun(
29
+ args: string[],
30
+ opts?: { stdin?: string; timeoutMs?: number },
31
+ ): Promise<string> {
32
+ return new Promise((resolve, reject) => {
33
+ const proc = spawn("docker", args, {
34
+ stdio: ["pipe", "pipe", "pipe"],
35
+ });
36
+
37
+ let stdout = "";
38
+ let stderr = "";
39
+ proc.stdout?.on("data", (buf: Buffer) => {
40
+ stdout += buf.toString();
41
+ });
42
+ proc.stderr?.on("data", (buf: Buffer) => {
43
+ stderr += buf.toString();
44
+ });
45
+
46
+ if (opts?.stdin) {
47
+ proc.stdin?.write(opts.stdin);
48
+ proc.stdin?.end();
49
+ } else {
50
+ proc.stdin?.end();
51
+ }
52
+
53
+ const timer = opts?.timeoutMs
54
+ ? setTimeout(() => {
55
+ proc.kill("SIGKILL");
56
+ reject(new Error(`docker command timed out after ${opts.timeoutMs}ms`));
57
+ }, opts.timeoutMs)
58
+ : null;
59
+
60
+ proc.on("close", (code) => {
61
+ if (timer) clearTimeout(timer);
62
+ if (code !== 0) {
63
+ reject(new Error(`docker ${args[0]} failed (${code}): ${stderr.trim()}`));
64
+ } else {
65
+ resolve(stdout);
66
+ }
67
+ });
68
+
69
+ proc.on("error", (err) => {
70
+ if (timer) clearTimeout(timer);
71
+ reject(err);
72
+ });
73
+ });
74
+ }
75
+
76
+ /** One-shot docker exec: run, wait for exit, return output. */
77
+ async function dockerExecOneShot(
78
+ containerName: string,
79
+ argv: string[],
80
+ stdin?: string,
81
+ timeoutMs?: number,
82
+ ): Promise<{ stdout: string; stderr: string; exit_code: number }> {
83
+ return new Promise((resolve, reject) => {
84
+ const proc = spawn("docker", ["exec", "-i", containerName, ...argv], {
85
+ stdio: ["pipe", "pipe", "pipe"],
86
+ });
87
+
88
+ let stdout = "";
89
+ let stderr = "";
90
+ proc.stdout?.on("data", (buf: Buffer) => {
91
+ stdout += buf.toString();
92
+ });
93
+ proc.stderr?.on("data", (buf: Buffer) => {
94
+ stderr += buf.toString();
95
+ });
96
+
97
+ if (stdin) {
98
+ proc.stdin?.write(stdin);
99
+ }
100
+ proc.stdin?.end();
101
+
102
+ const timer = timeoutMs
103
+ ? setTimeout(() => {
104
+ proc.kill("SIGKILL");
105
+ reject(new Error(`docker exec timed out after ${timeoutMs}ms`));
106
+ }, timeoutMs)
107
+ : null;
108
+
109
+ proc.on("close", (code) => {
110
+ if (timer) clearTimeout(timer);
111
+ resolve({ stdout, stderr, exit_code: code ?? 1 });
112
+ });
113
+
114
+ proc.on("error", (err) => {
115
+ if (timer) clearTimeout(timer);
116
+ reject(err);
117
+ });
118
+ });
119
+ }
120
+
121
+ /** Streaming docker exec: returns ExecSession with ReadableStream stdout. */
122
+ function dockerExecStreaming(
123
+ containerName: string,
124
+ opts: ExecOptions,
125
+ ): ExecSession {
126
+ const proc = spawn("docker", ["exec", "-i", containerName, ...opts.argv], {
127
+ stdio: ["pipe", "pipe", "pipe"],
128
+ });
129
+
130
+ // Pipe stdin
131
+ if (opts.stdin) {
132
+ proc.stdin?.write(opts.stdin);
133
+ }
134
+ proc.stdin?.end();
135
+
136
+ // Convert Node Readable to Web ReadableStream
137
+ // Readable.toWeb() is available since Node 17 and stable in Node 22.
138
+ // Do NOT set encoding on spawn — default produces Buffer (Uint8Array subtype).
139
+ const stdout = Readable.toWeb(proc.stdout!) as ReadableStream<Uint8Array>;
140
+
141
+ // Exit promise
142
+ let exitResolve: (v: { code: number }) => void;
143
+ let exitReject: (e: unknown) => void;
144
+ const exit = new Promise<{ code: number }>((res, rej) => {
145
+ exitResolve = res;
146
+ exitReject = rej;
147
+ });
148
+
149
+ proc.on("close", (code) => {
150
+ exitResolve({ code: code ?? 0 });
151
+ });
152
+ proc.on("error", (err) => {
153
+ exitReject(err);
154
+ });
155
+
156
+ // Timeout
157
+ let timer: NodeJS.Timeout | null = null;
158
+ if (opts.timeoutMs) {
159
+ timer = setTimeout(() => {
160
+ proc.kill("SIGKILL");
161
+ exitReject(new Error(`docker exec timed out after ${opts.timeoutMs}ms`));
162
+ }, opts.timeoutMs);
163
+ }
164
+ // Clean up timer on exit
165
+ exit.finally(() => {
166
+ if (timer) clearTimeout(timer);
167
+ });
168
+
169
+ // Link caller's abort signal
170
+ if (opts.signal) {
171
+ if (opts.signal.aborted) {
172
+ proc.kill("SIGTERM");
173
+ } else {
174
+ opts.signal.addEventListener("abort", () => {
175
+ proc.kill("SIGTERM");
176
+ // Escalate to SIGKILL after 3s if still alive
177
+ setTimeout(() => {
178
+ if (!proc.killed) proc.kill("SIGKILL");
179
+ }, 3000);
180
+ });
181
+ }
182
+ }
183
+
184
+ return {
185
+ stdout,
186
+ exit,
187
+ async kill() {
188
+ proc.kill("SIGTERM");
189
+ setTimeout(() => {
190
+ if (!proc.killed) proc.kill("SIGKILL");
191
+ }, 3000);
192
+ },
193
+ };
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Provider
198
+ // ---------------------------------------------------------------------------
199
+
200
+ export const dockerProvider: ContainerProvider = {
201
+ name: "docker",
202
+ stripControlChars: false, // Docker doesn't add HTTP framing bytes
203
+
204
+ async checkAvailability() {
205
+ try {
206
+ await dockerRun(["version", "--format", "{{.Server.Version}}"], { timeoutMs: 3000 });
207
+ return { available: true };
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err);
210
+ if (msg.includes("ENOENT")) {
211
+ return { available: false, message: "Docker CLI is not installed. Install it from https://docs.docker.com/get-docker/" };
212
+ }
213
+ return { available: false, message: `Docker is not running or not accessible: ${msg}` };
214
+ }
215
+ },
216
+
217
+ async create({ name }) {
218
+ // Create container with sleep infinity to keep it alive for repeated exec calls
219
+ await dockerRun([
220
+ "create",
221
+ "--name",
222
+ name,
223
+ DEFAULT_IMAGE,
224
+ "sleep",
225
+ "infinity",
226
+ ]);
227
+ await dockerRun(["start", name]);
228
+ },
229
+
230
+ async delete(name) {
231
+ await dockerRun(["rm", "-f", name]).catch(() => {
232
+ // Best-effort — container may already be gone
233
+ });
234
+ },
235
+
236
+ async list(opts) {
237
+ try {
238
+ const out = await dockerRun([
239
+ "ps",
240
+ "-a",
241
+ "--filter",
242
+ `name=${opts?.prefix ?? "ca-sess-"}`,
243
+ "--format",
244
+ "{{.Names}}",
245
+ ]);
246
+ return out
247
+ .trim()
248
+ .split("\n")
249
+ .filter(Boolean)
250
+ .map((name) => ({ name }));
251
+ } catch {
252
+ // Docker not available or no matching containers
253
+ return [];
254
+ }
255
+ },
256
+
257
+ async exec(name, argv, opts) {
258
+ return dockerExecOneShot(name, argv, opts?.stdin, opts?.timeoutMs);
259
+ },
260
+
261
+ startExec(name, opts) {
262
+ return Promise.resolve(dockerExecStreaming(name, opts));
263
+ },
264
+ };