@ziggs-ai/agent-sdk 0.38.17 → 0.39.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.
@@ -0,0 +1,581 @@
1
+ /**
2
+ * The box: one throwaway unit per checkout wake.
3
+ *
4
+ * Runs inside the box (a container the host started, or a confined local
5
+ * process in a lab) and nowhere else. It depends on nothing but Node and git,
6
+ * so it can run from the stock Node image: the host registers it as the task's
7
+ * command and starts the task with a per-box secret.
8
+ *
9
+ * What it exposes, on the channel port, to a caller holding the secret:
10
+ * POST /setup clone the task's repository and check out the task's branch
11
+ * POST /read one file, as the box user
12
+ * POST /write one whole file, as the box user
13
+ * POST /run one shell command in the checkout, as the box user, with caps
14
+ * POST /push commit everything and push the task's branch
15
+ * GET /relay/next, POST /relay/:id the git relay (below)
16
+ * POST /stop exit
17
+ *
18
+ * The box holds no credential. Its git remote is a relay on the loopback
19
+ * interface, served here: every git request is queued, the host long-polls
20
+ * it, performs it against the backend's git door under the hire's grant, and
21
+ * posts the answer back. Nothing in the box can reach the host's services.
22
+ *
23
+ * Commands run as an unprivileged user when BOX_RUN_AS is set (a container),
24
+ * or through BOX_EXEC_PREFIX (a confined local process), never as this server.
25
+ */
26
+ import { AsyncLocalStorage } from 'node:async_hooks';
27
+ import { createServer } from 'node:http';
28
+ import { spawn } from 'node:child_process';
29
+ import { chownSync, existsSync, mkdirSync } from 'node:fs';
30
+ import { join, resolve, sep } from 'node:path';
31
+ import { timingSafeEqual } from 'node:crypto';
32
+ const SECRET = process.env.BOX_SECRET ?? '';
33
+ if (!SECRET) {
34
+ console.error('BOX_SECRET is required');
35
+ process.exit(2);
36
+ }
37
+ const PORT = Number(process.env.BOX_PORT ?? 7777);
38
+ const BIND = process.env.BOX_BIND ?? '0.0.0.0';
39
+ const WORK = resolve(process.env.BOX_WORK ?? '/work');
40
+ const REPO = join(WORK, 'repo');
41
+ const HOME = process.env.BOX_HOME ?? join(WORK, 'home');
42
+ const TTL_SECONDS = Math.max(60, Number(process.env.BOX_TTL_SECONDS ?? 3600));
43
+ const RUN_AS = /^(\d+):(\d+)$/.exec(process.env.BOX_RUN_AS ?? '');
44
+ const EXEC_PREFIX = (() => {
45
+ try {
46
+ return JSON.parse(process.env.BOX_EXEC_PREFIX ?? '[]');
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ })();
52
+ const AUTHOR_NAME = process.env.BOX_GIT_AUTHOR_NAME ?? 'Ziggs agent';
53
+ const AUTHOR_EMAIL = process.env.BOX_GIT_AUTHOR_EMAIL ?? 'agents@ziggsai.com';
54
+ const SHELL = existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh';
55
+ const STARTED_AT = Date.now();
56
+ const OUTPUT_CAP = 48 * 1024;
57
+ const READ_CAP = 256 * 1024;
58
+ const CHECKPOINT_CAP = 64 * 1024 * 1024;
59
+ const TEMP = join(WORK, 'tmp');
60
+ mkdirSync(WORK, { recursive: true });
61
+ mkdirSync(HOME, { recursive: true });
62
+ mkdirSync(join(WORK, '.npm'), { recursive: true });
63
+ mkdirSync(TEMP, { recursive: true });
64
+ // Do this before any repository-controlled command runs. Never recursively chown
65
+ // a tree that the box user can populate with symlinks.
66
+ if (RUN_AS) {
67
+ for (const path of [WORK, HOME, TEMP, join(WORK, '.npm')]) {
68
+ chownSync(path, Number(RUN_AS[1]), Number(RUN_AS[2]));
69
+ }
70
+ }
71
+ /** Hard time cap: the box ends with or without the host. */
72
+ setTimeout(() => {
73
+ console.error(`box: time cap of ${TTL_SECONDS}s reached, exiting`);
74
+ process.exit(3);
75
+ }, TTL_SECONDS * 1000);
76
+ let state = null;
77
+ const ran = [];
78
+ // ── helpers ────────────────────────────────────────────────────────────────
79
+ function authorized(req) {
80
+ const header = String(req.headers['authorization'] ?? '');
81
+ const given = Buffer.from(header.replace(/^Bearer\s+/i, ''));
82
+ const want = Buffer.from(SECRET);
83
+ return given.length === want.length && timingSafeEqual(given, want);
84
+ }
85
+ function readJson(req, cap = 8 * 1024 * 1024) {
86
+ return new Promise((resolveBody, reject) => {
87
+ const chunks = [];
88
+ let size = 0;
89
+ req.on('data', (c) => {
90
+ size += c.length;
91
+ if (size > cap) {
92
+ reject(new Error(`body over ${cap} bytes`));
93
+ req.destroy();
94
+ return;
95
+ }
96
+ chunks.push(c);
97
+ });
98
+ req.on('end', () => {
99
+ const text = Buffer.concat(chunks).toString('utf8');
100
+ try {
101
+ resolveBody(text ? JSON.parse(text) : {});
102
+ }
103
+ catch (err) {
104
+ reject(err);
105
+ }
106
+ });
107
+ req.on('error', reject);
108
+ });
109
+ }
110
+ function json(res, status, body) {
111
+ const text = JSON.stringify(body);
112
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(text) });
113
+ res.end(text);
114
+ }
115
+ /** Bounded as bytes arrive, rather than retaining an entire build log in RAM. */
116
+ function capture(cap, mode = 'tail') {
117
+ const chunks = [];
118
+ let kept = 0;
119
+ let bytes = 0;
120
+ return {
121
+ append(chunk) {
122
+ bytes += chunk.length;
123
+ if (mode === 'head') {
124
+ const part = chunk.subarray(0, Math.max(0, cap - kept));
125
+ if (part.length) {
126
+ chunks.push(Buffer.from(part));
127
+ kept += part.length;
128
+ }
129
+ }
130
+ else {
131
+ chunks.push(Buffer.from(chunk.subarray(Math.max(0, chunk.length - cap))));
132
+ kept += chunks[chunks.length - 1].length;
133
+ while (kept > cap) {
134
+ const first = chunks[0];
135
+ const excess = kept - cap;
136
+ if (first.length <= excess) {
137
+ chunks.shift();
138
+ kept -= first.length;
139
+ }
140
+ else {
141
+ chunks[0] = first.subarray(excess);
142
+ kept -= excess;
143
+ }
144
+ }
145
+ }
146
+ },
147
+ get data() { return Buffer.concat(chunks, kept); },
148
+ get bytes() { return bytes; },
149
+ get truncated() { return bytes > cap; },
150
+ };
151
+ }
152
+ /** A path inside the checkout, or an error. */
153
+ function insideRepo(p) {
154
+ const rel = String(p ?? '').trim();
155
+ if (!rel)
156
+ throw new Error('path is required');
157
+ const abs = resolve(REPO, rel);
158
+ if (abs !== REPO && !abs.startsWith(REPO + sep))
159
+ throw new Error(`path ${rel} is outside the checkout`);
160
+ return abs;
161
+ }
162
+ const boxEnv = () => ({
163
+ PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin',
164
+ HOME,
165
+ LANG: 'C.UTF-8',
166
+ TERM: 'dumb',
167
+ CI: '1',
168
+ npm_config_cache: join(WORK, '.npm'),
169
+ npm_config_update_notifier: 'false',
170
+ npm_config_fund: 'false',
171
+ TMPDIR: TEMP, TMP: TEMP, TEMP,
172
+ ...(process.env.BOX_PROXY_URL ? {
173
+ HTTP_PROXY: process.env.BOX_PROXY_URL, HTTPS_PROXY: process.env.BOX_PROXY_URL,
174
+ http_proxy: process.env.BOX_PROXY_URL, https_proxy: process.env.BOX_PROXY_URL,
175
+ npm_config_proxy: process.env.BOX_PROXY_URL, npm_config_https_proxy: process.env.BOX_PROXY_URL,
176
+ NO_PROXY: '127.0.0.1,localhost', no_proxy: '127.0.0.1,localhost',
177
+ } : {}),
178
+ GIT_CONFIG_NOSYSTEM: '1',
179
+ GIT_TERMINAL_PROMPT: '0',
180
+ });
181
+ let cancellationEpoch = 0;
182
+ const operationEpoch = new AsyncLocalStorage();
183
+ function withOperation(operation) {
184
+ return operationEpoch.run(cancellationEpoch, operation);
185
+ }
186
+ /** Every subprocess, including Git and archive extraction, has the same boundary. */
187
+ function runAsBoxUser(argv, opts = {}) {
188
+ const epoch = operationEpoch.getStore();
189
+ if (epoch !== undefined && epoch !== cancellationEpoch) {
190
+ return Promise.reject(new Error('checkout operation cancelled'));
191
+ }
192
+ const started = Date.now();
193
+ const prefix = EXEC_PREFIX.map((arg) => arg.replaceAll('__BOX_RELAY_PORT__', String(relayPort)));
194
+ const full = [...prefix, ...argv];
195
+ return new Promise((resolveRun) => {
196
+ const child = spawn(full[0], full.slice(1), {
197
+ cwd: opts.cwd ?? REPO,
198
+ env: boxEnv(),
199
+ stdio: ['pipe', 'pipe', 'pipe'],
200
+ detached: true,
201
+ ...(RUN_AS ? { uid: Number(RUN_AS[1]), gid: Number(RUN_AS[2]) } : {}),
202
+ });
203
+ if (child.pid)
204
+ activeChildren.set(child.pid, child);
205
+ const out = capture(opts.outputCap ?? OUTPUT_CAP, opts.outputMode);
206
+ const err = capture(OUTPUT_CAP);
207
+ let timedOut = false;
208
+ const kill = () => {
209
+ try {
210
+ process.kill(-child.pid, 'SIGKILL');
211
+ }
212
+ catch {
213
+ child.kill('SIGKILL');
214
+ }
215
+ };
216
+ const timer = setTimeout(() => { timedOut = true; kill(); }, opts.timeoutMs ?? 600_000);
217
+ child.stdout.on('data', (c) => {
218
+ out.append(c);
219
+ if (opts.binary && out.truncated)
220
+ kill();
221
+ });
222
+ child.stderr.on('data', (c) => err.append(c));
223
+ child.stdin.on('error', () => { });
224
+ child.on('error', (e) => err.append(Buffer.from(e.message)));
225
+ child.on('close', (code) => {
226
+ clearTimeout(timer);
227
+ // Background children must not mutate a snapshot after the command ends.
228
+ kill();
229
+ activeChildren.delete(child.pid);
230
+ resolveRun({
231
+ exitCode: timedOut ? null : code,
232
+ stdout: opts.binary ? '' : out.data.toString('utf8'),
233
+ stderr: err.data.toString('utf8'),
234
+ stdoutBytes: out.bytes,
235
+ stdoutTruncated: out.truncated,
236
+ stderrTruncated: err.truncated,
237
+ timedOut,
238
+ durationMs: Date.now() - started,
239
+ ...(opts.binary ? { data: out.data } : {}),
240
+ });
241
+ });
242
+ child.stdin.end(opts.stdin);
243
+ });
244
+ }
245
+ const activeChildren = new Map();
246
+ function stopChildren() {
247
+ for (const pid of activeChildren.keys()) {
248
+ try {
249
+ process.kill(-pid, 'SIGKILL');
250
+ }
251
+ catch { /* already gone */ }
252
+ }
253
+ }
254
+ async function cancelChildren() {
255
+ ++cancellationEpoch;
256
+ const exits = [...activeChildren.values()].map(child => new Promise(resolve => child.once('close', () => resolve())));
257
+ stopChildren();
258
+ await Promise.all(exits);
259
+ }
260
+ process.on('exit', stopChildren);
261
+ process.on('SIGTERM', () => { stopChildren(); process.exit(0); });
262
+ async function git(args, opts = {}) {
263
+ const result = await runAsBoxUser(['git',
264
+ '-c', `user.name=${AUTHOR_NAME}`, '-c', `user.email=${AUTHOR_EMAIL}`,
265
+ '-c', 'credential.helper=', '-c', 'core.hooksPath=/dev/null', ...args,
266
+ ], { cwd: WORK, ...opts });
267
+ return { ...result, ok: result.exitCode === 0 && !result.timedOut };
268
+ }
269
+ const queued = [];
270
+ const inflight = new Map();
271
+ let waiter = null;
272
+ let relayPort = 0;
273
+ let relaySeq = 0;
274
+ const relay = createServer((req, res) => {
275
+ const item = { id: String(++relaySeq), req, res };
276
+ if (waiter) {
277
+ const w = waiter;
278
+ waiter = null;
279
+ w(item);
280
+ }
281
+ else {
282
+ queued.push(item);
283
+ }
284
+ });
285
+ function nextQueued(timeoutMs) {
286
+ const ready = queued.shift();
287
+ if (ready)
288
+ return Promise.resolve(ready);
289
+ return new Promise((resolveNext) => {
290
+ const timer = setTimeout(() => {
291
+ if (waiter === take)
292
+ waiter = null;
293
+ resolveNext(null);
294
+ }, timeoutMs);
295
+ const take = (q) => {
296
+ clearTimeout(timer);
297
+ resolveNext(q);
298
+ };
299
+ waiter = take;
300
+ });
301
+ }
302
+ async function relayNext(res) {
303
+ const item = await nextQueued(20_000);
304
+ if (!item) {
305
+ res.writeHead(204);
306
+ res.end();
307
+ return;
308
+ }
309
+ inflight.set(item.id, item);
310
+ const headers = {};
311
+ for (const name of ['content-type', 'content-encoding', 'accept', 'git-protocol']) {
312
+ const v = item.req.headers[name];
313
+ if (typeof v === 'string')
314
+ headers[name] = v;
315
+ }
316
+ res.writeHead(200, {
317
+ 'content-type': 'application/octet-stream',
318
+ 'x-relay-id': item.id,
319
+ 'x-relay-method': item.req.method ?? 'GET',
320
+ 'x-relay-path': item.req.url ?? '/',
321
+ 'x-relay-headers': JSON.stringify(headers),
322
+ });
323
+ item.req.pipe(res);
324
+ item.req.on('error', () => res.end());
325
+ }
326
+ function relayReply(id, req, res) {
327
+ const item = inflight.get(id);
328
+ if (!item) {
329
+ json(res, 404, { error: `no relayed request ${id}` });
330
+ req.resume();
331
+ return;
332
+ }
333
+ inflight.delete(id);
334
+ let headers = {};
335
+ try {
336
+ headers = JSON.parse(String(req.headers['x-relay-headers'] ?? '{}'));
337
+ }
338
+ catch {
339
+ headers = {};
340
+ }
341
+ const status = Number(req.headers['x-relay-status'] ?? 502);
342
+ item.res.writeHead(Number.isFinite(status) ? status : 502, headers);
343
+ req.pipe(item.res);
344
+ req.on('end', () => json(res, 200, { ok: true }));
345
+ req.on('error', () => { item.res.end(); json(res, 500, { error: 'reply stream failed' }); });
346
+ }
347
+ // ── routes ─────────────────────────────────────────────────────────────────
348
+ async function setup(body) {
349
+ if (state)
350
+ throw new Error('already set up');
351
+ const repository = String(body['repository'] ?? '');
352
+ const [owner, name] = repository.split('/');
353
+ if (!owner || !name)
354
+ throw new Error('repository must be owner/name');
355
+ const requestedBase = body['baseBranch'] ? String(body['baseBranch']) : null;
356
+ const branch = String(body['branch'] ?? '');
357
+ if (!/^agent\/[a-z0-9._-]+$/.test(branch))
358
+ throw new Error('branch must be agent/<task id>');
359
+ const remote = `http://127.0.0.1:${relayPort}/${owner}/${name}.git`;
360
+ const clone = await git(['clone', '--no-tags', '--single-branch', ...(requestedBase ? ['--branch', requestedBase] : []), remote, REPO], { timeoutMs: 10 * 60_000 });
361
+ if (!clone.ok)
362
+ throw new Error(`clone of ${repository}${requestedBase ? ` at ${requestedBase}` : ''} failed: ${clone.stderr.trim() || clone.stdout.trim()}`);
363
+ let baseBranch = requestedBase;
364
+ if (!baseBranch) {
365
+ const head = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: REPO });
366
+ baseBranch = head.ok ? head.stdout.trim().replace(/^origin\//, '') : null;
367
+ if (!baseBranch) {
368
+ const current = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: REPO });
369
+ baseBranch = current.ok ? current.stdout.trim() : null;
370
+ }
371
+ if (!baseBranch)
372
+ throw new Error('could not tell the default branch');
373
+ }
374
+ // An earlier wake may have pushed this task's branch already: continue it.
375
+ const fetched = await git(['fetch', '--no-tags', 'origin', `${branch}:refs/remotes/origin/${branch}`], { cwd: REPO, timeoutMs: 5 * 60_000 });
376
+ const resumed = fetched.ok;
377
+ const checkout = resumed
378
+ ? await git(['checkout', '-B', branch, `origin/${branch}`], { cwd: REPO })
379
+ : await git(['checkout', '-b', branch], { cwd: REPO });
380
+ if (!checkout.ok)
381
+ throw new Error(`checkout of ${branch} failed: ${checkout.stderr.trim()}`);
382
+ const head = await git(['rev-parse', 'HEAD'], { cwd: REPO });
383
+ state = { repository, baseBranch, branch };
384
+ return { repository, baseBranch, branch, head: head.stdout.trim(), resumed };
385
+ }
386
+ async function read(body) {
387
+ if (!state)
388
+ throw new Error('not set up');
389
+ const abs = insideRepo(body['path']);
390
+ const r = await runAsBoxUser(['cat', abs], { timeoutMs: 30_000, outputCap: READ_CAP, outputMode: 'head' });
391
+ if (r.exitCode !== 0)
392
+ throw new Error(r.stderr.trim() || `cannot read ${String(body['path'])}`);
393
+ const truncated = r.stdoutTruncated;
394
+ return {
395
+ path: String(body['path']),
396
+ content: r.stdout,
397
+ bytes: r.stdoutBytes,
398
+ truncated,
399
+ };
400
+ }
401
+ async function write(body) {
402
+ if (!state)
403
+ throw new Error('not set up');
404
+ const abs = insideRepo(body['path']);
405
+ const content = String(body['content'] ?? '');
406
+ const r = await runAsBoxUser([SHELL, '-c', 'mkdir -p "$(dirname "$1")" && cat > "$1"', 'write', abs], { stdin: content, timeoutMs: 30_000 });
407
+ if (r.exitCode !== 0)
408
+ throw new Error(r.stderr.trim() || `cannot write ${String(body['path'])}`);
409
+ return { path: String(body['path']), bytes: Buffer.byteLength(content) };
410
+ }
411
+ async function run(body) {
412
+ if (!state)
413
+ throw new Error('not set up');
414
+ const command = String(body['command'] ?? '').trim();
415
+ if (!command)
416
+ throw new Error('command is required');
417
+ const remainingMs = TTL_SECONDS * 1000 - (Date.now() - STARTED_AT) - 5_000;
418
+ const askedMs = Math.max(1, Math.min(Number(body['timeoutSeconds'] ?? 600), 1800)) * 1000;
419
+ const r = await runAsBoxUser([SHELL, '-c', command], { timeoutMs: Math.max(1_000, Math.min(askedMs, remainingMs)) });
420
+ ran.push({ command, exitCode: r.exitCode });
421
+ return { command, ...r };
422
+ }
423
+ async function push(body) {
424
+ if (!state)
425
+ throw new Error('not set up');
426
+ const message = String(body['message'] ?? '').trim() || `Change for task ${state.branch.replace(/^agent\//, '')}`;
427
+ const add = await git(['add', '-A'], { cwd: REPO });
428
+ if (!add.ok)
429
+ throw new Error(`git add failed: ${add.stderr.trim()}`);
430
+ const staged = await git(['diff', '--cached', '--quiet'], { cwd: REPO });
431
+ let commit = null;
432
+ if (!staged.ok) {
433
+ const c = await git(['commit', '-q', '-m', message], { cwd: REPO });
434
+ if (!c.ok)
435
+ throw new Error(`git commit failed: ${c.stderr.trim() || c.stdout.trim()}`);
436
+ }
437
+ const head = await git(['rev-parse', 'HEAD'], { cwd: REPO });
438
+ commit = head.ok ? head.stdout.trim() : null;
439
+ const changed = await git(['diff', '--name-only', `origin/${state.baseBranch}...HEAD`], { cwd: REPO });
440
+ const filesChanged = changed.ok ? changed.stdout.split('\n').map((l) => l.trim()).filter(Boolean) : [];
441
+ if (!filesChanged.length) {
442
+ return { branch: state.branch, commit, filesChanged, pushed: false, ran, note: 'nothing changed against the base branch; nothing pushed' };
443
+ }
444
+ // A branch an earlier wake pushed, with nothing new on it, needs no push.
445
+ const remote = await git(['rev-parse', '--verify', '-q', `refs/remotes/origin/${state.branch}`], { cwd: REPO });
446
+ if (staged.ok && remote.ok && remote.stdout.trim() === commit) {
447
+ return { branch: state.branch, commit, filesChanged, pushed: false, ran, note: 'nothing new since the branch was last pushed' };
448
+ }
449
+ const pushed = await git(['push', '-q', 'origin', `HEAD:refs/heads/${state.branch}`], { cwd: REPO, timeoutMs: 10 * 60_000 });
450
+ if (!pushed.ok)
451
+ throw new Error(`git push failed: ${pushed.stderr.trim() || pushed.stdout.trim()}`);
452
+ return { branch: state.branch, commit, filesChanged, pushed: true, ran };
453
+ }
454
+ async function checkpoint() {
455
+ if (activeChildren.size)
456
+ throw new Error('cannot checkpoint while a command is active');
457
+ if (!state)
458
+ throw new Error('not set up');
459
+ const archive = await runAsBoxUser(['tar', '-czf', '-',
460
+ '--exclude=./node_modules', '--exclude=*/node_modules', '--exclude=./.npm',
461
+ '--exclude=./.git/index.lock', '--exclude=./.git/config.lock',
462
+ '--exclude=./.git/packed-refs.lock', '--exclude=./.git/shallow.lock', '--exclude=./.git/refs/*.lock',
463
+ '-C', REPO, '.'], { binary: true, outputCap: CHECKPOINT_CAP, timeoutMs: 120_000 });
464
+ if (archive.exitCode !== 0 || archive.stdoutTruncated || !archive.data) {
465
+ throw new Error(`checkpoint failed (maximum ${CHECKPOINT_CAP} bytes): ${archive.stderr}`);
466
+ }
467
+ return archive.data;
468
+ }
469
+ async function restore(bytes) {
470
+ if (!state)
471
+ throw new Error('not set up');
472
+ const saved = state;
473
+ const clear = await runAsBoxUser(['rm', '-rf', REPO], { cwd: WORK });
474
+ if (clear.exitCode !== 0)
475
+ throw new Error('could not clear the fresh checkout');
476
+ const extract = await runAsBoxUser([SHELL, '-c',
477
+ 'mkdir -p "$1" && tar -xzf - --no-same-owner --no-same-permissions -C "$1"', 'restore', REPO], { cwd: WORK, stdin: bytes, timeoutMs: 120_000 });
478
+ if (extract.exitCode !== 0)
479
+ throw new Error(`could not restore checkpoint: ${extract.stderr}`);
480
+ const remote = `http://127.0.0.1:${relayPort}/${saved.repository}.git`;
481
+ const reset = await git(['config', '--local', '--replace-all', 'remote.origin.url', remote], { cwd: REPO });
482
+ if (!reset.ok)
483
+ throw new Error('checkpoint contains no usable Git checkout');
484
+ }
485
+ function readBytes(req, cap) {
486
+ return new Promise((resolveBody, reject) => {
487
+ const chunks = [];
488
+ let size = 0;
489
+ req.on('data', (chunk) => {
490
+ size += chunk.length;
491
+ if (size > cap) {
492
+ reject(new Error('checkpoint too large'));
493
+ req.destroy();
494
+ }
495
+ else
496
+ chunks.push(chunk);
497
+ });
498
+ req.on('end', () => resolveBody(Buffer.concat(chunks)));
499
+ req.on('error', reject);
500
+ });
501
+ }
502
+ const server = createServer(async (req, res) => {
503
+ const url = new URL(req.url ?? '/', 'http://box');
504
+ if (!authorized(req)) {
505
+ req.resume();
506
+ json(res, 401, { error: 'box: missing or wrong secret' });
507
+ return;
508
+ }
509
+ try {
510
+ if (req.method === 'GET' && url.pathname === '/health') {
511
+ json(res, 200, { ok: true, ready: !!state, uptimeMs: Date.now() - STARTED_AT, ttlSeconds: TTL_SECONDS });
512
+ return;
513
+ }
514
+ if (req.method === 'GET' && url.pathname === '/relay/next') {
515
+ await relayNext(res);
516
+ return;
517
+ }
518
+ const reply = /^\/relay\/([^/]+)$/.exec(url.pathname);
519
+ if (req.method === 'POST' && reply) {
520
+ relayReply(decodeURIComponent(reply[1]), req, res);
521
+ return;
522
+ }
523
+ if (req.method !== 'POST') {
524
+ json(res, 404, { error: 'not found' });
525
+ return;
526
+ }
527
+ if (url.pathname === '/checkpoint') {
528
+ req.resume();
529
+ const data = await withOperation(checkpoint);
530
+ res.writeHead(200, { 'content-type': 'application/gzip', 'content-length': data.length });
531
+ res.end(data);
532
+ return;
533
+ }
534
+ if (url.pathname === '/restore') {
535
+ const bytes = await readBytes(req, CHECKPOINT_CAP);
536
+ await withOperation(() => restore(bytes));
537
+ json(res, 200, { ok: true });
538
+ return;
539
+ }
540
+ const body = await readJson(req);
541
+ switch (url.pathname) {
542
+ case '/cancel':
543
+ await cancelChildren();
544
+ json(res, 200, { ok: true });
545
+ return;
546
+ case '/setup':
547
+ json(res, 200, await withOperation(() => setup(body)));
548
+ return;
549
+ case '/read':
550
+ json(res, 200, await withOperation(() => read(body)));
551
+ return;
552
+ case '/write':
553
+ json(res, 200, await withOperation(() => write(body)));
554
+ return;
555
+ case '/run':
556
+ json(res, 200, await withOperation(() => run(body)));
557
+ return;
558
+ case '/push':
559
+ json(res, 200, await withOperation(() => push(body)));
560
+ return;
561
+ case '/stop':
562
+ json(res, 200, { ok: true });
563
+ setTimeout(() => process.exit(0), 100);
564
+ return;
565
+ default:
566
+ json(res, 404, { error: 'not found' });
567
+ }
568
+ }
569
+ catch (err) {
570
+ json(res, 400, { error: err.message });
571
+ }
572
+ });
573
+ server.keepAliveTimeout = 65_000;
574
+ server.headersTimeout = 70_000;
575
+ relay.listen(0, '127.0.0.1', () => {
576
+ relayPort = relay.address().port;
577
+ server.listen(PORT, BIND, () => {
578
+ const port = server.address().port;
579
+ console.log(`BOX_READY ${JSON.stringify({ port, relayPort })}`);
580
+ });
581
+ });
@@ -0,0 +1,86 @@
1
+ import type { CheckoutBoxEndpoint, CheckoutPushResult, CheckoutRunResult, CheckoutTarget } from './types.js';
2
+ /**
3
+ * The backend door the box's git traffic goes through, and the grant it is
4
+ * spent under. The host presents its own operator credentials; the box never
5
+ * sees them, and the connection owner's token never leaves the backend.
6
+ */
7
+ export interface GitDoorTarget {
8
+ backendUrl: string;
9
+ operatorKey: string;
10
+ agentId: string;
11
+ laneId?: string;
12
+ connectionId: string;
13
+ grantId: string;
14
+ taskId: string;
15
+ repository: Pick<CheckoutTarget, 'owner' | 'name'>;
16
+ }
17
+ /** The door path for one of the box's git requests, or null when the request names another repository. */
18
+ export declare function doorPathFor(boxPath: string, door: Pick<GitDoorTarget, 'connectionId' | 'repository'>): string | null;
19
+ export declare function doorHeaders(door: GitDoorTarget, gitHeaders: Record<string, string>): Record<string, string>;
20
+ export declare class CheckoutBoxError extends Error {
21
+ readonly status?: number | undefined;
22
+ constructor(message: string, status?: number | undefined);
23
+ }
24
+ /**
25
+ * The host's end of the channel. One per box. Every call carries the box's
26
+ * secret; the box answers nothing without it.
27
+ */
28
+ export declare class CheckoutBoxClient {
29
+ private readonly endpoint;
30
+ private readonly fetchFn;
31
+ private relayStopped;
32
+ private relayLoop;
33
+ private relayAbort;
34
+ private readonly forwarding;
35
+ private mutation;
36
+ private closing;
37
+ onMutation?: () => Promise<void>;
38
+ checkpointWarning?: string;
39
+ warnCheckpoint(message: string): void;
40
+ private withCheckpointWarning;
41
+ private mutate;
42
+ quiesce(): Promise<void>;
43
+ checkpoint(): Promise<Uint8Array>;
44
+ restore(data: Uint8Array): Promise<void>;
45
+ constructor(endpoint: CheckoutBoxEndpoint, fetchFn?: typeof fetch);
46
+ private auth;
47
+ private call;
48
+ /** Poll the box until it answers, or give up. */
49
+ waitReady(timeoutMs: number): Promise<void>;
50
+ /**
51
+ * Forward the box's git requests to the door, one at a time as git makes
52
+ * them, until {@link stopRelay}. The box queues what git asks; this end
53
+ * long-polls, performs each request against the door under the hire's
54
+ * grant, and posts the answer back for git to read.
55
+ */
56
+ startRelay(door: GitDoorTarget): void;
57
+ stopRelay(): Promise<void>;
58
+ private relay;
59
+ private forward;
60
+ setup(spec: {
61
+ repository: string;
62
+ baseBranch: string | null;
63
+ branch: string;
64
+ }): Promise<{
65
+ baseBranch: string;
66
+ branch: string;
67
+ head: string;
68
+ resumed: boolean;
69
+ }>;
70
+ read(path: string): Promise<{
71
+ path: string;
72
+ content: string;
73
+ bytes: number;
74
+ truncated: boolean;
75
+ checkpointWarning?: string;
76
+ }>;
77
+ write(path: string, content: string): Promise<{
78
+ path: string;
79
+ bytes: number;
80
+ checkpointWarning?: string;
81
+ }>;
82
+ run(command: string, timeoutSeconds?: number): Promise<CheckoutRunResult>;
83
+ push(message: string): Promise<CheckoutPushResult>;
84
+ /** Ask the box to exit. Best effort: a box that is already gone is the goal. */
85
+ stop(): Promise<void>;
86
+ }