@parcha/pi-durable-disk 0.0.0-stage → 0.1.0-beta.13

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 (50) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +2 -0
  4. package/README.md +695 -2
  5. package/bin/archil-scoped +165 -0
  6. package/dist/app.d.ts +40 -0
  7. package/dist/app.js +47 -0
  8. package/dist/claim.d.ts +222 -0
  9. package/dist/claim.js +722 -0
  10. package/dist/cli.d.ts +64 -0
  11. package/dist/cli.js +664 -0
  12. package/dist/commands.d.ts +67 -0
  13. package/dist/commands.js +299 -0
  14. package/dist/confine.d.ts +91 -0
  15. package/dist/confine.js +341 -0
  16. package/dist/env.d.ts +129 -0
  17. package/dist/env.js +608 -0
  18. package/dist/errors.d.ts +47 -0
  19. package/dist/errors.js +55 -0
  20. package/dist/fork.d.ts +41 -0
  21. package/dist/fork.js +181 -0
  22. package/dist/hosts/daytona-launch.d.ts +52 -0
  23. package/dist/hosts/daytona-launch.js +270 -0
  24. package/dist/hosts/daytona.d.ts +147 -0
  25. package/dist/hosts/daytona.js +368 -0
  26. package/dist/hosts/docker.d.ts +103 -0
  27. package/dist/hosts/docker.js +362 -0
  28. package/dist/hosts/local-host.d.ts +71 -0
  29. package/dist/hosts/local-host.js +356 -0
  30. package/dist/index.d.ts +16 -0
  31. package/dist/index.js +17 -0
  32. package/dist/lease.d.ts +7 -0
  33. package/dist/lease.js +6 -0
  34. package/dist/local-run.d.ts +10 -0
  35. package/dist/local-run.js +63 -0
  36. package/dist/park.d.ts +92 -0
  37. package/dist/park.js +229 -0
  38. package/dist/run.d.ts +290 -0
  39. package/dist/run.js +785 -0
  40. package/dist/serve.d.ts +101 -0
  41. package/dist/serve.js +393 -0
  42. package/dist/status.d.ts +123 -0
  43. package/dist/status.js +264 -0
  44. package/dist/store.d.ts +103 -0
  45. package/dist/store.js +217 -0
  46. package/dist/supervise.d.ts +340 -0
  47. package/dist/supervise.js +528 -0
  48. package/dist/watchdog.d.ts +39 -0
  49. package/dist/watchdog.js +137 -0
  50. package/package.json +87 -4
