@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,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
|
+
};
|