@kosa8/sdk 0.0.0-stage → 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/LICENSE +26 -0
- package/README.md +182 -2
- package/dist/index.cjs +1052 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +798 -0
- package/dist/index.d.ts +798 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
// src/checkpoints.ts
|
|
2
|
+
import { promises as fs } from "fs";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
var Kosa8Error = class extends Error {
|
|
8
|
+
/** HTTP status the daemon answered with, when there was an answer. */
|
|
9
|
+
status;
|
|
10
|
+
/** The daemon's `error` text, verbatim. */
|
|
11
|
+
daemonMessage;
|
|
12
|
+
/** The daemon's `remediation` text: what to do about it. */
|
|
13
|
+
remediation;
|
|
14
|
+
method;
|
|
15
|
+
path;
|
|
16
|
+
/** The decoded response body, when there was one. */
|
|
17
|
+
body;
|
|
18
|
+
constructor(message, options = {}) {
|
|
19
|
+
super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
|
|
20
|
+
this.name = new.target.name;
|
|
21
|
+
this.status = options.status;
|
|
22
|
+
this.daemonMessage = options.daemonMessage;
|
|
23
|
+
this.remediation = options.remediation;
|
|
24
|
+
this.method = options.method;
|
|
25
|
+
this.path = options.path;
|
|
26
|
+
this.body = options.body;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
var Kosa8ConnectionError = class extends Kosa8Error {
|
|
30
|
+
};
|
|
31
|
+
var Kosa8ApiError = class extends Kosa8Error {
|
|
32
|
+
};
|
|
33
|
+
var Kosa8BadRequestError = class extends Kosa8ApiError {
|
|
34
|
+
};
|
|
35
|
+
var Kosa8AuthError = class extends Kosa8ApiError {
|
|
36
|
+
};
|
|
37
|
+
var Kosa8PolicyError = class extends Kosa8ApiError {
|
|
38
|
+
};
|
|
39
|
+
var Kosa8ApprovalRequiredError = class extends Kosa8PolicyError {
|
|
40
|
+
approval;
|
|
41
|
+
constructor(message, approval, options = {}) {
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.approval = approval;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
var Kosa8NotFoundError = class extends Kosa8ApiError {
|
|
47
|
+
};
|
|
48
|
+
var Kosa8ConflictError = class extends Kosa8ApiError {
|
|
49
|
+
};
|
|
50
|
+
var Kosa8UnsupportedError = class extends Kosa8ApiError {
|
|
51
|
+
};
|
|
52
|
+
var Kosa8StreamError = class extends Kosa8Error {
|
|
53
|
+
stdout;
|
|
54
|
+
stderr;
|
|
55
|
+
constructor(message, out, options = {}) {
|
|
56
|
+
super(message, options);
|
|
57
|
+
this.stdout = out.stdout;
|
|
58
|
+
this.stderr = out.stderr;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
var Kosa8CheckpointError = class extends Kosa8Error {
|
|
62
|
+
};
|
|
63
|
+
function isRefusal(v) {
|
|
64
|
+
return typeof v === "object" && v !== null && typeof v.why === "string";
|
|
65
|
+
}
|
|
66
|
+
function errorFromResponse(status, body, ctx = {}) {
|
|
67
|
+
let daemonMessage;
|
|
68
|
+
let remediation;
|
|
69
|
+
let approval;
|
|
70
|
+
if (typeof body === "object" && body !== null) {
|
|
71
|
+
const b = body;
|
|
72
|
+
if (typeof b.error === "string" && b.error !== "") daemonMessage = b.error;
|
|
73
|
+
else if (typeof b.message === "string" && b.message !== "") daemonMessage = b.message;
|
|
74
|
+
if (typeof b.remediation === "string" && b.remediation !== "") remediation = b.remediation;
|
|
75
|
+
approval = b.approval;
|
|
76
|
+
} else if (typeof body === "string" && body.trim() !== "") {
|
|
77
|
+
daemonMessage = body.trim();
|
|
78
|
+
}
|
|
79
|
+
const base = daemonMessage ?? `request failed with HTTP ${status}`;
|
|
80
|
+
const message = remediation ? `${base} (${remediation})` : base;
|
|
81
|
+
const opts = { status, daemonMessage, remediation, body, ...ctx };
|
|
82
|
+
if (status === 403 && isRefusal(approval)) {
|
|
83
|
+
return new Kosa8ApprovalRequiredError(message, approval, opts);
|
|
84
|
+
}
|
|
85
|
+
switch (status) {
|
|
86
|
+
case 400:
|
|
87
|
+
return new Kosa8BadRequestError(message, opts);
|
|
88
|
+
case 401:
|
|
89
|
+
return new Kosa8AuthError(message, opts);
|
|
90
|
+
case 403:
|
|
91
|
+
return new Kosa8PolicyError(message, opts);
|
|
92
|
+
case 404:
|
|
93
|
+
return new Kosa8NotFoundError(message, opts);
|
|
94
|
+
case 409:
|
|
95
|
+
return new Kosa8ConflictError(message, opts);
|
|
96
|
+
case 501:
|
|
97
|
+
return new Kosa8UnsupportedError(message, opts);
|
|
98
|
+
default:
|
|
99
|
+
return new Kosa8ApiError(message, opts);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/checkpoints.ts
|
|
104
|
+
var MAX_CHECKPOINTS_PER_SANDBOX = 10;
|
|
105
|
+
function validateSandboxName(name) {
|
|
106
|
+
if (name === "" || name === "." || name === ".." || /[/\\]/.test(name) || name.startsWith(".")) {
|
|
107
|
+
throw new Kosa8CheckpointError(`invalid sandbox name ${JSON.stringify(name)}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
var MemoryCheckpointStore = class {
|
|
111
|
+
data = {};
|
|
112
|
+
queue = Promise.resolve();
|
|
113
|
+
async load() {
|
|
114
|
+
return structuredClone(this.data);
|
|
115
|
+
}
|
|
116
|
+
update(fn) {
|
|
117
|
+
const run = this.queue.then(async () => {
|
|
118
|
+
const copy = structuredClone(this.data);
|
|
119
|
+
const out = await fn(copy);
|
|
120
|
+
this.data = copy;
|
|
121
|
+
return out;
|
|
122
|
+
});
|
|
123
|
+
this.queue = run.catch(() => void 0);
|
|
124
|
+
return run;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
var FileCheckpointStore = class {
|
|
128
|
+
path;
|
|
129
|
+
queue = Promise.resolve();
|
|
130
|
+
constructor(filePath) {
|
|
131
|
+
this.path = filePath ?? path.join(os.homedir(), ".kosa8", "checkpoints.json");
|
|
132
|
+
}
|
|
133
|
+
async load() {
|
|
134
|
+
let buf;
|
|
135
|
+
try {
|
|
136
|
+
buf = await fs.readFile(this.path, "utf8");
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (err.code === "ENOENT") return {};
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const parsed = JSON.parse(buf);
|
|
143
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
144
|
+
} catch {
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
update(fn) {
|
|
149
|
+
const run = this.queue.then(() => this.locked(fn));
|
|
150
|
+
this.queue = run.catch(() => void 0);
|
|
151
|
+
return run;
|
|
152
|
+
}
|
|
153
|
+
async locked(fn) {
|
|
154
|
+
await fs.mkdir(path.dirname(this.path), { recursive: true, mode: 448 });
|
|
155
|
+
const lock = this.path + ".node.lock";
|
|
156
|
+
const deadline = Date.now() + 1e4;
|
|
157
|
+
for (; ; ) {
|
|
158
|
+
try {
|
|
159
|
+
const h = await fs.open(lock, "wx", 384);
|
|
160
|
+
await h.close();
|
|
161
|
+
break;
|
|
162
|
+
} catch (err) {
|
|
163
|
+
if (err.code !== "EEXIST") throw err;
|
|
164
|
+
try {
|
|
165
|
+
const st = await fs.stat(lock);
|
|
166
|
+
if (Date.now() - st.mtimeMs > 3e4) {
|
|
167
|
+
await fs.rm(lock, { force: true });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (Date.now() > deadline) throw new Kosa8CheckpointError(`timed out waiting for the checkpoint store lock ${lock}`);
|
|
174
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
const data = await this.load();
|
|
179
|
+
const out = await fn(data);
|
|
180
|
+
const tmp = this.path + ".tmp";
|
|
181
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2), { mode: 384 });
|
|
182
|
+
await fs.rename(tmp, this.path);
|
|
183
|
+
return out;
|
|
184
|
+
} finally {
|
|
185
|
+
await fs.rm(lock, { force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
function seqOf(id, sandbox) {
|
|
190
|
+
const pre = sandbox + "-ckpt-";
|
|
191
|
+
if (!id.startsWith(pre)) return 0;
|
|
192
|
+
const m = /^(\d+)/.exec(id.slice(pre.length));
|
|
193
|
+
return m ? Number(m[1]) : 0;
|
|
194
|
+
}
|
|
195
|
+
function sorted(list) {
|
|
196
|
+
return list.map((c, i) => ({ c, i, t: Date.parse(c.created) || 0 })).sort((a, b) => a.t - b.t || a.i - b.i).map((x) => x.c);
|
|
197
|
+
}
|
|
198
|
+
var CheckpointBook = class {
|
|
199
|
+
constructor(store) {
|
|
200
|
+
this.store = store;
|
|
201
|
+
}
|
|
202
|
+
/** The snapshot id the next checkpoint should use, and ids to evict to stay under the cap. */
|
|
203
|
+
async nextId(sandbox) {
|
|
204
|
+
validateSandboxName(sandbox);
|
|
205
|
+
const list = (await this.store.load())[sandbox] ?? [];
|
|
206
|
+
let n = list.length + 1;
|
|
207
|
+
for (const c of list) {
|
|
208
|
+
const v = seqOf(c.id, sandbox);
|
|
209
|
+
if (v >= n) n = v + 1;
|
|
210
|
+
}
|
|
211
|
+
const evict = [];
|
|
212
|
+
const over = list.length + 1 - MAX_CHECKPOINTS_PER_SANDBOX;
|
|
213
|
+
if (over > 0) for (const c of list.slice(0, over)) evict.push(c.id);
|
|
214
|
+
return { id: `${sandbox}-ckpt-${n}`, evict };
|
|
215
|
+
}
|
|
216
|
+
async record(c, evicted = []) {
|
|
217
|
+
validateSandboxName(c.sandbox);
|
|
218
|
+
const rec = {
|
|
219
|
+
id: c.id,
|
|
220
|
+
sandbox: c.sandbox,
|
|
221
|
+
label: c.label ?? "",
|
|
222
|
+
...c.auto ? { auto: true } : {},
|
|
223
|
+
created: c.created ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
224
|
+
};
|
|
225
|
+
await this.store.update((s) => {
|
|
226
|
+
const drop = new Set(evicted);
|
|
227
|
+
s[c.sandbox] = [...(s[c.sandbox] ?? []).filter((e) => !drop.has(e.id)), rec];
|
|
228
|
+
});
|
|
229
|
+
return rec;
|
|
230
|
+
}
|
|
231
|
+
async list(sandbox) {
|
|
232
|
+
return sorted([...(await this.store.load())[sandbox] ?? []]);
|
|
233
|
+
}
|
|
234
|
+
async latest(sandbox) {
|
|
235
|
+
const list = await this.list(sandbox);
|
|
236
|
+
if (list.length === 0) {
|
|
237
|
+
throw new Kosa8CheckpointError(
|
|
238
|
+
`no checkpoints for ${JSON.stringify(sandbox)} \u2014 take one with checkpoint() or \`kosa8 checkpoint ${sandbox}\``
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return list[list.length - 1];
|
|
242
|
+
}
|
|
243
|
+
/** Find by id or (case-insensitive) label, newest match first. */
|
|
244
|
+
async resolve(sandbox, ref) {
|
|
245
|
+
const list = await this.list(sandbox);
|
|
246
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
247
|
+
const c = list[i];
|
|
248
|
+
if (c.id === ref || c.label.toLowerCase() === ref.toLowerCase()) return c;
|
|
249
|
+
}
|
|
250
|
+
throw new Kosa8CheckpointError(`no checkpoint ${JSON.stringify(ref)} for sandbox ${JSON.stringify(sandbox)}`);
|
|
251
|
+
}
|
|
252
|
+
/** Keep `id` as the top of the stack; return the ids taken after it (now meaningless). */
|
|
253
|
+
async pop(sandbox, id) {
|
|
254
|
+
return this.store.update((s) => {
|
|
255
|
+
const list = s[sandbox] ?? [];
|
|
256
|
+
const idx = list.findIndex((c) => c.id === id);
|
|
257
|
+
if (idx < 0) throw new Kosa8CheckpointError(`no checkpoint ${JSON.stringify(id)} for sandbox ${JSON.stringify(sandbox)}`);
|
|
258
|
+
const orphaned = list.slice(idx + 1).map((c) => c.id);
|
|
259
|
+
s[sandbox] = list.slice(0, idx + 1);
|
|
260
|
+
return orphaned;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** Drop every checkpoint of a sandbox; return the snapshot ids to delete. */
|
|
264
|
+
async forget(sandbox) {
|
|
265
|
+
return this.store.update((s) => {
|
|
266
|
+
const ids = (s[sandbox] ?? []).map((c) => c.id);
|
|
267
|
+
delete s[sandbox];
|
|
268
|
+
return ids;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// src/transport.ts
|
|
274
|
+
import * as http from "http";
|
|
275
|
+
import * as https from "https";
|
|
276
|
+
import * as os2 from "os";
|
|
277
|
+
import * as path2 from "path";
|
|
278
|
+
var APPROVAL_HEADER = "X-Kosa8-Approval";
|
|
279
|
+
var ACTOR_HEADER = "X-Kosa8-Actor";
|
|
280
|
+
var AGENT_TOKEN_HEADER = "X-Kosa8-Agent-Token";
|
|
281
|
+
function defaultSocketPath() {
|
|
282
|
+
return path2.join(os2.homedir(), ".kosa8", "run", "kosa8.sock");
|
|
283
|
+
}
|
|
284
|
+
function isLoopback(hostname) {
|
|
285
|
+
const h = hostname.replace(/^\[|\]$/g, "");
|
|
286
|
+
return h === "localhost" || h === "::1" || /^127\./.test(h);
|
|
287
|
+
}
|
|
288
|
+
function resolveEndpoint(opts) {
|
|
289
|
+
const env = opts.env ?? process.env;
|
|
290
|
+
const fromEnv = (env.KOSA8_HOST ?? "").trim();
|
|
291
|
+
const raw = (opts.host ?? fromEnv).trim().replace(/\/+$/, "");
|
|
292
|
+
if (raw === "") {
|
|
293
|
+
return { kind: "unix", socketPath: opts.socketPath ?? defaultSocketPath() };
|
|
294
|
+
}
|
|
295
|
+
let h = raw;
|
|
296
|
+
const i = h.indexOf("://");
|
|
297
|
+
if (i > 0) h = h.slice(0, i).toLowerCase() + h.slice(i);
|
|
298
|
+
if (h.startsWith("unix://")) {
|
|
299
|
+
return { kind: "unix", socketPath: h.slice("unix://".length) };
|
|
300
|
+
}
|
|
301
|
+
if (h.startsWith("tcp://")) h = "http://" + h.slice("tcp://".length);
|
|
302
|
+
else if (!h.startsWith("http://") && !h.startsWith("https://")) {
|
|
303
|
+
if (h.includes("://")) {
|
|
304
|
+
throw new Kosa8Error(
|
|
305
|
+
`KOSA8_HOST scheme "${h.slice(0, h.indexOf("://"))}" is not supported; use http://, https://, tcp:// or unix://`
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
h = "http://" + h;
|
|
309
|
+
}
|
|
310
|
+
const url = new URL(h);
|
|
311
|
+
const insecure = opts.insecure === true || env.KOSA8_INSECURE === "1";
|
|
312
|
+
if (url.protocol === "http:" && !insecure && !isLoopback(url.hostname)) {
|
|
313
|
+
throw new Kosa8Error(
|
|
314
|
+
`refusing to talk to ${url.host} over plain http: the token and every request would cross the network unencrypted. Use https://${url.host} (a worker behind a TLS-terminating tunnel or proxy), or set insecure: true / KOSA8_INSECURE=1 if this link is already private and trusted`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const token = opts.token ?? (opts.host === void 0 && fromEnv !== "" ? env.KOSA8_TOKEN : void 0);
|
|
318
|
+
return { kind: "tcp", url, token: token || void 0 };
|
|
319
|
+
}
|
|
320
|
+
var Transport = class {
|
|
321
|
+
endpoint;
|
|
322
|
+
actor;
|
|
323
|
+
agentToken;
|
|
324
|
+
extraHeaders;
|
|
325
|
+
constructor(opts = {}) {
|
|
326
|
+
this.endpoint = resolveEndpoint(opts);
|
|
327
|
+
this.actor = opts.actor ?? "tool";
|
|
328
|
+
const env = opts.env ?? process.env;
|
|
329
|
+
this.agentToken = opts.agentToken ?? (env.KOSA8_AGENT_TOKEN || void 0);
|
|
330
|
+
this.extraHeaders = { ...opts.headers ?? {} };
|
|
331
|
+
}
|
|
332
|
+
/** Human-readable description of where requests go. */
|
|
333
|
+
get target() {
|
|
334
|
+
return this.endpoint.kind === "unix" ? `unix://${this.endpoint.socketPath}` : this.endpoint.url.origin;
|
|
335
|
+
}
|
|
336
|
+
buildPath(p, query) {
|
|
337
|
+
const base = this.endpoint.kind === "tcp" ? this.endpoint.url.pathname.replace(/\/+$/, "") : "";
|
|
338
|
+
let out = base + p;
|
|
339
|
+
if (query) {
|
|
340
|
+
const qs = new URLSearchParams();
|
|
341
|
+
for (const [k, v] of Object.entries(query)) {
|
|
342
|
+
if (v === void 0 || v === false) continue;
|
|
343
|
+
qs.set(k, v === true ? "1" : String(v));
|
|
344
|
+
}
|
|
345
|
+
const s = qs.toString();
|
|
346
|
+
if (s) out += "?" + s;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
/** Send a request and return the raw response once headers arrive. */
|
|
351
|
+
raw(method, p, opts = {}) {
|
|
352
|
+
const headers = {
|
|
353
|
+
Accept: "application/json",
|
|
354
|
+
[ACTOR_HEADER]: this.actor,
|
|
355
|
+
...this.extraHeaders,
|
|
356
|
+
...opts.headers ?? {}
|
|
357
|
+
};
|
|
358
|
+
if (opts.approval && opts.approval.trim() !== "") headers[APPROVAL_HEADER] = opts.approval.trim();
|
|
359
|
+
if (this.agentToken) headers[AGENT_TOKEN_HEADER] = this.agentToken;
|
|
360
|
+
let payload;
|
|
361
|
+
let streamBody;
|
|
362
|
+
if (opts.rawBody !== void 0) {
|
|
363
|
+
headers["Content-Type"] = "application/octet-stream";
|
|
364
|
+
if (typeof opts.rawBody === "string" || opts.rawBody instanceof Uint8Array) {
|
|
365
|
+
payload = Buffer.from(opts.rawBody);
|
|
366
|
+
headers["Content-Length"] = String(payload.length);
|
|
367
|
+
} else {
|
|
368
|
+
streamBody = opts.rawBody;
|
|
369
|
+
}
|
|
370
|
+
} else if (opts.body !== void 0) {
|
|
371
|
+
payload = Buffer.from(JSON.stringify(opts.body));
|
|
372
|
+
headers["Content-Type"] = "application/json";
|
|
373
|
+
headers["Content-Length"] = String(payload.length);
|
|
374
|
+
} else if (method !== "GET" && method !== "HEAD") {
|
|
375
|
+
headers["Content-Length"] = "0";
|
|
376
|
+
}
|
|
377
|
+
const reqPath = this.buildPath(p, opts.query);
|
|
378
|
+
let reqOpts;
|
|
379
|
+
let mod = http;
|
|
380
|
+
if (this.endpoint.kind === "unix") {
|
|
381
|
+
reqOpts = { socketPath: this.endpoint.socketPath, path: reqPath, method, headers: { Host: "kosa8", ...headers } };
|
|
382
|
+
} else {
|
|
383
|
+
const u = this.endpoint.url;
|
|
384
|
+
if (this.endpoint.token) headers.Authorization = `Bearer ${this.endpoint.token}`;
|
|
385
|
+
mod = u.protocol === "https:" ? https : http;
|
|
386
|
+
reqOpts = { protocol: u.protocol, hostname: u.hostname, port: u.port || void 0, path: reqPath, method, headers };
|
|
387
|
+
}
|
|
388
|
+
return new Promise((resolve, reject) => {
|
|
389
|
+
const signal = opts.signal;
|
|
390
|
+
if (signal?.aborted) {
|
|
391
|
+
reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const req = mod.request(reqOpts, (res) => {
|
|
395
|
+
resolve({ status: res.statusCode ?? 0, headers: res.headers, stream: res });
|
|
396
|
+
});
|
|
397
|
+
const onAbort = () => {
|
|
398
|
+
req.destroy(signal?.reason instanceof Error ? signal.reason : new DOMException("Aborted", "AbortError"));
|
|
399
|
+
};
|
|
400
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
401
|
+
req.on("close", () => signal?.removeEventListener("abort", onAbort));
|
|
402
|
+
req.on("error", (err) => {
|
|
403
|
+
if (signal?.aborted) {
|
|
404
|
+
reject(signal.reason ?? err);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
reject(
|
|
408
|
+
new Kosa8ConnectionError(
|
|
409
|
+
`kosa8 daemon unreachable at ${this.target}: ${err.message}` + (err.code === "ENOENT" || err.code === "ECONNREFUSED" ? " (is kosa8 installed and its daemon running? start it with `kosa8 daemon start`)" : ""),
|
|
410
|
+
{ method, path: p, cause: err }
|
|
411
|
+
)
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
if (streamBody) {
|
|
415
|
+
const src = streamBody;
|
|
416
|
+
void (async () => {
|
|
417
|
+
try {
|
|
418
|
+
for await (const chunk of src) {
|
|
419
|
+
if (!req.write(chunk)) await new Promise((r) => req.once("drain", r));
|
|
420
|
+
}
|
|
421
|
+
req.end();
|
|
422
|
+
} catch (err) {
|
|
423
|
+
req.destroy(err);
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (payload) req.write(payload);
|
|
429
|
+
req.end();
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
/** Send a request and decode a JSON answer; HTTP errors become typed errors. */
|
|
433
|
+
async json(method, p, opts = {}) {
|
|
434
|
+
const res = await this.raw(method, p, opts);
|
|
435
|
+
const text = (await readAll(res.stream, opts.signal)).toString("utf8");
|
|
436
|
+
let body = void 0;
|
|
437
|
+
if (text.trim() !== "") {
|
|
438
|
+
try {
|
|
439
|
+
body = JSON.parse(text);
|
|
440
|
+
} catch {
|
|
441
|
+
body = text;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (res.status >= 400) throw errorFromResponse(res.status, body, { method, path: p });
|
|
445
|
+
return body;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Send a request whose answer is a stream of NDJSON frames (run, exec,
|
|
449
|
+
* attach). HTTP errors (a refusal before streaming began) become typed
|
|
450
|
+
* errors; each frame's base64 `data` is decoded to a Buffer.
|
|
451
|
+
*/
|
|
452
|
+
async *frames(method, p, opts = {}) {
|
|
453
|
+
const res = await this.raw(method, p, opts);
|
|
454
|
+
if (res.status >= 400) {
|
|
455
|
+
const text = (await readAll(res.stream, opts.signal)).toString("utf8");
|
|
456
|
+
let body = text;
|
|
457
|
+
try {
|
|
458
|
+
body = JSON.parse(text);
|
|
459
|
+
} catch {
|
|
460
|
+
}
|
|
461
|
+
throw errorFromResponse(res.status, body, { method, path: p });
|
|
462
|
+
}
|
|
463
|
+
let buf = "";
|
|
464
|
+
try {
|
|
465
|
+
for await (const chunk of res.stream) {
|
|
466
|
+
buf += chunk.toString("utf8");
|
|
467
|
+
let nl;
|
|
468
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
469
|
+
const line = buf.slice(0, nl).trim();
|
|
470
|
+
buf = buf.slice(nl + 1);
|
|
471
|
+
if (line !== "") yield decodeFrame(line);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
} catch (err) {
|
|
475
|
+
if (opts.signal?.aborted) throw opts.signal.reason ?? err;
|
|
476
|
+
throw err;
|
|
477
|
+
} finally {
|
|
478
|
+
res.stream.destroy();
|
|
479
|
+
}
|
|
480
|
+
const rest = buf.trim();
|
|
481
|
+
if (rest !== "") yield decodeFrame(rest);
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
function decodeFrame(line) {
|
|
485
|
+
const f = JSON.parse(line);
|
|
486
|
+
const out = { ...f };
|
|
487
|
+
if (typeof f.data === "string") out.data = Buffer.from(f.data, "base64");
|
|
488
|
+
return out;
|
|
489
|
+
}
|
|
490
|
+
async function readAll(stream, signal) {
|
|
491
|
+
const chunks = [];
|
|
492
|
+
try {
|
|
493
|
+
for await (const c of stream) chunks.push(c);
|
|
494
|
+
} catch (err) {
|
|
495
|
+
if (signal?.aborted) throw signal.reason ?? err;
|
|
496
|
+
throw err;
|
|
497
|
+
}
|
|
498
|
+
return Buffer.concat(chunks);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// src/client.ts
|
|
502
|
+
function egressString(egress, allow) {
|
|
503
|
+
if (allow && allow.length > 0) {
|
|
504
|
+
if (egress !== void 0 && egress !== "deny") {
|
|
505
|
+
throw new Kosa8Error("egressAllow already limits the network to the hosts it lists; do not also set egress");
|
|
506
|
+
}
|
|
507
|
+
return "allow:" + allow.map((h) => h.trim()).join(",");
|
|
508
|
+
}
|
|
509
|
+
if (egress === void 0) return void 0;
|
|
510
|
+
if (typeof egress === "string") return egress;
|
|
511
|
+
if (egress.allow.length === 0) {
|
|
512
|
+
throw new Kosa8Error('an egress allow-list needs at least one host; use egress: "deny" for no network');
|
|
513
|
+
}
|
|
514
|
+
return "allow:" + egress.allow.map((h) => h.trim()).join(",");
|
|
515
|
+
}
|
|
516
|
+
function envList(env) {
|
|
517
|
+
if (!env) return void 0;
|
|
518
|
+
if (Array.isArray(env)) return env;
|
|
519
|
+
return Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
520
|
+
}
|
|
521
|
+
var enc = encodeURIComponent;
|
|
522
|
+
var Kosa8Client = class {
|
|
523
|
+
transport;
|
|
524
|
+
checkpoints;
|
|
525
|
+
constructor(options = {}) {
|
|
526
|
+
this.transport = new Transport(options);
|
|
527
|
+
this.checkpoints = new CheckpointBook(options.checkpointStore ?? new FileCheckpointStore());
|
|
528
|
+
}
|
|
529
|
+
// --- status ---------------------------------------------------------------
|
|
530
|
+
/** GET /kosa8/v1/health: daemon status and version. */
|
|
531
|
+
health(opts = {}) {
|
|
532
|
+
return this.transport.json("GET", "/kosa8/v1/health", opts);
|
|
533
|
+
}
|
|
534
|
+
/** The daemon's version string. */
|
|
535
|
+
async version(opts = {}) {
|
|
536
|
+
return (await this.health(opts)).version;
|
|
537
|
+
}
|
|
538
|
+
// --- host engine ------------------------------------------------------------
|
|
539
|
+
/** POST /kosa8/v1/pull: pull an image into the host engine (checked against the registry allow-list). */
|
|
540
|
+
pull(ref, opts = {}) {
|
|
541
|
+
return this.transport.json("POST", "/kosa8/v1/pull", { ...opts, body: { ref } });
|
|
542
|
+
}
|
|
543
|
+
// --- sandboxes ---------------------------------------------------------------
|
|
544
|
+
/** GET /kosa8/v1/sandboxes */
|
|
545
|
+
listSandboxes(opts = {}) {
|
|
546
|
+
return this.transport.json("GET", "/kosa8/v1/sandboxes", opts);
|
|
547
|
+
}
|
|
548
|
+
/** Find one sandbox by id or name (from the list; there is no single-sandbox GET). */
|
|
549
|
+
async getSandbox(idOrName, opts = {}) {
|
|
550
|
+
const list = await this.listSandboxes(opts);
|
|
551
|
+
return list.find((s) => s.id === idOrName) ?? list.find((s) => s.name === idOrName);
|
|
552
|
+
}
|
|
553
|
+
/** POST /kosa8/v1/sandboxes: boot a microVM sandbox. */
|
|
554
|
+
async createSandbox(options = {}) {
|
|
555
|
+
const body = {};
|
|
556
|
+
if (options.name) body.name = options.name;
|
|
557
|
+
if (options.cpus) body.cpus = options.cpus;
|
|
558
|
+
if (options.memMb) body.mem_mb = options.memMb;
|
|
559
|
+
if (options.diskGb) body.disk_gb = options.diskGb;
|
|
560
|
+
if (options.mounts) body.mounts = options.mounts;
|
|
561
|
+
const egress = egressString(options.egress, options.egressAllow);
|
|
562
|
+
if (egress !== void 0) body.egress = egress;
|
|
563
|
+
if (options.capture) body.capture = options.capture;
|
|
564
|
+
if (options.labels) body.labels = options.labels;
|
|
565
|
+
if (options.broker) body.broker = options.broker;
|
|
566
|
+
return this.transport.json("POST", "/kosa8/v1/sandboxes", {
|
|
567
|
+
body,
|
|
568
|
+
approval: options.approval,
|
|
569
|
+
signal: options.signal
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
/** POST /kosa8/v1/sandboxes/{id}/up: boot a stopped sandbox from its own disk. */
|
|
573
|
+
startSandbox(id, opts = {}) {
|
|
574
|
+
return this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/up`, opts);
|
|
575
|
+
}
|
|
576
|
+
/** POST /kosa8/v1/sandboxes/{id}/down: stop without deleting (disk kept). */
|
|
577
|
+
stopSandbox(id, opts = {}) {
|
|
578
|
+
return this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/down`, opts);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* DELETE /kosa8/v1/sandboxes/{id}. Like `kosa8 sandbox rm`, the sandbox's
|
|
582
|
+
* checkpoints and their snapshots are dropped too unless
|
|
583
|
+
* `keepCheckpoints` is set.
|
|
584
|
+
*/
|
|
585
|
+
async deleteSandbox(id, opts = {}) {
|
|
586
|
+
const out = await this.transport.json("DELETE", `/kosa8/v1/sandboxes/${enc(id)}`, {
|
|
587
|
+
query: { wipe: opts.wipe },
|
|
588
|
+
approval: opts.approval,
|
|
589
|
+
signal: opts.signal
|
|
590
|
+
});
|
|
591
|
+
let dropped = 0;
|
|
592
|
+
if (!opts.keepCheckpoints) {
|
|
593
|
+
try {
|
|
594
|
+
const ids = await this.checkpoints.forget(id);
|
|
595
|
+
for (const sid of ids) {
|
|
596
|
+
await this.deleteSnapshot(sid, { wipe: opts.wipe, approval: opts.approval }).catch(() => void 0);
|
|
597
|
+
}
|
|
598
|
+
dropped = ids.length;
|
|
599
|
+
} catch {
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return { ...out, checkpoints_dropped: dropped };
|
|
603
|
+
}
|
|
604
|
+
/** POST /kosa8/v1/sandboxes/{id}/pull: pull an image into the sandbox's private engine (fetched on the host). */
|
|
605
|
+
pullIntoSandbox(id, ref, opts = {}) {
|
|
606
|
+
return this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/pull`, { ...opts, body: { ref } });
|
|
607
|
+
}
|
|
608
|
+
/** GET /kosa8/v1/sandboxes/{id}/images */
|
|
609
|
+
sandboxImages(id, opts = {}) {
|
|
610
|
+
return this.transport.json("GET", `/kosa8/v1/sandboxes/${enc(id)}/images`, opts);
|
|
611
|
+
}
|
|
612
|
+
/** GET /kosa8/v1/sandboxes/{id}/containers */
|
|
613
|
+
sandboxContainers(id, opts = {}) {
|
|
614
|
+
return this.transport.json("GET", `/kosa8/v1/sandboxes/${enc(id)}/containers`, opts);
|
|
615
|
+
}
|
|
616
|
+
/** GET /kosa8/v1/sandboxes/{id}/egress: the policy the host enforces and what it decided. */
|
|
617
|
+
sandboxEgress(id, opts = {}) {
|
|
618
|
+
return this.transport.json("GET", `/kosa8/v1/sandboxes/${enc(id)}/egress`, opts);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* POST /kosa8/v1/sandboxes/{id}/run: run a container to completion inside
|
|
622
|
+
* the sandbox's engine. The image must already be in the sandbox
|
|
623
|
+
* (`pullIntoSandbox`). Resolves with the exit code; a non-zero exit is not
|
|
624
|
+
* an error.
|
|
625
|
+
*/
|
|
626
|
+
run(id, image, args = [], opts = {}) {
|
|
627
|
+
return this.collect(
|
|
628
|
+
this.transport.frames("POST", `/kosa8/v1/sandboxes/${enc(id)}/run`, {
|
|
629
|
+
body: { image, args, env: envList(opts.env) },
|
|
630
|
+
approval: opts.approval,
|
|
631
|
+
signal: opts.signal
|
|
632
|
+
}),
|
|
633
|
+
opts
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
/** POST /kosa8/v1/sandboxes/{id}/start: start a detached container; resolves with its id. */
|
|
637
|
+
async startContainer(id, image, args = [], opts = {}) {
|
|
638
|
+
const out = await this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/start`, {
|
|
639
|
+
...opts,
|
|
640
|
+
body: { image, args }
|
|
641
|
+
});
|
|
642
|
+
return out.id;
|
|
643
|
+
}
|
|
644
|
+
/** POST /kosa8/v1/sandboxes/{id}/exec: run a command in a running container of the sandbox. */
|
|
645
|
+
exec(id, container, args, opts = {}) {
|
|
646
|
+
return this.collect(this.execFrames(id, container, args, opts), opts);
|
|
647
|
+
}
|
|
648
|
+
/** The raw frame stream of an exec, for callers that want to process output as it arrives. */
|
|
649
|
+
execFrames(id, container, args, opts = {}) {
|
|
650
|
+
return this.transport.frames("POST", `/kosa8/v1/sandboxes/${enc(id)}/exec`, {
|
|
651
|
+
body: { container, args },
|
|
652
|
+
approval: opts.approval,
|
|
653
|
+
signal: opts.signal
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
/** POST /kosa8/v1/sandboxes/{id}/start-interactive: start a container with stdin held open. */
|
|
657
|
+
async startInteractive(id, image, args = [], opts = {}) {
|
|
658
|
+
const out = await this.transport.json(
|
|
659
|
+
"POST",
|
|
660
|
+
`/kosa8/v1/sandboxes/${enc(id)}/start-interactive`,
|
|
661
|
+
{ body: { image, args, env: envList(opts.env), tty: opts.tty ?? false }, approval: opts.approval, signal: opts.signal }
|
|
662
|
+
);
|
|
663
|
+
return out.id;
|
|
664
|
+
}
|
|
665
|
+
/** POST /kosa8/v1/sandboxes/{id}/containers/{cid}/stdin: stream bytes into an interactive container's stdin. */
|
|
666
|
+
writeStdin(id, container, data, opts = {}) {
|
|
667
|
+
return this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/containers/${enc(container)}/stdin`, {
|
|
668
|
+
...opts,
|
|
669
|
+
rawBody: data
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/** POST /kosa8/v1/sandboxes/{id}/containers/{cid}/attach: follow a container's output until it exits. */
|
|
673
|
+
attach(id, container, opts = {}) {
|
|
674
|
+
return this.collect(
|
|
675
|
+
this.transport.frames("POST", `/kosa8/v1/sandboxes/${enc(id)}/containers/${enc(container)}/attach`, {
|
|
676
|
+
approval: opts.approval,
|
|
677
|
+
signal: opts.signal
|
|
678
|
+
}),
|
|
679
|
+
opts
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
// --- snapshots, fork, explore -------------------------------------------------
|
|
683
|
+
/** POST /kosa8/v1/sandboxes/{id}/snapshot: capture memory, processes and disks. */
|
|
684
|
+
snapshot(id, opts = {}) {
|
|
685
|
+
const body = {};
|
|
686
|
+
if (opts.id) body.id = opts.id;
|
|
687
|
+
if (opts.encrypt) body.encrypt = true;
|
|
688
|
+
return this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/snapshot`, {
|
|
689
|
+
body,
|
|
690
|
+
approval: opts.approval,
|
|
691
|
+
signal: opts.signal
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
/** GET /kosa8/v1/snapshots */
|
|
695
|
+
listSnapshots(opts = {}) {
|
|
696
|
+
return this.transport.json("GET", "/kosa8/v1/snapshots", opts);
|
|
697
|
+
}
|
|
698
|
+
/** POST /kosa8/v1/snapshots/{id}/restore: boot a sandbox from a snapshot, processes resumed. */
|
|
699
|
+
restoreSnapshot(snapshotId, opts = {}) {
|
|
700
|
+
const body = {};
|
|
701
|
+
if (opts.name) body.name = opts.name;
|
|
702
|
+
if (opts.label) body.label = opts.label;
|
|
703
|
+
if (opts.allowTampered) body.allow_tampered = true;
|
|
704
|
+
return this.transport.json("POST", `/kosa8/v1/snapshots/${enc(snapshotId)}/restore`, {
|
|
705
|
+
body,
|
|
706
|
+
approval: opts.approval,
|
|
707
|
+
signal: opts.signal
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
/** DELETE /kosa8/v1/snapshots/{id} */
|
|
711
|
+
deleteSnapshot(snapshotId, opts = {}) {
|
|
712
|
+
return this.transport.json("DELETE", `/kosa8/v1/snapshots/${enc(snapshotId)}`, {
|
|
713
|
+
query: { wipe: opts.wipe, force: opts.force },
|
|
714
|
+
approval: opts.approval,
|
|
715
|
+
signal: opts.signal
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/** POST /kosa8/v1/sandboxes/{id}/fork: N live copies of a running sandbox. */
|
|
719
|
+
fork(id, opts = {}) {
|
|
720
|
+
const body = { n: opts.n ?? 1 };
|
|
721
|
+
if (opts.prefix) body.prefix = opts.prefix;
|
|
722
|
+
if (opts.label) body.label = opts.label;
|
|
723
|
+
return this.transport.json("POST", `/kosa8/v1/sandboxes/${enc(id)}/fork`, {
|
|
724
|
+
body,
|
|
725
|
+
approval: opts.approval,
|
|
726
|
+
signal: opts.signal
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Best-of-N, as `kosa8 explore`: fork the sandbox once per variant, run
|
|
731
|
+
* each variant (`sh -c VARIANT` in `image`) in its own branch
|
|
732
|
+
* concurrently, keep the fastest successful branch and delete the rest.
|
|
733
|
+
*/
|
|
734
|
+
async explore(id, opts) {
|
|
735
|
+
const variants = opts.variants ?? [];
|
|
736
|
+
if (variants.length < 2) {
|
|
737
|
+
throw new Kosa8Error("give at least two variants; with one there is nothing to compare");
|
|
738
|
+
}
|
|
739
|
+
const image = opts.image ?? "alpine:latest";
|
|
740
|
+
const call = { approval: opts.approval, signal: opts.signal };
|
|
741
|
+
if (opts.pull !== false) await this.pullIntoSandbox(id, image, call);
|
|
742
|
+
const prefix = opts.prefix ?? `${id}-explore-${Math.floor(Date.now() / 1e3) % 1e5}`;
|
|
743
|
+
const forked = await this.fork(id, { n: variants.length, prefix, ...call });
|
|
744
|
+
const names = forked.forks.map((f) => f.id).filter((x) => typeof x === "string");
|
|
745
|
+
if (names.length < variants.length) {
|
|
746
|
+
throw new Kosa8Error(`forked ${names.length} branches for ${variants.length} variants; see listSandboxes() for what was created`);
|
|
747
|
+
}
|
|
748
|
+
const timeoutMs = opts.timeoutMs ?? 12e4;
|
|
749
|
+
const branches = await Promise.all(
|
|
750
|
+
variants.map((v, i) => this.runBranch(names[i], image, v, timeoutMs, opts))
|
|
751
|
+
);
|
|
752
|
+
let winner = "";
|
|
753
|
+
let best = Infinity;
|
|
754
|
+
for (const b of branches) {
|
|
755
|
+
if (!b.error && b.exit === 0 && b.duration_ms < best) {
|
|
756
|
+
winner = b.name;
|
|
757
|
+
best = b.duration_ms;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (opts.keep !== void 0) {
|
|
761
|
+
if (!names.includes(opts.keep)) {
|
|
762
|
+
throw new Kosa8Error(`keep ${opts.keep} is not one of the branches (${names.join(", ")}); all of them were kept`);
|
|
763
|
+
}
|
|
764
|
+
winner = opts.keep;
|
|
765
|
+
}
|
|
766
|
+
let removed = 0;
|
|
767
|
+
if (!opts.keepAll) {
|
|
768
|
+
for (const b of branches) {
|
|
769
|
+
if (b.name !== winner) {
|
|
770
|
+
try {
|
|
771
|
+
await this.transport.json("DELETE", `/kosa8/v1/sandboxes/${enc(b.name)}`, { approval: opts.approval });
|
|
772
|
+
removed++;
|
|
773
|
+
} catch {
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
return { parent: id, branches, winner, removed };
|
|
779
|
+
}
|
|
780
|
+
async runBranch(name, image, variant, timeoutMs, opts) {
|
|
781
|
+
const start = Date.now();
|
|
782
|
+
const r = { name, variant, exit: 0, output: "", duration_ms: 0 };
|
|
783
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
784
|
+
const ctl = new AbortController();
|
|
785
|
+
const timer = setTimeout(() => ctl.abort(new Error(`branch timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
786
|
+
const onAbort = () => ctl.abort(opts.signal?.reason);
|
|
787
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
788
|
+
try {
|
|
789
|
+
let out = "";
|
|
790
|
+
const res = await this.run(name, image, ["sh", "-c", variant], {
|
|
791
|
+
approval: opts.approval,
|
|
792
|
+
signal: ctl.signal,
|
|
793
|
+
onStdout: (c) => out += c.toString("utf8"),
|
|
794
|
+
onStderr: (c) => out += c.toString("utf8")
|
|
795
|
+
});
|
|
796
|
+
r.exit = res.exitCode;
|
|
797
|
+
r.output = out.replace(/\n+$/, "");
|
|
798
|
+
delete r.error;
|
|
799
|
+
break;
|
|
800
|
+
} catch (err) {
|
|
801
|
+
r.error = err instanceof Error ? err.message : String(err);
|
|
802
|
+
if (err instanceof Kosa8StreamError) r.output = (err.stdout + err.stderr).replace(/\n+$/, "");
|
|
803
|
+
const transient = /connection refused|EOF|connection reset|broken pipe|no such file or directory|stream ended|ECONNREFUSED|ECONNRESET|socket hang up/i.test(
|
|
804
|
+
r.error
|
|
805
|
+
);
|
|
806
|
+
if (!transient || ctl.signal.aborted) break;
|
|
807
|
+
await new Promise((res) => setTimeout(res, (attempt + 1) * 500));
|
|
808
|
+
} finally {
|
|
809
|
+
clearTimeout(timer);
|
|
810
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
r.duration_ms = Date.now() - start;
|
|
814
|
+
return r;
|
|
815
|
+
}
|
|
816
|
+
// --- checkpoints (client-side over snapshots, as pkg/checkpoint) ---------------
|
|
817
|
+
/**
|
|
818
|
+
* Save a restore point: a snapshot with id `<sandbox>-ckpt-<n>`, recorded on
|
|
819
|
+
* the sandbox's checkpoint stack (the same store `kosa8 checkpoint` uses).
|
|
820
|
+
* The oldest is evicted past 10.
|
|
821
|
+
*/
|
|
822
|
+
async checkpoint(sandbox, opts = {}) {
|
|
823
|
+
const { id, evict } = await this.checkpoints.nextId(sandbox);
|
|
824
|
+
const snap = await this.snapshot(sandbox, { id, approval: opts.approval, signal: opts.signal });
|
|
825
|
+
for (const old of evict) {
|
|
826
|
+
await this.deleteSnapshot(old, { approval: opts.approval }).catch(() => void 0);
|
|
827
|
+
}
|
|
828
|
+
const checkpoint = await this.checkpoints.record({ id, sandbox, label: opts.label ?? "" }, evict);
|
|
829
|
+
return { checkpoint, evicted: evict, snapshot: snap };
|
|
830
|
+
}
|
|
831
|
+
/** A sandbox's restore points, oldest first. */
|
|
832
|
+
listCheckpoints(sandbox) {
|
|
833
|
+
return this.checkpoints.list(sandbox);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Undo: put the sandbox back to a checkpoint under the same name, processes
|
|
837
|
+
* resumed. Deletes the sandbox, then restores the snapshot with its name;
|
|
838
|
+
* checkpoints taken after the target are discarded. Current state is lost.
|
|
839
|
+
*/
|
|
840
|
+
async rewind(sandbox, opts = {}) {
|
|
841
|
+
const target = opts.to ? await this.checkpoints.resolve(sandbox, opts.to) : await this.checkpoints.latest(sandbox);
|
|
842
|
+
const call = { approval: opts.approval, signal: opts.signal };
|
|
843
|
+
await this.transport.json("DELETE", `/kosa8/v1/sandboxes/${enc(sandbox)}`, call);
|
|
844
|
+
let restored;
|
|
845
|
+
try {
|
|
846
|
+
restored = await this.restoreSnapshot(target.id, { name: sandbox, ...call });
|
|
847
|
+
} catch (err) {
|
|
848
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
849
|
+
throw new Kosa8Error(
|
|
850
|
+
`restore failed and ${sandbox} is now stopped: ${msg} \u2014 recover with restoreSnapshot(${JSON.stringify(target.id)}, { name: ${JSON.stringify(sandbox)} })`,
|
|
851
|
+
{ cause: err }
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
let dropped = [];
|
|
855
|
+
try {
|
|
856
|
+
dropped = await this.checkpoints.pop(sandbox, target.id);
|
|
857
|
+
for (const sid of dropped) await this.deleteSnapshot(sid, { approval: opts.approval }).catch(() => void 0);
|
|
858
|
+
} catch {
|
|
859
|
+
}
|
|
860
|
+
return { sandbox: restored.sandbox, checkpoint: target, dropped, restore_ms: restored.restore_ms };
|
|
861
|
+
}
|
|
862
|
+
// --- helpers -------------------------------------------------------------------
|
|
863
|
+
/** A handle bound to one sandbox. */
|
|
864
|
+
sandbox(id) {
|
|
865
|
+
return new SandboxHandle(this, id);
|
|
866
|
+
}
|
|
867
|
+
async collect(frames, opts) {
|
|
868
|
+
const out = [];
|
|
869
|
+
const err = [];
|
|
870
|
+
for await (const f of frames) {
|
|
871
|
+
if (f.done) {
|
|
872
|
+
const stdoutBytes = Buffer.concat(out);
|
|
873
|
+
const stderrBytes = Buffer.concat(err);
|
|
874
|
+
if (f.error) {
|
|
875
|
+
throw new Kosa8StreamError(f.error, {
|
|
876
|
+
stdout: stdoutBytes.toString("utf8"),
|
|
877
|
+
stderr: stderrBytes.toString("utf8")
|
|
878
|
+
}, { daemonMessage: f.error });
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
exitCode: typeof f.exit === "number" ? f.exit : 0,
|
|
882
|
+
stdout: stdoutBytes.toString("utf8"),
|
|
883
|
+
stderr: stderrBytes.toString("utf8"),
|
|
884
|
+
stdoutBytes,
|
|
885
|
+
stderrBytes
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
if (!f.data) continue;
|
|
889
|
+
if (f.stream === "stderr") {
|
|
890
|
+
err.push(f.data);
|
|
891
|
+
opts.onStderr?.(f.data);
|
|
892
|
+
} else if (f.stream === "status") {
|
|
893
|
+
opts.onStatus?.(f.data);
|
|
894
|
+
} else {
|
|
895
|
+
out.push(f.data);
|
|
896
|
+
opts.onStdout?.(f.data);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
throw new Kosa8StreamError("stream ended unexpectedly (no final frame)", {
|
|
900
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
901
|
+
stderr: Buffer.concat(err).toString("utf8")
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
};
|
|
905
|
+
var SandboxHandle = class {
|
|
906
|
+
constructor(client, id) {
|
|
907
|
+
this.client = client;
|
|
908
|
+
this.id = id;
|
|
909
|
+
}
|
|
910
|
+
info(opts) {
|
|
911
|
+
return this.client.getSandbox(this.id, opts);
|
|
912
|
+
}
|
|
913
|
+
start(opts) {
|
|
914
|
+
return this.client.startSandbox(this.id, opts);
|
|
915
|
+
}
|
|
916
|
+
stop(opts) {
|
|
917
|
+
return this.client.stopSandbox(this.id, opts);
|
|
918
|
+
}
|
|
919
|
+
delete(opts) {
|
|
920
|
+
return this.client.deleteSandbox(this.id, opts);
|
|
921
|
+
}
|
|
922
|
+
pull(ref, opts) {
|
|
923
|
+
return this.client.pullIntoSandbox(this.id, ref, opts);
|
|
924
|
+
}
|
|
925
|
+
run(image, args, opts) {
|
|
926
|
+
return this.client.run(this.id, image, args, opts);
|
|
927
|
+
}
|
|
928
|
+
startContainer(image, args, opts) {
|
|
929
|
+
return this.client.startContainer(this.id, image, args, opts);
|
|
930
|
+
}
|
|
931
|
+
exec(container, args, opts) {
|
|
932
|
+
return this.client.exec(this.id, container, args, opts);
|
|
933
|
+
}
|
|
934
|
+
containers(opts) {
|
|
935
|
+
return this.client.sandboxContainers(this.id, opts);
|
|
936
|
+
}
|
|
937
|
+
images(opts) {
|
|
938
|
+
return this.client.sandboxImages(this.id, opts);
|
|
939
|
+
}
|
|
940
|
+
egress(opts) {
|
|
941
|
+
return this.client.sandboxEgress(this.id, opts);
|
|
942
|
+
}
|
|
943
|
+
snapshot(opts) {
|
|
944
|
+
return this.client.snapshot(this.id, opts);
|
|
945
|
+
}
|
|
946
|
+
fork(opts) {
|
|
947
|
+
return this.client.fork(this.id, opts);
|
|
948
|
+
}
|
|
949
|
+
explore(opts) {
|
|
950
|
+
return this.client.explore(this.id, opts);
|
|
951
|
+
}
|
|
952
|
+
checkpoint(opts) {
|
|
953
|
+
return this.client.checkpoint(this.id, opts);
|
|
954
|
+
}
|
|
955
|
+
checkpoints() {
|
|
956
|
+
return this.client.listCheckpoints(this.id);
|
|
957
|
+
}
|
|
958
|
+
rewind(opts) {
|
|
959
|
+
return this.client.rewind(this.id, opts);
|
|
960
|
+
}
|
|
961
|
+
};
|
|
962
|
+
export {
|
|
963
|
+
ACTOR_HEADER,
|
|
964
|
+
AGENT_TOKEN_HEADER,
|
|
965
|
+
APPROVAL_HEADER,
|
|
966
|
+
CheckpointBook,
|
|
967
|
+
FileCheckpointStore,
|
|
968
|
+
Kosa8ApiError,
|
|
969
|
+
Kosa8ApprovalRequiredError,
|
|
970
|
+
Kosa8AuthError,
|
|
971
|
+
Kosa8BadRequestError,
|
|
972
|
+
Kosa8CheckpointError,
|
|
973
|
+
Kosa8Client,
|
|
974
|
+
Kosa8ConflictError,
|
|
975
|
+
Kosa8ConnectionError,
|
|
976
|
+
Kosa8Error,
|
|
977
|
+
Kosa8NotFoundError,
|
|
978
|
+
Kosa8PolicyError,
|
|
979
|
+
Kosa8StreamError,
|
|
980
|
+
Kosa8UnsupportedError,
|
|
981
|
+
MAX_CHECKPOINTS_PER_SANDBOX,
|
|
982
|
+
MemoryCheckpointStore,
|
|
983
|
+
SandboxHandle,
|
|
984
|
+
Transport,
|
|
985
|
+
defaultSocketPath,
|
|
986
|
+
errorFromResponse,
|
|
987
|
+
resolveEndpoint,
|
|
988
|
+
validateSandboxName
|
|
989
|
+
};
|
|
990
|
+
//# sourceMappingURL=index.js.map
|