@@ -0,0 +1,147 @@
1
+ import { PdaError } from "../errors.js";
2
+ import type { HostDriver, HostStatus } from "../supervise.js";
3
+ export declare const DAYTONA_API_URL = "https://app.daytona.io/api";
4
+ export declare const LABEL_FLEET = "pda-fleet";
5
+ export declare const LABEL_RUN = "pda-run";
6
+ export type SandboxInfo = {
7
+ id: string;
8
+ name: string;
9
+ state?: string;
10
+ snapshot?: string;
11
+ labels?: Record<string, string>;
12
+ target?: string;
13
+ toolboxProxyUrl?: string;
14
+ errorReason?: string;
15
+ };
16
+ export type CreateSandboxBody = {
17
+ name: string;
18
+ snapshot: string;
19
+ target?: string;
20
+ labels: Record<string, string>;
21
+ autoStopInterval: number;
22
+ autoDeleteInterval: number;
23
+ ttlMinutes?: number;
24
+ networkBlockAll?: boolean;
25
+ networkAllowList?: string;
26
+ };
27
+ export type ExecResult = {
28
+ exitCode: number;
29
+ result: string;
30
+ };
31
+ export interface DaytonaClient {
32
+ create(body: CreateSandboxBody): Promise<SandboxInfo>;
33
+ /** By id or name; null when it does not exist. */
34
+ get(idOrName: string): Promise<SandboxInfo | null>;
35
+ list(labels: Record<string, string>): Promise<SandboxInfo[]>;
36
+ /** `force`: SIGKILL instead of SIGTERM. A sandbox that does not exist is not an error. */
37
+ stop(id: string, force: boolean): Promise<void>;
38
+ /** A sandbox that does not exist is not an error. */
39
+ remove(id: string): Promise<void>;
40
+ /** A shell script run by the toolbox's user; stdout and stderr combined. */
41
+ exec(box: SandboxInfo, command: string, timeoutSec: number): Promise<ExecResult>;
42
+ upload(box: SandboxInfo, path: string, content: Uint8Array): Promise<void>;
43
+ }
44
+ export declare class DaytonaApiError extends PdaError {
45
+ /** The HTTP status, null when no response arrived (network failure or timeout). */
46
+ readonly status: number | null;
47
+ /** Worth one more attempt: no response, 429 or a 5xx. */
48
+ readonly retryable: boolean;
49
+ constructor(status: number | null, message: string, options?: {
50
+ cause?: unknown;
51
+ });
52
+ }
53
+ export interface DaytonaRestOptions {
54
+ apiKey: string;
55
+ /** Default https://app.daytona.io/api. */
56
+ apiUrl?: string;
57
+ fetch?: typeof fetch;
58
+ /** Per request, for calls that take no explicit timeout. Default 30 s. */
59
+ timeoutMs?: number;
60
+ }
61
+ /** The REST API with an API key (Bearer). Error messages carry the method, path and status, never a header or a body sent. */
62
+ export declare function daytonaRest(options: DaytonaRestOptions): DaytonaClient;
63
+ export type DaytonaHostErrorCode = "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DAYTONA_API_FAILED";
64
+ export declare class DaytonaHostError extends PdaError {
65
+ constructor(code: DaytonaHostErrorCode, message: string, options?: {
66
+ cause?: unknown;
67
+ });
68
+ }
69
+ export interface DaytonaHostOptions {
70
+ /** The API client; the supervisor builds it with its own key (`daytonaRest`). */
71
+ client: DaytonaClient;
72
+ /** The snapshot every box starts from (its resources come with it). */
73
+ snapshot: string;
74
+ /** The region (`us`, `eu`, a custom region); default the organization's. */
75
+ target?: string;
76
+ /** Names the boxes this driver owns, in their label and in every handle; a handle of another fleet is not its own. */
77
+ fleet?: string;
78
+ /** Box names are `<namePrefix><run id>-<stamp>`. Default "pda-". */
79
+ namePrefix?: string;
80
+ /** Extra labels on every box. */
81
+ labels?: Record<string, string>;
82
+ /** In the box; the same on every host of a deployment. Default /mnt/archil. */
83
+ mountRoot?: string;
84
+ /** Node in the box. Default /usr/local/bin/node. */
85
+ node?: string;
86
+ /** This package, installed (built), in the box. Default /usr/local/lib/pi-durable-disk. */
87
+ packageDir?: string;
88
+ /** The in-box launcher. Default `<packageDir>/dist/hosts/daytona-launch.js`. */
89
+ launcher?: string;
90
+ /** The archil wrapper in the box, root-owned. Default /usr/local/sbin/archil-scoped. */
91
+ archil?: string;
92
+ /** The unprivileged user and group the instance and its tools run as in the box. Default "pda". */
93
+ user?: string;
94
+ group?: string;
95
+ /** The instance program in the box; `run` and its flags are appended. Default: `node` and `<packageDir>/dist/cli.js`. */
96
+ command?: string[];
97
+ /** Extra flags for `run` (an app module, lease periods). */
98
+ runArgs?: string[];
99
+ /** Extra environment for the instance. The driver never copies its own environment (which holds the API keys). */
100
+ env?: Record<string, string>;
101
+ /** Restart in place after a non-terminal exit (default true). */
102
+ restart?: boolean;
103
+ /** Daytona destroys every box this long after creation, whatever its state. Default 25 h (a mount token lives 24 h). */
104
+ ttlMinutes?: number;
105
+ /** Sandbox egress. Default: the organization's (full egress on Tier 3 and 4, which `archil mount` needs). */
106
+ network?: {
107
+ blockAll?: boolean;
108
+ allowList?: string;
109
+ };
110
+ /** The launcher's root-only directory in the box. Default /run/pda. */
111
+ boxDir?: string;
112
+ /** Where the toolbox's user uploads (its own, 0700) before the launcher moves the files into `boxDir`. Default /tmp/pda-stage. */
113
+ stageDir?: string;
114
+ /** Run the launcher through `sudo -n` (default true: a default Daytona image's toolbox user is `daytona`, with sudo). */
115
+ sudo?: boolean;
116
+ /** From the create call to the instance spawned. Default 180 s. */
117
+ startTimeoutMs?: number;
118
+ /** How long `stop` lets the instance drain before it deletes the box. Default 30 s. */
119
+ stopTimeoutMs?: number;
120
+ /** Runs once the box is started and before the launcher: installs what the snapshot lacks (a default image). */
121
+ prepare?: (box: SandboxInfo, client: DaytonaClient) => Promise<void>;
122
+ /** Poll period while waiting on a box. Default 1 s. */
123
+ pollMs?: number;
124
+ /** Create attempts on a retryable failure (each first looks the name up, in case the last one landed). Default 3. */
125
+ createAttempts?: number;
126
+ now?: () => number;
127
+ }
128
+ /**
129
+ * A sandbox state as a host status; `started` needs the box's own word (`check`). Transitions either way and `paused` (a
130
+ * frozen VM still holding its claim) are running, so the lease decides and STONITH stops them; deletion under way is
131
+ * stopped, deleted is gone.
132
+ */
133
+ export declare function sandboxStatus(state: string | undefined): HostStatus | "check";
134
+ /** A Daytona-safe box name for a run: lowercase, at most 63 characters, unique per call. */
135
+ export declare function sandboxName(prefix: string, runId: string, now: number): string;
136
+ export declare function daytonaHost(opts: DaytonaHostOptions): HostDriver & {
137
+ readonly fleet: string;
138
+ readonly mountRoot: string;
139
+ };
140
+ /** Delete every box of a fleet (the janitor after a crash of whoever started them); returns what it asked to delete. */
141
+ export declare function sweepSandboxes(client: DaytonaClient, labels: Record<string, string>): Promise<{
142
+ deleted: string[];
143
+ failed: {
144
+ id: string;
145
+ error: string;
146
+ }[];
147
+ }>;
@@ -0,0 +1,368 @@
1
+ // daytonaHost: one Daytona sandbox per instance, created from a snapshot that holds Node, the archil client
2
+ // (libfuse2), this package, the run user and the sudoers drop-in for `archil-scoped` and `fusermount -u` (or from a
3
+ // default image the caller's `prepare` installs them into). The toolbox's user is root or has passwordless sudo (a
4
+ // default Daytona image's `daytona` user). The box runs `daytona-launch.ts`, which keeps the instance alive as the run
5
+ // user (the in-box stand-in for systemd: restart in place after a non-terminal exit, never after 0, 65, 70, 75 or 76).
6
+ //
7
+ // The mount token never travels in argv, an environment variable, a toolbox command string or the sandbox's env (which
8
+ // Daytona stores and returns on every read): the driver creates a 0700 staging directory of the toolbox's user, uploads
9
+ // the token into it, and the launcher (root) moves it into its root-only directory, reads it, unlinks it and hands it to
10
+ // the first incarnation on a pipe to its stdin.
11
+ //
12
+ // Every sandbox is labeled with its fleet and run, deletes itself when it stops (`autoDeleteInterval: 0`) and is destroyed
13
+ // by Daytona after `ttlMinutes` whatever its state, so a start that fails half way, or a driver that dies, leaks nothing
14
+ // past that bound. `stop` drains the instance (bounded) and deletes the box, which removes its mounts and every process
15
+ // in it, the commands a crashed incarnation detached included. The supervisor holds the API key; boxes never do.
16
+ import { DEFAULT_MOUNT_ROOT, runPath } from "../claim.js";
17
+ import { PdaError } from "../errors.js";
18
+ import { LAUNCH_DIR } from "./daytona-launch.js";
19
+ import { TERMINAL_EXITS } from "./local-host.js";
20
+ export const DAYTONA_API_URL = "https://app.daytona.io/api";
21
+ export const LABEL_FLEET = "pda-fleet";
22
+ export const LABEL_RUN = "pda-run";
23
+ export class DaytonaApiError extends PdaError {
24
+ /** The HTTP status, null when no response arrived (network failure or timeout). */
25
+ status;
26
+ /** Worth one more attempt: no response, 429 or a 5xx. */
27
+ retryable;
28
+ constructor(status, message, options = {}) {
29
+ super("DAYTONA_API_FAILED", message, options);
30
+ this.status = status;
31
+ this.retryable = status === null || status === 429 || status >= 500;
32
+ }
33
+ }
34
+ /** The REST API with an API key (Bearer). Error messages carry the method, path and status, never a header or a body sent. */
35
+ export function daytonaRest(options) {
36
+ if (!options.apiKey)
37
+ throw new DaytonaHostError("INVALID_ARGUMENT", "a Daytona API key is required");
38
+ const base = (options.apiUrl ?? DAYTONA_API_URL).replace(/\/+$/, "");
39
+ const doFetch = options.fetch ?? fetch;
40
+ const auth = { Authorization: `Bearer ${options.apiKey}`, "X-Daytona-Source": "pi-durable-disk" };
41
+ const proxies = new Map();
42
+ async function call(method, url, init = {}) {
43
+ const what = `${method} ${new URL(url).pathname}`;
44
+ let res;
45
+ try {
46
+ res = await doFetch(url, {
47
+ method,
48
+ headers: init.json === undefined ? auth : { ...auth, "Content-Type": "application/json" },
49
+ body: init.json !== undefined ? JSON.stringify(init.json) : init.form,
50
+ signal: AbortSignal.timeout(init.timeoutMs ?? options.timeoutMs ?? 30_000),
51
+ });
52
+ }
53
+ catch (err) {
54
+ throw new DaytonaApiError(null, `${what}: ${err.message}`, { cause: err });
55
+ }
56
+ const text = await res.text().catch(() => "");
57
+ if (res.status === 404 && init.missingOk)
58
+ return null;
59
+ if (!res.ok)
60
+ throw new DaytonaApiError(res.status, `${what}: ${res.status} ${text.replace(/\s+/g, " ").slice(0, 300)}`);
61
+ if (!text)
62
+ return null;
63
+ try {
64
+ return JSON.parse(text);
65
+ }
66
+ catch (err) {
67
+ throw new DaytonaApiError(res.status, `${what}: the answer is not JSON`, { cause: err });
68
+ }
69
+ }
70
+ const sandboxUrl = (idOrName, rest = "") => `${base}/sandbox/${encodeURIComponent(idOrName)}${rest}`;
71
+ async function toolbox(box) {
72
+ let proxy = box.toolboxProxyUrl ?? proxies.get(box.id);
73
+ if (!proxy) {
74
+ const r = (await call("GET", sandboxUrl(box.id, "/toolbox-proxy-url")));
75
+ if (!r?.url)
76
+ throw new DaytonaApiError(200, `GET toolbox-proxy-url for ${box.id}: no url in the answer`);
77
+ proxy = r.url;
78
+ proxies.set(box.id, proxy);
79
+ }
80
+ return `${proxy.replace(/\/+$/, "")}/${encodeURIComponent(box.id)}`;
81
+ }
82
+ return {
83
+ async create(body) {
84
+ return (await call("POST", `${base}/sandbox`, { json: body, timeoutMs: 120_000 }));
85
+ },
86
+ async get(idOrName) {
87
+ return (await call("GET", sandboxUrl(idOrName), { missingOk: true }));
88
+ },
89
+ async list(labels) {
90
+ const out = [];
91
+ let cursor = null;
92
+ do {
93
+ const q = new URLSearchParams({ labels: JSON.stringify(labels), limit: "100", ...(cursor ? { cursor } : {}) });
94
+ const page = (await call("GET", `${base}/sandbox?${q}`));
95
+ if (Array.isArray(page))
96
+ return [...out, ...page];
97
+ out.push(...(page?.items ?? []));
98
+ cursor = page?.nextCursor ?? null;
99
+ } while (cursor);
100
+ return out;
101
+ },
102
+ async stop(id, force) {
103
+ await call("POST", sandboxUrl(id, `/stop${force ? "?force=true" : ""}`), { missingOk: true, timeoutMs: 120_000 });
104
+ },
105
+ async remove(id) {
106
+ await call("DELETE", sandboxUrl(id), { missingOk: true, timeoutMs: 120_000 });
107
+ },
108
+ async exec(box, command, timeoutSec) {
109
+ const r = (await call("POST", `${await toolbox(box)}/process/execute`, { json: { command, timeout: timeoutSec }, timeoutMs: (timeoutSec + 15) * 1000 }));
110
+ return { exitCode: typeof r?.exitCode === "number" ? r.exitCode : -1, result: typeof r?.result === "string" ? r.result : "" };
111
+ },
112
+ async upload(box, path, content) {
113
+ const form = new FormData();
114
+ form.append("file", new Blob([new Uint8Array(content)]), "file");
115
+ await call("POST", `${await toolbox(box)}/files/upload-v2?${new URLSearchParams({ path })}`, { form });
116
+ },
117
+ };
118
+ }
119
+ export class DaytonaHostError extends PdaError {
120
+ constructor(code, message, options = {}) {
121
+ super(code, message, options);
122
+ }
123
+ }
124
+ const BOX_PATH = /^\/[A-Za-z0-9._/-]+$/;
125
+ const ACCOUNT = /^(?:[a-z_][a-z0-9_-]{0,31}|\d{1,10})$/;
126
+ const FLEET = /^[a-z0-9][a-z0-9-]{0,62}$/;
127
+ const UP = new Set(["creating", "pulling_snapshot", "pending_build", "building_snapshot", "starting", "restoring", "resuming", "resizing", "snapshotting", "forking", "stopping", "pausing", "paused"]);
128
+ const DOWN = new Set(["stopped", "archived", "archiving", "destroying"]);
129
+ const BROKEN = new Set(["error", "build_failed"]);
130
+ /**
131
+ * A sandbox state as a host status; `started` needs the box's own word (`check`). Transitions either way and `paused` (a
132
+ * frozen VM still holding its claim) are running, so the lease decides and STONITH stops them; deletion under way is
133
+ * stopped, deleted is gone.
134
+ */
135
+ export function sandboxStatus(state) {
136
+ if (state === "started")
137
+ return "check";
138
+ if (state === "destroyed")
139
+ return "gone";
140
+ if (state !== undefined && UP.has(state))
141
+ return "running";
142
+ if (state !== undefined && DOWN.has(state))
143
+ return "stopped";
144
+ if (state !== undefined && BROKEN.has(state))
145
+ return "failed";
146
+ return "unknown";
147
+ }
148
+ /** A Daytona-safe box name for a run: lowercase, at most 63 characters, unique per call. */
149
+ export function sandboxName(prefix, runId, now) {
150
+ const slug = runId.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "run";
151
+ const stamp = `${now.toString(36)}${Math.floor(Math.random() * 1296).toString(36).padStart(2, "0")}`;
152
+ return `${prefix}${slug}-${stamp}`.slice(0, 63);
153
+ }
154
+ /** The last line of a command's output that parses as JSON, or null. */
155
+ function lastJson(text) {
156
+ for (const line of text.trim().split("\n").reverse()) {
157
+ try {
158
+ return JSON.parse(line);
159
+ }
160
+ catch {
161
+ // not this line
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
167
+ export function daytonaHost(opts) {
168
+ const client = opts.client;
169
+ const fleet = opts.fleet ?? "pda";
170
+ const prefix = opts.namePrefix ?? "pda-";
171
+ const mountRoot = opts.mountRoot ?? DEFAULT_MOUNT_ROOT;
172
+ const node = opts.node ?? "/usr/local/bin/node";
173
+ const packageDir = opts.packageDir ?? "/usr/local/lib/pi-durable-disk";
174
+ const archil = opts.archil ?? "/usr/local/sbin/archil-scoped";
175
+ const user = opts.user ?? "pda";
176
+ const group = opts.group ?? user;
177
+ const dir = opts.boxDir ?? LAUNCH_DIR;
178
+ const stage = opts.stageDir ?? "/tmp/pda-stage";
179
+ const S = opts.sudo === false ? "" : "sudo -n ";
180
+ const ttlMinutes = opts.ttlMinutes ?? 25 * 60;
181
+ const startTimeoutMs = opts.startTimeoutMs ?? 180_000;
182
+ const stopTimeoutMs = opts.stopTimeoutMs ?? 30_000;
183
+ const pollMs = opts.pollMs ?? 1_000;
184
+ const createAttempts = opts.createAttempts ?? 3;
185
+ const now = opts.now ?? Date.now;
186
+ const launch = opts.launcher ?? `${packageDir}/dist/hosts/daytona-launch.js`;
187
+ for (const [what, value] of Object.entries({ mountRoot, node, packageDir, archil, boxDir: dir, stageDir: stage, launcher: launch })) {
188
+ if (!BOX_PATH.test(value))
189
+ throw new DaytonaHostError("INVALID_ARGUMENT", `${what} ${JSON.stringify(value)} is not a plain absolute path`);
190
+ }
191
+ if (!ACCOUNT.test(user) || !ACCOUNT.test(group))
192
+ throw new DaytonaHostError("INVALID_ARGUMENT", "user and group are account names or numeric ids");
193
+ if (user === "root" || user === "0")
194
+ throw new DaytonaHostError("INVALID_ARGUMENT", "the instance and the agent's tools never run as root");
195
+ if (!FLEET.test(fleet))
196
+ throw new DaytonaHostError("INVALID_ARGUMENT", `fleet ${JSON.stringify(fleet)} is not a lowercase label value`);
197
+ if (!opts.snapshot)
198
+ throw new DaytonaHostError("INVALID_ARGUMENT", "a snapshot is required");
199
+ const command = opts.command ?? [node, `${packageDir}/dist/cli.js`];
200
+ const mine = (h) => h.driver === "daytona" && h.fleet === fleet && typeof h.sandboxId === "string" && typeof h.name === "string";
201
+ const ours = (box, runId) => box.labels?.[LABEL_FLEET] === fleet && box.labels?.[LABEL_RUN] === runId;
202
+ const launcher = (verb, name, timeoutMs) => `${S}${node} ${launch} ${verb} ${name} --dir ${dir}${verb === "start" ? ` --stage ${stage}` : ""}${timeoutMs === undefined ? "" : ` --timeout-ms ${timeoutMs}`}`;
203
+ async function createOrAdopt(body, runId) {
204
+ for (let attempt = 1;; attempt++) {
205
+ try {
206
+ return await client.create(body);
207
+ }
208
+ catch (err) {
209
+ if (err instanceof DaytonaApiError && err.status === 409) {
210
+ // The name is unique per organization and this one is fresh, so a holder is this start's own earlier attempt.
211
+ const box = await client.get(body.name);
212
+ if (box && ours(box, runId))
213
+ return box;
214
+ throw new DaytonaHostError("START_FAILED", `a sandbox named ${body.name} exists and is not this start's`, { cause: err });
215
+ }
216
+ if (!(err instanceof DaytonaApiError && err.retryable) || attempt >= createAttempts) {
217
+ throw new DaytonaHostError("START_FAILED", `creating sandbox ${body.name} failed: ${err.message}`, { cause: err });
218
+ }
219
+ const landed = await client.get(body.name).catch(() => null);
220
+ if (landed && ours(landed, runId))
221
+ return landed;
222
+ await sleep(Math.min(pollMs * 2 ** attempt, 10_000));
223
+ }
224
+ }
225
+ }
226
+ async function waitStarted(box, deadline) {
227
+ for (let current = box;;) {
228
+ if (current?.state === "started")
229
+ return current;
230
+ const s = current ? sandboxStatus(current.state) : "gone";
231
+ if (s === "gone" || s === "failed" || current?.state === "destroying") {
232
+ throw new DaytonaHostError("START_FAILED", `sandbox ${box.name} went to ${current?.state ?? "deleted"} while starting${current?.errorReason ? `: ${current.errorReason}` : ""}`);
233
+ }
234
+ if (now() >= deadline)
235
+ throw new DaytonaHostError("START_FAILED", `sandbox ${box.name} did not start in ${startTimeoutMs} ms (last state ${current?.state})`);
236
+ await sleep(pollMs);
237
+ current = await client.get(box.id);
238
+ }
239
+ }
240
+ async function run(box, command, timeoutSec, what) {
241
+ const r = await client.exec(box, command, timeoutSec);
242
+ if (r.exitCode !== 0)
243
+ throw new DaytonaHostError("START_FAILED", `${what} in ${box.name} exited ${r.exitCode}: ${r.result.trim().split("\n").at(-1)?.slice(0, 300) ?? ""}`);
244
+ return r;
245
+ }
246
+ return {
247
+ fleet,
248
+ mountRoot,
249
+ async start(ref, mountToken) {
250
+ runPath(ref.id);
251
+ if (!mountToken)
252
+ throw new DaytonaHostError("INVALID_ARGUMENT", "no mount token");
253
+ const t0 = now();
254
+ const name = sandboxName(prefix, ref.id, t0);
255
+ const body = {
256
+ name,
257
+ snapshot: opts.snapshot,
258
+ ...(opts.target ? { target: opts.target } : {}),
259
+ labels: { ...opts.labels, [LABEL_FLEET]: fleet, [LABEL_RUN]: ref.id },
260
+ autoStopInterval: 0,
261
+ autoDeleteInterval: 0,
262
+ ttlMinutes,
263
+ ...(opts.network?.blockAll ? { networkBlockAll: true } : {}),
264
+ ...(opts.network?.allowList ? { networkAllowList: opts.network.allowList } : {}),
265
+ };
266
+ let box = await createOrAdopt(body, ref.id);
267
+ try {
268
+ box = await waitStarted(box, t0 + startTimeoutMs);
269
+ const prepared = now();
270
+ if (opts.prepare)
271
+ await opts.prepare(box, client);
272
+ const prepareMs = now() - prepared;
273
+ const handle = { driver: "daytona", fleet, target: box.target ?? opts.target ?? null, sandboxId: box.id, name, mountpoint: `${mountRoot}/${runPath(ref.id)}` };
274
+ const spec = {
275
+ argv: [...command, "run", "--disk", ref.disk, "--region", ref.region, "--id", ref.id, "--mount-root", mountRoot, "--archil", archil, ...(opts.runArgs ?? []), "--token-stdin"],
276
+ env: { ...opts.env, PDA_HOLDER: JSON.stringify(handle) },
277
+ user,
278
+ group,
279
+ restart: opts.restart !== false,
280
+ terminalExits: [...TERMINAL_EXITS],
281
+ };
282
+ // Both directories first (an upload creates missing parents with the toolbox's default mode): the staging one the
283
+ // toolbox's user's own and 0700, so no other user (the run user above all) can have made it or read from it.
284
+ await run(box, `umask 077 && mkdir -p ${stage} && chmod 700 ${stage} && [ "$(stat -c %u ${stage})" = "$(id -u)" ] && ${S}mkdir -p ${dir} && ${S}chmod 700 ${dir}`, 30, "preparing the launch directories");
285
+ await client.upload(box, `${stage}/${name}.json`, new TextEncoder().encode(JSON.stringify(spec)));
286
+ await client.upload(box, `${stage}/${name}.token`, new TextEncoder().encode(`${mountToken}\n`));
287
+ const left = Math.max(5_000, t0 + prepareMs + startTimeoutMs - now());
288
+ const r = await run(box, launcher("start", name, left), Math.ceil(left / 1000) + 10, "starting the instance");
289
+ const state = lastJson(r.result);
290
+ if (!state || !(state.spawned > 0)) {
291
+ throw new DaytonaHostError("START_FAILED", `the instance in ${name} did not start: ${state?.reason ?? r.result.trim().slice(0, 300)}`);
292
+ }
293
+ return handle;
294
+ }
295
+ catch (err) {
296
+ await client.remove(box.id).catch(() => { });
297
+ if (err instanceof DaytonaHostError)
298
+ throw err;
299
+ throw new DaytonaHostError("START_FAILED", `starting an instance in ${name} failed: ${err.message}`, { cause: err });
300
+ }
301
+ },
302
+ async status(h) {
303
+ if (!mine(h))
304
+ return "unknown";
305
+ let box;
306
+ try {
307
+ box = await client.get(h.sandboxId);
308
+ }
309
+ catch (err) {
310
+ throw new DaytonaHostError("DAYTONA_API_FAILED", `reading sandbox ${h.name}: ${err.message}`, { cause: err });
311
+ }
312
+ if (!box)
313
+ return "gone";
314
+ // The label, not the id in a handle, decides whose box this is: a handle can never reach another fleet's box.
315
+ if (box.labels?.[LABEL_FLEET] !== fleet)
316
+ return "unknown";
317
+ const s = sandboxStatus(box.state);
318
+ if (s !== "check")
319
+ return s;
320
+ // A box we cannot ask is still a live box holding whatever it held: running, so STONITH stops it.
321
+ const r = await client.exec(box, launcher("status", h.name), 20).catch(() => null);
322
+ const word = r && r.exitCode === 0 ? lastJson(r.result)?.status : undefined;
323
+ return word === "stopped" || word === "failed" ? word : "running";
324
+ },
325
+ async stop(h) {
326
+ if (!mine(h))
327
+ return;
328
+ const id = h.sandboxId;
329
+ let box;
330
+ try {
331
+ box = await client.get(id);
332
+ }
333
+ catch (err) {
334
+ throw new DaytonaHostError("STOP_FAILED", `reading sandbox ${h.name} to confirm it is this fleet's: ${err.message}`, { cause: err });
335
+ }
336
+ if (box === null || box.labels?.[LABEL_FLEET] !== fleet)
337
+ return;
338
+ if (box.state === "started") {
339
+ await client.exec(box, launcher("stop", h.name, stopTimeoutMs), Math.ceil(stopTimeoutMs / 1000) + 10).catch(() => null);
340
+ }
341
+ try {
342
+ await client.remove(id);
343
+ }
344
+ catch (first) {
345
+ // A delete refused mid-transition: power the box off (SIGKILL), then delete again.
346
+ await client.stop(id, true).catch(() => { });
347
+ try {
348
+ await client.remove(id);
349
+ }
350
+ catch (err) {
351
+ throw new DaytonaHostError("STOP_FAILED", `deleting sandbox ${h.name} failed: ${err.message}`, { cause: first });
352
+ }
353
+ }
354
+ },
355
+ };
356
+ }
357
+ /** Delete every box of a fleet (the janitor after a crash of whoever started them); returns what it asked to delete. */
358
+ export async function sweepSandboxes(client, labels) {
359
+ if (!labels[LABEL_FLEET])
360
+ throw new DaytonaHostError("INVALID_ARGUMENT", `a sweep needs the ${LABEL_FLEET} label`);
361
+ const out = { deleted: [], failed: [] };
362
+ for (const box of await client.list(labels)) {
363
+ if (box.labels?.[LABEL_FLEET] !== labels[LABEL_FLEET])
364
+ continue;
365
+ await client.remove(box.id).then(() => out.deleted.push(box.id), (e) => out.failed.push({ id: box.id, error: e.message }));
366
+ }
367
+ return out;
368
+ }
@@ -0,0 +1,103 @@
1
+ import { type RunRef } from "../claim.js";
2
+ import { PdaError } from "../errors.js";
3
+ import type { HostDriver, HostHandle, HostStatus } from "../supervise.js";
4
+ export type DockerRan = {
5
+ code: number | null;
6
+ timedOut: boolean;
7
+ stdout: string;
8
+ stderr: string;
9
+ };
10
+ /** Runs one docker CLI command; replaceable so tests can drive a fake daemon. */
11
+ export type DockerRunner = (argv: string[], opts?: {
12
+ input?: string | Uint8Array;
13
+ timeoutMs?: number;
14
+ }) => Promise<DockerRan>;
15
+ export interface DockerHostOptions {
16
+ /** The image every container runs (docker/Dockerfile or one built FROM it). Required to start. */
17
+ image?: string;
18
+ /** The docker CLI. Default `docker` on the PATH; it talks to whatever daemon its context or DOCKER_HOST names. */
19
+ docker?: string;
20
+ /** Labels every container `pda.fleet=<fleet>`; a handle of another fleet is not this driver's. Default "default". */
21
+ fleet?: string;
22
+ /** Container names are `<namePrefix><run id>-<disk key>-g<attempt>`. Default "pda-". */
23
+ namePrefix?: string;
24
+ /** Where runs mount inside every container (the same on every host). Default /mnt/archil. */
25
+ mountRoot?: string;
26
+ /** The app module on this machine (`run --app`); its directory, or `appRoot`, is bind-mounted read-only. */
27
+ app?: string;
28
+ /** The directory mounted at /opt/pda/app (default: the app's own). Its node_modules is hidden: imports resolve to the image's. */
29
+ appRoot?: string;
30
+ /** Extra flags for `run` (heartbeat and lease periods, `--on-sigterm`). */
31
+ runArgs?: string[];
32
+ /** The user the agent's commands run as inside the container. Default "pda" (uid 1500 in the image). */
33
+ runAs?: string;
34
+ /** Environment for the instance. Visible to `docker inspect`, so never a secret; the driver never copies its own. */
35
+ env?: Record<string, string>;
36
+ /** Extra `docker create` flags (resource limits, `--add-host`, a network). A restart policy other than `no` is refused. */
37
+ dockerArgs?: string[];
38
+ /**
39
+ * The AppArmor profile, false to pass none, or "auto" (the default): `apparmor=unconfined` only where the Docker daemon
40
+ * applies AppArmor (`docker info` lists it in its security options). There Docker's default profile denies mount(2),
41
+ * so `archil mount` fails with "Permission denied" under it. Where the daemon applies none (Docker Desktop and OrbStack
42
+ * on a Mac), no option is passed.
43
+ */
44
+ apparmor?: "auto" | string | false;
45
+ /** Called once with each decision the driver makes about the host (the AppArmor option and why). */
46
+ note?: (line: string) => void;
47
+ /**
48
+ * `docker stop -t`: SIGTERM, then SIGKILL after this. Default 30 s. The instance drains for this long minus a close
49
+ * reserve (half of it, at most 5 s) before it releases.
50
+ */
51
+ stopTimeoutMs?: number;
52
+ /**
53
+ * The instance parks a run whose live work only waits longer than this: it releases the run and exits 0, and the
54
+ * supervisor starts a new container at the wake. It must be at least twice a container's start (about 2 s to opened).
55
+ * Default 60 s, as for localHost. null or 0: never park, the instance stays up through every wait.
56
+ */
57
+ parkThresholdMs?: number | null;
58
+ exec?: DockerRunner;
59
+ }
60
+ export declare class DockerHostError extends PdaError {
61
+ constructor(code: "INVALID_ARGUMENT" | "START_FAILED" | "STOP_FAILED" | "DOCKER_FAILED", message: string, options?: {
62
+ cause?: unknown;
63
+ });
64
+ }
65
+ /** dockerHost's park threshold: localHost's, far above twice a container's start. */
66
+ export declare const DOCKER_PARK_THRESHOLD_MS = 60000;
67
+ /** Eight hex digits of the run's disk and region: part of each container's name, so equal run ids on two disks differ. */
68
+ export declare function diskKey(ref: Pick<RunRef, "disk" | "region">): string;
69
+ /** The docker CLI with the caller's PATH (Docker Desktop installs outside the system paths) and HOME (its contexts). */
70
+ export declare const runDocker: DockerRunner;
71
+ /** One regular file in a POSIX tar stream, owned by root: what `docker cp -` extracts into the container. */
72
+ export declare function tarOneFile(name: string, data: Uint8Array, mode?: number): Uint8Array;
73
+ type DockerState = {
74
+ Status?: string;
75
+ ExitCode?: number;
76
+ OOMKilled?: boolean;
77
+ Error?: string;
78
+ StartedAt?: string;
79
+ FinishedAt?: string;
80
+ Running?: boolean;
81
+ Paused?: boolean;
82
+ };
83
+ /**
84
+ * A container's state as a host status. Paused is a frozen host that still holds its claim: running, so the lease decides,
85
+ * and STONITH stops it (`docker stop` works on a paused container). Exit 0 (a drain) is stopped; any other exit, the
86
+ * terminal 65, 70, 75 and 76 included, is failed. Created (never started) is stopped.
87
+ */
88
+ export declare function containerStatus(state: DockerState): HostStatus;
89
+ export type ContainerInfo = {
90
+ status: HostStatus;
91
+ state: string;
92
+ exitCode: number | null;
93
+ oomKilled: boolean;
94
+ error: string;
95
+ startedAt: string | null;
96
+ finishedAt: string | null;
97
+ };
98
+ export declare function dockerHost(opts?: DockerHostOptions): HostDriver & {
99
+ readonly fleet: string;
100
+ readonly mountRoot: string;
101
+ describe(handle: HostHandle): Promise<ContainerInfo | null>;
102
+ };
103
+ export {};