@staix/agent-hub 0.17.0 → 0.19.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/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
package/src/hub/check-runner.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { constants, closeSync, fstatSync, openSync, readFileSync } from "node:fs";
|
|
4
|
-
import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
5
7
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
8
|
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
9
|
+
import { stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
7
10
|
import { hostExecutable } from "./host-executable.ts";
|
|
8
11
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
9
12
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -21,7 +24,7 @@ export interface CheckRequest {
|
|
|
21
24
|
}
|
|
22
25
|
export interface RunReceipt extends CheckRequest {
|
|
23
26
|
run: string; sha: string; tree: string; lockfile: string; excluded: number; dependency_files: number;
|
|
24
|
-
runner: "container"; image: string; platform: string; container: string;
|
|
27
|
+
runner: "container" | "remote"; image: string; platform: string; container: string;
|
|
25
28
|
hub_pid: number; hub_signature: string; created: number;
|
|
26
29
|
status: "queued" | "running" | "finished" | "stale";
|
|
27
30
|
exit: number | null; timed_out: boolean; cancelled: boolean; interrupted: boolean;
|
|
@@ -29,9 +32,9 @@ export interface RunReceipt extends CheckRequest {
|
|
|
29
32
|
error?: string;
|
|
30
33
|
}
|
|
31
34
|
export interface RunHandle { run: string; done: Promise<RunReceipt> }
|
|
32
|
-
export interface CommandResult { code: number | null; stdout: string }
|
|
35
|
+
export interface CommandResult { code: number | null; stdout: string; outputBytes?: number }
|
|
33
36
|
/** Fixed-argv transport seam. The production implementation below is the only container spawner. */
|
|
34
|
-
export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void }) => Promise<CommandResult>;
|
|
37
|
+
export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void; input?: ReadableStream<Uint8Array> }) => Promise<CommandResult>;
|
|
35
38
|
export interface RunnerOptions {
|
|
36
39
|
projectDir: string; stateDir: string; storageDir: string; projectKey: string;
|
|
37
40
|
enabled: boolean | (() => boolean); isDenied: (path: string) => boolean;
|
|
@@ -39,10 +42,27 @@ export interface RunnerOptions {
|
|
|
39
42
|
/** Daemon authority fence, re-evaluated across every asynchronous stage. */
|
|
40
43
|
isCurrent?: (request: CheckRequest) => boolean;
|
|
41
44
|
/** Test seams; never supplied from a tool request or config. */
|
|
42
|
-
command?: ContainerCommand; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
|
|
45
|
+
command?: ContainerCommand; remote?: RemoteRunnerConfig; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
|
|
43
46
|
schedule?: (callback: () => void, ms: number) => () => void;
|
|
44
47
|
now?: () => number; liveness?: typeof processLiveness; signature?: typeof processSignature;
|
|
45
48
|
}
|
|
49
|
+
export interface RemoteRunnerConfig { host: string; image: string; root: string }
|
|
50
|
+
/** Only the backend mechanics vary; task authority, staging, receipts and leases remain one lifecycle. */
|
|
51
|
+
interface RunnerTransport {
|
|
52
|
+
command: ContainerCommand;
|
|
53
|
+
kind: "remote"; image: string; platform: string;
|
|
54
|
+
probe(command: ContainerCommand, cleanup: ContainerCommand): Promise<RunnerState>;
|
|
55
|
+
isolate(command: ContainerCommand): Promise<void>;
|
|
56
|
+
runArgs(receipt: RunReceipt, labels: string[], volume?: string): string[];
|
|
57
|
+
copy(command: ContainerCommand, source: string, container: string, signal: AbortSignal): Promise<void>;
|
|
58
|
+
/** Remote only: bring the lockfile-keyed dependency volume to a verified prepared state and return its name. */
|
|
59
|
+
prepareDeps?(command: ContainerCommand, receipt: RunReceipt, source: string, signal: AbortSignal, labels: string[]): Promise<string>;
|
|
60
|
+
execArgs(container: string, words: readonly string[]): string[];
|
|
61
|
+
cleanup(command: ContainerCommand, name: string): Promise<boolean>;
|
|
62
|
+
listArgs: string[];
|
|
63
|
+
list(stdout: string): unknown[];
|
|
64
|
+
redact(text: string): string;
|
|
65
|
+
}
|
|
46
66
|
interface Entry { receipt: RunReceipt; resolve: (receipt: RunReceipt) => void; abort: AbortController }
|
|
47
67
|
function safePath(path: string): boolean {
|
|
48
68
|
return !!path && !isAbsolute(path) && !path.includes("\\") && !path.includes("\0") && path.split("/").every(p => p !== ".." && p !== "." && p !== "");
|
|
@@ -93,6 +113,7 @@ function runContainer(executable: string, args: string[], options: { signal?: Ab
|
|
|
93
113
|
export class ContainerRunner {
|
|
94
114
|
private readonly command: ContainerCommand;
|
|
95
115
|
private readonly entries = new Map<string, Entry>();
|
|
116
|
+
private readonly saves = new Map<string, Promise<void>>();
|
|
96
117
|
private readonly queue: Entry[] = [];
|
|
97
118
|
private active?: Entry;
|
|
98
119
|
private closing = false;
|
|
@@ -108,9 +129,9 @@ export class ContainerRunner {
|
|
|
108
129
|
private readonly records: string;
|
|
109
130
|
private readonly root: string;
|
|
110
131
|
private readonly retentionTimer: ReturnType<typeof setInterval>;
|
|
111
|
-
constructor(private readonly options: RunnerOptions) {
|
|
132
|
+
constructor(private readonly options: RunnerOptions, private readonly transport?: RunnerTransport) {
|
|
112
133
|
if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
|
|
113
|
-
this.command = options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
|
|
134
|
+
this.command = transport?.command ?? options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
|
|
114
135
|
this.now = options.now ?? Date.now;
|
|
115
136
|
this.signature = (options.signature ?? processSignature)(process.pid) ?? "";
|
|
116
137
|
this.records = join(options.stateDir, "checks");
|
|
@@ -153,6 +174,15 @@ export class ContainerRunner {
|
|
|
153
174
|
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
154
175
|
return result;
|
|
155
176
|
};
|
|
177
|
+
if (this.transport) {
|
|
178
|
+
value = await this.transport.probe(async (args, options) => {
|
|
179
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
180
|
+
const result = await this.command(args, { ...options, signal, timeoutMs: 15_000 });
|
|
181
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
182
|
+
return result;
|
|
183
|
+
}, (args, options) => this.command(args, { ...options, timeoutMs: 15_000 }));
|
|
184
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
185
|
+
} else {
|
|
156
186
|
const version = await probe(["--version"]);
|
|
157
187
|
const service = await probe(["system", "status"]);
|
|
158
188
|
const image = await probe(["image", "inspect", CHECK_IMAGE]);
|
|
@@ -164,7 +194,8 @@ export class ContainerRunner {
|
|
|
164
194
|
: network.code === 0 && !isolatedNetwork(network.stdout)
|
|
165
195
|
? { available: false, reason: "network isolation unverified" }
|
|
166
196
|
: { available: true, platform: `linux/arm64 (apple-container ${version.stdout.trim().slice(0, 80)})` };
|
|
167
|
-
|
|
197
|
+
}
|
|
198
|
+
} catch { if (this.closing || signal.aborted) return { available: false, reason: this.closing ? "stopping" : "check stopped" }; value = { available: false, reason: this.transport ? "remote runner unavailable" : "container unavailable" }; }
|
|
168
199
|
const checkedAt = this.now();
|
|
169
200
|
value.checkedAt = checkedAt;
|
|
170
201
|
this.availableCache = { at: checkedAt, value };
|
|
@@ -179,7 +210,7 @@ export class ContainerRunner {
|
|
|
179
210
|
if (this.entries.size >= 3) throw new PreEffectToolRefusal("busy");
|
|
180
211
|
const run = randomUUID();
|
|
181
212
|
const receipt: RunReceipt = { ...request, files: request.files ? [...request.files] : undefined, run, sha: "", tree: "", lockfile: "", excluded: 0, dependency_files: 0,
|
|
182
|
-
runner: "container", image: CHECK_IMAGE, platform: "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
|
|
213
|
+
runner: this.transport?.kind ?? "container", image: this.transport?.image ?? CHECK_IMAGE, platform: this.transport?.platform ?? "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
|
|
183
214
|
timed_out: false, cancelled: false, interrupted: false, duration_ms: 0, tail: "", output_bytes: 0, truncated: false, output_withheld: false };
|
|
184
215
|
let resolveResult!: (receipt: RunReceipt) => void;
|
|
185
216
|
const done = new Promise<RunReceipt>(r => { resolveResult = r; });
|
|
@@ -205,11 +236,25 @@ export class ContainerRunner {
|
|
|
205
236
|
await this.save(receipt);
|
|
206
237
|
if (entry === this.active && entry) await this.cleanup(receipt.container);
|
|
207
238
|
}
|
|
208
|
-
|
|
239
|
+
/**
|
|
240
|
+
* Saves of one run's receipt go one at a time, in the order they were asked for, and each turn serializes the receipt when it runs, not when it was asked (#867): the run's own
|
|
241
|
+
* completion path and `markStale` save the same receipt concurrently, and an earlier snapshot written last must not undo a stale mark. Each write has its own temp name, so two
|
|
242
|
+
* writers never share one, and a failed write or rename removes its temp. A failed save rejects only its own caller; the next turn still runs.
|
|
243
|
+
*/
|
|
244
|
+
private save(receipt: RunReceipt): Promise<void> {
|
|
245
|
+
const run = receipt.run;
|
|
246
|
+
const turn = (this.saves.get(run) ?? Promise.resolve()).then(() => this.write(receipt));
|
|
247
|
+
const tail = turn.catch(() => {});
|
|
248
|
+
this.saves.set(run, tail);
|
|
249
|
+
void tail.then(() => { if (this.saves.get(run) === tail) this.saves.delete(run); });
|
|
250
|
+
return turn;
|
|
251
|
+
}
|
|
252
|
+
private async write(receipt: RunReceipt): Promise<void> {
|
|
209
253
|
await mkdir(this.records, { recursive: true, mode: 0o700 });
|
|
210
254
|
const path = join(this.records, `${receipt.run}.json`);
|
|
211
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
212
|
-
await writeFile(temp, JSON.stringify(receipt), { mode: 0o600 }); await rename(temp, path);
|
|
255
|
+
const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
256
|
+
try { await writeFile(temp, JSON.stringify(receipt), { mode: 0o600 }); await rename(temp, path); }
|
|
257
|
+
catch (error) { await rm(temp, { force: true }).catch(() => {}); throw error; }
|
|
213
258
|
}
|
|
214
259
|
private async drain(): Promise<void> {
|
|
215
260
|
if (this.active) return;
|
|
@@ -282,13 +327,23 @@ export class ContainerRunner {
|
|
|
282
327
|
if (data.status !== 0 || data.error) throw new Error("source blob unavailable");
|
|
283
328
|
const blob = Buffer.from(data.stdout);
|
|
284
329
|
bytes += blob.length; if (bytes > 512 * 1024 * 1024) throw new Error("source exceeds byte limit");
|
|
285
|
-
const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: entry.mode === "100755" ? 0o700 : 0o600 });
|
|
330
|
+
const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: this.transport ? entry.mode === "100755" ? 0o755 : 0o644 : entry.mode === "100755" ? 0o700 : 0o600 });
|
|
331
|
+
if (this.transport) {
|
|
332
|
+
// chmod is intentional: the hub may have umask 077, but the remote non-root uid must traverse/read.
|
|
333
|
+
await chmod(dest, entry.mode === "100755" ? 0o755 : 0o644);
|
|
334
|
+
for (let directory = dirname(dest); directory !== dirname(src); directory = dirname(directory)) await chmod(directory, 0o755);
|
|
335
|
+
}
|
|
336
|
+
check(); paths.add(entry.path);
|
|
286
337
|
}
|
|
287
338
|
if (receipt.files?.some(file => !paths.has(file))) throw new Error("test file unavailable in snapshot");
|
|
288
339
|
if (!paths.has("bun.lock")) throw new Error("lockfile unavailable");
|
|
289
340
|
const lock = await readFile(join(src, "bun.lock")); check();
|
|
290
|
-
const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
|
|
291
341
|
receipt.lockfile = hash(lock);
|
|
342
|
+
if (this.transport) {
|
|
343
|
+
// Source only: Mac dependencies must never be transported to a Linux runner.
|
|
344
|
+
await chmod(src, 0o755); check(); return;
|
|
345
|
+
}
|
|
346
|
+
const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
|
|
292
347
|
if (receipt.lockfile !== hash(hostLock)) throw new Error("lockfile differs; dependencies unavailable");
|
|
293
348
|
const deps = join(this.options.projectDir, "node_modules");
|
|
294
349
|
const dest = join(src, "node_modules");
|
|
@@ -333,7 +388,8 @@ export class ContainerRunner {
|
|
|
333
388
|
try {
|
|
334
389
|
this.ensureCurrent(entry);
|
|
335
390
|
await this.save(r); this.ensureCurrent(entry);
|
|
336
|
-
const state = await this.available(entry.abort.signal); this.ensureCurrent(entry);
|
|
391
|
+
const state = await this.available(entry.abort.signal); this.ensureCurrent(entry);
|
|
392
|
+
if (!state.available) { if (this.transport && state.reason === "remote runner unavailable") throw new RemoteTransportError(); throw new Error(`runner unavailable: ${state.reason}`); }
|
|
337
393
|
r.platform = state.platform!;
|
|
338
394
|
release = await this.heavy(entry); this.ensureCurrent(entry);
|
|
339
395
|
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
@@ -346,43 +402,57 @@ export class ContainerRunner {
|
|
|
346
402
|
if (!entry.abort.signal.aborted && this.now() >= deadline) expire();
|
|
347
403
|
if (entry.abort.signal.aborted) throw new Error("check stopped");
|
|
348
404
|
};
|
|
349
|
-
const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void) => {
|
|
405
|
+
const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void, input?: ReadableStream<Uint8Array>) => {
|
|
350
406
|
checkRunning();
|
|
351
|
-
const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}) });
|
|
407
|
+
const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}), ...(input ? { input } : {}) });
|
|
352
408
|
checkRunning(); // A transport may return success after cancellation; it cannot authorize another stage.
|
|
353
409
|
return result;
|
|
354
410
|
};
|
|
411
|
+
await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); checkRunning();
|
|
355
412
|
await this.snapshot(r, src, entry.abort.signal, checkRunning);
|
|
356
413
|
this.ensureCurrent(entry);
|
|
357
414
|
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
415
|
+
const transportCommand: ContainerCommand = (args, options) => command(args, options?.timeoutMs, options?.output, options?.input);
|
|
416
|
+
if (this.transport) await this.transport.isolate(transportCommand);
|
|
417
|
+
else {
|
|
358
418
|
const network = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
|
|
359
419
|
if (network.code !== 0 && (await command(["network", "create", "--internal", NETWORK])).code !== 0) throw new Error("runner unavailable: internal network unavailable");
|
|
360
420
|
this.ensureCurrent(entry);
|
|
361
421
|
// Refuse an existing non-internal network rather than trusting its name.
|
|
362
422
|
const info = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
|
|
363
423
|
if (info.code !== 0 || !isolatedNetwork(info.stdout)) throw new Error("runner unavailable: network isolation unverified");
|
|
424
|
+
}
|
|
364
425
|
r.status = "running"; await this.save(r); this.ensureCurrent(entry);
|
|
365
426
|
attemptedContainer = true;
|
|
366
427
|
// Cold VM startup and a <=1.5 GiB source/dependency transfer need their own bounded allowances.
|
|
367
|
-
const
|
|
428
|
+
const labels = ["--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`];
|
|
429
|
+
// Preparation and verification containers reuse this run's name and labels, so cancel, timeout, shutdown, cleanup
|
|
430
|
+
// confirmation and the orphan sweep need no second ownership path. Each one is confirmed gone before the check starts.
|
|
431
|
+
const volume = await this.transport?.prepareDeps?.(transportCommand, r, src, entry.abort.signal, labels); this.ensureCurrent(entry);
|
|
432
|
+
const run = await command(this.transport?.runArgs(r, labels, volume) ?? ["run", "--detach", "--rm", "--name", r.container, ...labels, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
|
|
368
433
|
this.ensureCurrent(entry);
|
|
369
|
-
if (run.code !== 0) throw new Error("runner unavailable: container start failed");
|
|
370
|
-
|
|
434
|
+
if (run.code !== 0) { if (this.transport) throw new RemoteTransportError(); throw new Error("runner unavailable: container start failed"); }
|
|
435
|
+
if (this.transport) await this.transport.copy(transportCommand, src, r.container, entry.abort.signal);
|
|
436
|
+
else {
|
|
437
|
+
const copy = await command(["cp", `${src}/.`, `${r.container}:/work`], 180_000);
|
|
438
|
+
if (copy.code !== 0) throw new Error("container source transfer failed");
|
|
439
|
+
}
|
|
371
440
|
this.ensureCurrent(entry);
|
|
372
|
-
if (copy.code !== 0) throw new Error("container source transfer failed");
|
|
373
441
|
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
374
442
|
const words = r.check === "test" ? ["bun", "test", ...r.files!] : ["bun", "x", "--no-install", "tsc", "--noEmit"];
|
|
375
|
-
const result = await command(["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
|
|
443
|
+
const result = await command(this.transport?.execArgs(r.container, words) ?? ["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
|
|
376
444
|
r.output_bytes += data.length;
|
|
377
445
|
if (output.length < MAX_OUTPUT) output = Buffer.concat([output, data.subarray(0, MAX_OUTPUT - output.length)]);
|
|
378
446
|
outputTail = Buffer.concat([outputTail, data]).subarray(-16_384);
|
|
379
447
|
r.truncated = r.output_bytes > MAX_OUTPUT;
|
|
380
448
|
});
|
|
381
449
|
this.ensureCurrent(entry);
|
|
450
|
+
if (this.transport && result.code === 255) { r.output_withheld = true; throw new RemoteTransportError(); }
|
|
451
|
+
if (result.outputBytes !== undefined) { r.output_bytes = result.outputBytes; r.truncated = r.output_bytes > MAX_OUTPUT; }
|
|
382
452
|
r.exit = entry.abort.signal.aborted ? null : result.code;
|
|
383
453
|
} catch (error) {
|
|
384
454
|
const code = (error as NodeJS.ErrnoException)?.code;
|
|
385
|
-
r.error = entry.abort.signal.aborted ? "check stopped" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
|
|
455
|
+
r.error = entry.abort.signal.aborted ? "check stopped" : error instanceof RemoteTransportError ? "remote runner unavailable" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
|
|
386
456
|
}
|
|
387
457
|
finally {
|
|
388
458
|
cancelDeadline?.();
|
|
@@ -391,8 +461,11 @@ export class ContainerRunner {
|
|
|
391
461
|
if (!removed) { r.error = "container cleanup unconfirmed; heavy slot retained"; r.exit = null; }
|
|
392
462
|
await rm(src, { recursive: true, force: true }).catch(() => {});
|
|
393
463
|
if (release && removed) await release().catch(() => {});
|
|
394
|
-
|
|
464
|
+
const rawTail = outputTail.toString("utf8"), safeTail = this.transport?.redact(rawTail) ?? rawTail;
|
|
465
|
+
r.output_withheld ||= safeTail !== rawTail;
|
|
466
|
+
r.duration_ms = Math.max(0, this.now() - begun); r.tail = tail(safeTail, r.output_bytes > outputTail.length); if (r.status !== "stale") r.status = "finished";
|
|
395
467
|
if (!this.current(r)) { r.status = "stale"; }
|
|
468
|
+
if (this.transport) { const raw = output.toString("utf8"), safe = this.transport.redact(raw); r.output_withheld ||= safe !== raw; output = Buffer.from(safe); }
|
|
396
469
|
try { await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, "output.log"), r.truncated ? Buffer.concat([output.subarray(0, MAX_OUTPUT - 7), Buffer.from("\n[cut]\n")]) : output, { mode: 0o600 }); await this.save(r); } catch { r.error = "check receipt storage unavailable"; r.tail = ""; r.output_withheld = true; }
|
|
397
470
|
await this.maintain(); // Maintenance never changes an outcome already saved for this run.
|
|
398
471
|
if (!this.current(r)) { r.status = "stale"; await this.save(r).catch(() => {}); }
|
|
@@ -400,6 +473,7 @@ export class ContainerRunner {
|
|
|
400
473
|
}
|
|
401
474
|
}
|
|
402
475
|
private async cleanup(name: string): Promise<boolean> {
|
|
476
|
+
if (this.transport) return this.transport.cleanup(this.command, name);
|
|
403
477
|
await this.command(["kill", name]).catch(() => {});
|
|
404
478
|
await this.command(["rm", name]).catch(() => {});
|
|
405
479
|
try {
|
|
@@ -458,13 +532,18 @@ export class ContainerRunner {
|
|
|
458
532
|
if (!this.enabled()) { await this.maintain(); return unknown; }
|
|
459
533
|
try {
|
|
460
534
|
let containers: unknown;
|
|
461
|
-
try { const list = await this.command(["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = JSON.parse(list.stdout); } catch { return unknown; }
|
|
535
|
+
try { const list = await this.command(this.transport?.listArgs ?? ["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = this.transport?.list(list.stdout) ?? JSON.parse(list.stdout); } catch { return unknown; }
|
|
462
536
|
if (!Array.isArray(containers)) return unknown;
|
|
463
537
|
for (const container of containers) {
|
|
464
538
|
const c = container as { id?: string; configuration?: { id?: string; labels?: Record<string, string> }; labels?: Record<string, string> };
|
|
465
539
|
const labels = c.configuration?.labels ?? c.labels;
|
|
466
540
|
if (labels?.["agenthub.project"] !== this.options.projectKey) continue;
|
|
467
541
|
const name = c.configuration?.id ?? c.id;
|
|
542
|
+
const probe = labels["agenthub.probe"];
|
|
543
|
+
// Probes have no task receipt and self-expire after the fixed 600-second idle process.
|
|
544
|
+
// Recognizing their exact shape is no authority to kill them or other unknown owners.
|
|
545
|
+
if (this.transport && typeof probe === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(probe)
|
|
546
|
+
&& name === `agenthub-check-probe-${probe}` && !labels["agenthub.run"]) continue;
|
|
468
547
|
const run = labels["agenthub.run"];
|
|
469
548
|
if (!name || !run || name !== `agenthub-check-${run}`) { unknown.push("orphan check: unknown owner"); continue; }
|
|
470
549
|
let r: RunReceipt | undefined;
|
|
@@ -512,3 +591,284 @@ export class ContainerRunner {
|
|
|
512
591
|
}
|
|
513
592
|
}
|
|
514
593
|
}
|
|
594
|
+
|
|
595
|
+
/** Machine-local data only; every failure is value-free so host/account names never enter diagnostics. */
|
|
596
|
+
export function remoteRunnerConfig(value: unknown): RemoteRunnerConfig {
|
|
597
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote runner configuration");
|
|
598
|
+
const raw = value as Record<string, unknown>;
|
|
599
|
+
if (Object.keys(raw).sort().join(",") !== "host,image,root"
|
|
600
|
+
|| typeof raw.host !== "string" || raw.host.length > 288 || !/^(?:[a-z_][a-z0-9_-]{0,31}@)?[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(raw.host)
|
|
601
|
+
|| typeof raw.image !== "string" || !/^[a-z0-9][a-z0-9./:_-]{0,200}@sha256:[a-f0-9]{64}$/.test(raw.image)
|
|
602
|
+
|| typeof raw.root !== "string" || raw.root.length > 1024 || !/^(?:\/[A-Za-z0-9._-]+)+\/?$/.test(raw.root) || raw.root.split("/").some(part => part === "." || part === "..")) throw new Error("invalid remote runner configuration");
|
|
603
|
+
return { host: raw.host, image: raw.image, root: raw.root };
|
|
604
|
+
}
|
|
605
|
+
/** OpenSSH joins its remote argv into a shell command. Quote every token, including fixed Docker arguments. */
|
|
606
|
+
export function remoteDockerArgs(host: string, args: readonly string[], mergeStderr = false): string[] {
|
|
607
|
+
const quote = (word: string) => `'${word.replaceAll("'", `'"'"'`)}'`;
|
|
608
|
+
return ["-T", "-a", "-x", "-o", "ClearAllForwardings=yes", "-o", "PermitLocalCommand=no", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "--", host, `docker ${args.map(quote).join(" ")}${args[0] === "exec" && mergeStderr ? " 2>&1" : ""}`];
|
|
609
|
+
}
|
|
610
|
+
/** Darwin's system tar is bsdtar even when a GNU tar shim comes first on PATH. */
|
|
611
|
+
export function remoteArchiveArgs(projectDir: string, source: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
612
|
+
const executable = platform === "darwin" ? "/usr/bin/tar" : hostExecutable("tar", projectDir);
|
|
613
|
+
const metadata = platform === "darwin"
|
|
614
|
+
? ["--format=gnutar", "--no-xattrs", "--no-acls", "--no-fflags", "--disable-copyfile", "--uid", "65532", "--gid", "65532", "--uname", "check", "--gname", "check"]
|
|
615
|
+
: ["--format=gnu", "--no-xattrs", "--no-acls", "--owner=65532", "--group=65532", "--numeric-owner"];
|
|
616
|
+
return [executable, ...metadata, "-cf", "-", "-C", source, "."];
|
|
617
|
+
}
|
|
618
|
+
function runRemoteDocker(executable: string, remote: RemoteRunnerConfig, args: string[], options: Parameters<ContainerCommand>[1] = {}): Promise<CommandResult> {
|
|
619
|
+
return new Promise((resolveResult, reject) => {
|
|
620
|
+
if (options.signal?.aborted) { reject(new Error("remote runner unavailable")); return; }
|
|
621
|
+
// The hub's agent socket authenticates this client; -a prevents forwarding it into the remote job.
|
|
622
|
+
const env = { PATH: dirname(executable), HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK };
|
|
623
|
+
const child = spawn(executable, remoteDockerArgs(remote.host, args, options.output !== undefined), { env, detached: true, stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
624
|
+
let stdout = "", inputFailed = false, bytes = 0, first = Buffer.alloc(0), last = Buffer.alloc(0);
|
|
625
|
+
let stopping: Promise<void> | undefined;
|
|
626
|
+
const stop = () => stopping ??= stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 });
|
|
627
|
+
const abort = () => { void stop().catch(() => {}); };
|
|
628
|
+
child.once("spawn", () => { trackGroup(child); if (options.signal?.aborted) abort(); });
|
|
629
|
+
const timer = setTimeout(abort, Math.max(1, Math.min(600_000, options.timeoutMs ?? 15_000)));
|
|
630
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
631
|
+
if (options.signal?.aborted) abort();
|
|
632
|
+
const input = options.input ? Readable.fromWeb(options.input as import("node:stream/web").ReadableStream<Uint8Array>) : undefined;
|
|
633
|
+
input?.on("error", () => { inputFailed = true; abort(); }); child.stdin?.on("error", () => { inputFailed = true; input?.destroy(); }); if (input && child.stdin) input.pipe(child.stdin);
|
|
634
|
+
child.stdout!.on("data", (data: Buffer) => {
|
|
635
|
+
bytes += data.length;
|
|
636
|
+
if (options.output) {
|
|
637
|
+
if (first.length < MAX_OUTPUT) first = Buffer.concat([first, data.subarray(0, MAX_OUTPUT - first.length)]);
|
|
638
|
+
last = Buffer.concat([last, data]).subarray(-16_384);
|
|
639
|
+
} else stdout = (stdout + data.toString()).slice(-MAX_OUTPUT);
|
|
640
|
+
});
|
|
641
|
+
// SSH diagnostics mix account/host paths with remote stderr. Never forward that channel as task output.
|
|
642
|
+
child.stderr!.resume();
|
|
643
|
+
const finish = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); input?.destroy(); };
|
|
644
|
+
child.once("error", () => { finish(); reject(new Error("remote runner unavailable")); });
|
|
645
|
+
child.once("close", async code => {
|
|
646
|
+
finish();
|
|
647
|
+
try { await stop(); } catch { reject(new Error("remote runner unavailable")); return; }
|
|
648
|
+
if (code === 255 || options.signal?.aborted) return resolveResult({ code, stdout: "", outputBytes: 0 });
|
|
649
|
+
if (options.output) { options.output(first); if (bytes > first.length) options.output(last); }
|
|
650
|
+
resolveResult({ code: inputFailed ? 1 : code, stdout, ...(options.output ? { outputBytes: bytes } : {}) });
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
const DEPENDENCY_PREFIX = "agenthub-check-deps-";
|
|
655
|
+
const DEPS_PROJECT = "agenthub.deps.project", DEPS_KEY = "agenthub.deps.key";
|
|
656
|
+
/** Newest dependency volumes kept per project, the current one included; older ones are pruned. */
|
|
657
|
+
export const KEEP_DEPENDENCY_VOLUMES = 3;
|
|
658
|
+
/** Content key of a dependency volume: the commit's lockfile hash, Bun version, image architecture and image digest. */
|
|
659
|
+
export function dependencyKey(parts: { lockfile: string; manifest: string; bun: string; arch: string; image: string }): string {
|
|
660
|
+
return createHash("sha256").update(["agenthub-deps-v2", parts.lockfile, parts.manifest, parts.bun, parts.arch, parts.image].join("\n")).digest("hex");
|
|
661
|
+
}
|
|
662
|
+
/** Files besides bun.lock that decide what `bun install --frozen-lockfile` produces: manifests, Bun and registry config, patches. */
|
|
663
|
+
const INSTALL_INPUT = /(?:^|\/)(?:package\.json|bunfig\.toml|\.npmrc)$|^patches\//;
|
|
664
|
+
/** Scripts bun runs during the root or a workspace install. They can read tracked source, so no manifest hash can bind their output. */
|
|
665
|
+
const INSTALL_LIFECYCLE = ["preinstall", "install", "postinstall", "prepublish", "preprepare", "prepare", "postprepare"];
|
|
666
|
+
/** Hash of every install-affecting file in the staged source, by path and content, so a changed manifest or config never reuses a volume. */
|
|
667
|
+
export async function dependencyManifest(source: string): Promise<string> {
|
|
668
|
+
const files: string[] = [];
|
|
669
|
+
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
670
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
671
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
672
|
+
if (entry.isDirectory()) { if (entry.name !== "node_modules" && entry.name !== ".git") await walk(join(dir, entry.name), path); }
|
|
673
|
+
else if (entry.isFile() && INSTALL_INPUT.test(path)) files.push(path);
|
|
674
|
+
if (files.length > 10_000) throw new Error("install inputs exceed file limit");
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
await walk(source, "");
|
|
678
|
+
const digest = createHash("sha256");
|
|
679
|
+
for (const path of files.sort()) {
|
|
680
|
+
const data = await readFile(join(source, path));
|
|
681
|
+
if (path.endsWith("package.json")) {
|
|
682
|
+
// A lifecycle script makes node_modules depend on source outside the key. Refuse it rather than reuse a stale volume or
|
|
683
|
+
// key on the whole tree; the install stays exactly `bun install --frozen-lockfile`, never `--ignore-scripts`.
|
|
684
|
+
let manifest: unknown;
|
|
685
|
+
try { manifest = JSON.parse(data.toString("utf8")); } catch { throw new RemoteDependencyError(); }
|
|
686
|
+
const scripts = manifest && typeof manifest === "object" ? (manifest as { scripts?: unknown }).scripts : undefined;
|
|
687
|
+
if (scripts !== undefined && (!scripts || typeof scripts !== "object" || Array.isArray(scripts) || INSTALL_LIFECYCLE.some(name => Object.hasOwn(scripts, name)))) throw new RemoteDependencyError();
|
|
688
|
+
}
|
|
689
|
+
digest.update(`${path}\0${hash(data)}\0`);
|
|
690
|
+
}
|
|
691
|
+
return digest.digest("hex");
|
|
692
|
+
}
|
|
693
|
+
export function dependencyVolume(projectKey: string, key: string): string { return `${DEPENDENCY_PREFIX}${projectKey}-${key.slice(0, 32)}`; }
|
|
694
|
+
/** Distinguish transport failures from deterministic request/snapshot refusals without leaking remote stderr. */
|
|
695
|
+
class RemoteTransportError extends Error { constructor() { super("remote runner unavailable"); } }
|
|
696
|
+
/** The prepared dependency volume could not be proven; a fixed reason, never remote output. */
|
|
697
|
+
class RemoteDependencyError extends Error { constructor() { super("remote dependencies unavailable"); } }
|
|
698
|
+
|
|
699
|
+
/** Same production strategy for transport-only component/native checks; production uses RemoteRunner's dependency gate. */
|
|
700
|
+
export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
|
|
701
|
+
if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
|
|
702
|
+
const remote = remoteRunnerConfig(options.remote), digest = remote.image.slice(remote.image.indexOf("@") + 1);
|
|
703
|
+
const command: ContainerCommand = async (args, opts) => {
|
|
704
|
+
try { return await (options.command ? options.command(args, opts) : runRemoteDocker(hostExecutable("ssh", options.projectDir), remote, args, opts)); }
|
|
705
|
+
catch { throw new RemoteTransportError(); }
|
|
706
|
+
};
|
|
707
|
+
const privateValues = [...new Set([remote.host, ...remote.host.split("@"), remote.root])].sort((a, b) => b.length - a.length);
|
|
708
|
+
const redact = (text: string) => {
|
|
709
|
+
for (const value of privateValues) {
|
|
710
|
+
text = text.replaceAll(value, "***");
|
|
711
|
+
for (let length = Math.min(value.length - 1, text.length); length > 0; length--) if (text.endsWith(value.slice(0, length))) { text = text.slice(0, -length) + "***"; break; }
|
|
712
|
+
}
|
|
713
|
+
return text;
|
|
714
|
+
};
|
|
715
|
+
const format = '{"id":{{json .Names}},"project":{{json (.Label "agenthub.project")}},"hub":{{json (.Label "agenthub.hub")}},"run":{{json (.Label "agenthub.run")}},"probe":{{json (.Label "agenthub.probe")}}}';
|
|
716
|
+
const inventoryArgs = (name?: string, project = true) => ["ps", "--all", "--no-trunc", ...(project ? ["--filter", `label=agenthub.project=${options.projectKey}`] : []), ...(name ? ["--filter", `name=^/${name}$`] : []), "--format", format];
|
|
717
|
+
const listArgs = inventoryArgs();
|
|
718
|
+
const list = (stdout: string): { id: string; labels: Record<string, string> }[] => {
|
|
719
|
+
const lines = stdout.trim() ? stdout.trim().split("\n") : [];
|
|
720
|
+
if (lines.length > 10_000) throw new RemoteTransportError();
|
|
721
|
+
return lines.map(line => {
|
|
722
|
+
let value: Record<string, unknown>;
|
|
723
|
+
try { value = JSON.parse(line) as Record<string, unknown>; } catch { throw new RemoteTransportError(); }
|
|
724
|
+
if (!["id", "project", "hub", "run", "probe"].every(key => typeof value[key] === "string")) throw new RemoteTransportError();
|
|
725
|
+
return { id: value.id as string, labels: { "agenthub.project": value.project as string, "agenthub.hub": value.hub as string, "agenthub.run": value.run as string, "agenthub.probe": value.probe as string } };
|
|
726
|
+
});
|
|
727
|
+
};
|
|
728
|
+
const cleanup = async (command: ContainerCommand, name: string): Promise<boolean> => {
|
|
729
|
+
if (!/^agenthub-check-(?:probe-)?[a-f0-9-]{36}$/.test(name)) return false;
|
|
730
|
+
try {
|
|
731
|
+
const before = await command(inventoryArgs(name));
|
|
732
|
+
if (before.code !== 0) return false;
|
|
733
|
+
const owned = list(before.stdout).filter(row => row.id === name && row.labels["agenthub.project"] === options.projectKey);
|
|
734
|
+
if (owned.length > 1) return false;
|
|
735
|
+
if (owned.length === 1) { await command(["kill", name]).catch(() => {}); await command(["rm", name]).catch(() => {}); }
|
|
736
|
+
// Exact-name absence, even if a same-name container has different labels; never kill that other owner.
|
|
737
|
+
const after = await command(inventoryArgs(name, false));
|
|
738
|
+
return after.code === 0 && !list(after.stdout).some(row => row.id === name);
|
|
739
|
+
} catch { return false; }
|
|
740
|
+
};
|
|
741
|
+
const hardening = ["--pull", "never", "--network", "none", "--user", "65532:65532", "--read-only", "--tmpfs", "/tmp:rw,nosuid,nodev,size=64m,mode=1777", "--tmpfs", "/home/check:rw,nosuid,nodev,size=16m,uid=65532,gid=65532", "--tmpfs", "/work:rw,nosuid,nodev,size=512m,mode=0755,uid=65532,gid=65532", "--env", "HOME=/home/check", "--env", "TMPDIR=/tmp", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "64", "--cpus", "4", "--memory", "4g"];
|
|
742
|
+
// Network is "none" for every run except the single dependency-preparation container.
|
|
743
|
+
const hardenedFor = (network: "none" | "bridge") => { const args = [...hardening]; args[args.indexOf("--network") + 1] = network; return args; };
|
|
744
|
+
const mount = (volume: string, readonly: boolean) => ["--mount", `type=volume,src=${volume},dst=/work/node_modules${readonly ? ",readonly" : ""}`];
|
|
745
|
+
const readyFile = "/work/node_modules/.agenthub-deps-ready";
|
|
746
|
+
// Observed by the readiness probe: the Bun version and architecture the image actually runs.
|
|
747
|
+
let observed: { bun: string; arch: string } | undefined;
|
|
748
|
+
const execArgs = (container: string, words: readonly string[]) => ["exec", "--user", "65532:65532", "--workdir", "/work", container, ...words];
|
|
749
|
+
const copy = async (command: ContainerCommand, source: string, container: string, signal: AbortSignal) => {
|
|
750
|
+
try {
|
|
751
|
+
const argv = remoteArchiveArgs(options.projectDir, source);
|
|
752
|
+
const archive = Bun.spawn(argv, { env: { PATH: dirname(argv[0]!), COPYFILE_DISABLE: "1" }, stdout: "pipe", stderr: "ignore" });
|
|
753
|
+
const abort = () => { if (archive.exitCode === null) try { archive.kill("SIGKILL"); } catch { /* Exit promise confirms this owned producer. */ } };
|
|
754
|
+
const timer = setTimeout(abort, 180_000);
|
|
755
|
+
signal.addEventListener("abort", abort, { once: true }); if (signal.aborted) abort();
|
|
756
|
+
try {
|
|
757
|
+
// docker cp cannot write a read-only container filesystem, even where a writable tmpfs is mounted.
|
|
758
|
+
const result = await command(["exec", "-i", "--user", "65532:65532", container, "tar", "--no-same-owner", "-xf", "-", "-C", "/work"], { timeoutMs: 180_000, input: archive.stdout });
|
|
759
|
+
if (result.code !== 0 || await archive.exited !== 0) throw new RemoteTransportError();
|
|
760
|
+
} finally { clearTimeout(timer); signal.removeEventListener("abort", abort); abort(); await archive.exited; }
|
|
761
|
+
} catch { throw new RemoteTransportError(); }
|
|
762
|
+
};
|
|
763
|
+
return {
|
|
764
|
+
command, kind: "remote", image: digest, platform: "linux/unknown (remote docker unknown)", listArgs, list, redact, execArgs, cleanup, copy,
|
|
765
|
+
async probe(command, teardown) {
|
|
766
|
+
let probeRoot: string | undefined;
|
|
767
|
+
try {
|
|
768
|
+
const version = await command(["version", "--format", "{{json .Server}}"]);
|
|
769
|
+
if (version.code !== 0) throw new RemoteTransportError();
|
|
770
|
+
const server = JSON.parse(version.stdout) as { Version?: string; Os?: string; Arch?: string };
|
|
771
|
+
if (server.Os !== "linux" || !["amd64", "arm64"].includes(server.Arch ?? "") || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(server.Version ?? "")) throw new RemoteTransportError();
|
|
772
|
+
const image = await command(["image", "inspect", remote.image, "--format", '{"digests":{{json .RepoDigests}},"arch":{{json .Architecture}},"os":{{json .Os}}}']);
|
|
773
|
+
const metadata = image.code === 0 ? JSON.parse(image.stdout) as { digests?: unknown; arch?: string; os?: string } : undefined;
|
|
774
|
+
if (metadata?.os !== "linux" || !["amd64", "arm64"].includes(metadata.arch ?? "") || !Array.isArray(metadata.digests) || !metadata.digests.some(value => typeof value === "string" && value.endsWith(`@${digest}`))) throw new RemoteTransportError();
|
|
775
|
+
let architecture: string | undefined;
|
|
776
|
+
const probeId = randomUUID(), name = `agenthub-check-probe-${probeId}`;
|
|
777
|
+
probeRoot = await mkdtemp(join(tmpdir(), "ahub-remote-probe-"));
|
|
778
|
+
const source = join(probeRoot, "src"); await mkdir(source, { mode: 0o755 }); await chmod(source, 0o755);
|
|
779
|
+
await writeFile(join(source, "source-proof"), "AHUB_REMOTE_SOURCE", { mode: 0o644 }); await chmod(join(source, "source-proof"), 0o644);
|
|
780
|
+
let clean = false;
|
|
781
|
+
try {
|
|
782
|
+
const launched = await command(["run", "--detach", "--rm", "--name", name, "--label", `agenthub.project=${options.projectKey}`, "--label", `agenthub.probe=${probeId}`, ...hardening, remote.image, "sleep", "600"]);
|
|
783
|
+
if (launched.code !== 0) throw new RemoteTransportError();
|
|
784
|
+
await copy(command, source, name, new AbortController().signal);
|
|
785
|
+
const result = await command(execArgs(name, ["bun", "-e", 'const {networkInterfaces}=await import("node:os"); if(process.getuid?.()!==65532||Object.keys(networkInterfaces()).some(name=>name!=="lo")||await Bun.file("/work/source-proof").text()!=="AHUB_REMOTE_SOURCE")process.exit(2); await Bun.write("/tmp/ready","ok"); try{await Bun.write("/root-write-proof","no");process.exit(3)}catch{} console.log(JSON.stringify({marker:"AHUB_REMOTE_READY",arch:process.arch,platform:process.platform,bun:Bun.version}));']));
|
|
786
|
+
const proof = result.code === 0 ? JSON.parse(result.stdout) as { marker?: string; arch?: string; platform?: string; bun?: string } : undefined;
|
|
787
|
+
architecture = proof?.arch === "x64" ? "amd64" : proof?.arch;
|
|
788
|
+
if (proof?.marker !== "AHUB_REMOTE_READY" || proof.platform !== "linux" || architecture !== metadata.arch || !/^\d+\.\d+\.\d+$/.test(proof.bun ?? "")) throw new RemoteTransportError();
|
|
789
|
+
observed = { bun: proof.bun!, arch: architecture! };
|
|
790
|
+
} finally { clean = await cleanup(teardown, name); }
|
|
791
|
+
if (!clean) throw new RemoteTransportError();
|
|
792
|
+
return { available: true, platform: `linux/${architecture} (remote docker ${server.Version!.match(/^\d+\.\d+\.\d+/)![0]})` };
|
|
793
|
+
} catch { return { available: false, reason: "remote runner unavailable" }; }
|
|
794
|
+
finally { if (probeRoot) await rm(probeRoot, { recursive: true, force: true }).catch(() => {}); }
|
|
795
|
+
},
|
|
796
|
+
async isolate() { /* Fixed --network none needs no mutable host network. */ },
|
|
797
|
+
runArgs(receipt, labels, volume) {
|
|
798
|
+
// A check never runs without a verified prepared volume, and only ever mounts it read-only; no host path is used.
|
|
799
|
+
if (!volume) throw new RemoteDependencyError();
|
|
800
|
+
return ["run", "--detach", "--rm", "--name", receipt.container, ...labels, ...hardenedFor("none"), ...mount(volume, true), "--workdir", "/work", remote.image, "sleep", "600"];
|
|
801
|
+
},
|
|
802
|
+
async prepareDeps(command, receipt, source, signal, labels) {
|
|
803
|
+
if (!observed) throw new RemoteTransportError();
|
|
804
|
+
const key = dependencyKey({ lockfile: receipt.lockfile, manifest: await dependencyManifest(source), bun: observed.bun, arch: observed.arch, image: digest });
|
|
805
|
+
const volume = dependencyVolume(options.projectKey, key), name = receipt.container;
|
|
806
|
+
const ok = (result: CommandResult) => { if (result.code === 255 || result.code === 125) throw new RemoteTransportError(); if (result.code !== 0) throw new RemoteDependencyError(); return result; };
|
|
807
|
+
// Every step container carries this run's name and labels, so it is confirmed gone before the next one starts.
|
|
808
|
+
const gone = async () => { if (!await cleanup(command, name)) throw new RemoteTransportError(); };
|
|
809
|
+
const owner = async (): Promise<"missing" | "ours" | "foreign"> => {
|
|
810
|
+
const result = await command(["volume", "inspect", volume, "--format", `{"project":{{json (index .Labels "${DEPS_PROJECT}")}},"key":{{json (index .Labels "${DEPS_KEY}")}}}`]);
|
|
811
|
+
if (result.code === 255) throw new RemoteTransportError();
|
|
812
|
+
if (result.code !== 0) return "missing";
|
|
813
|
+
try { const labelled = JSON.parse(result.stdout) as { project?: unknown; key?: unknown }; return labelled.project === options.projectKey && labelled.key === key ? "ours" : "foreign"; } catch { return "foreign"; }
|
|
814
|
+
};
|
|
815
|
+
const marker = `await Bun.file(${JSON.stringify(readyFile)})`;
|
|
816
|
+
const verify = async (): Promise<boolean> => {
|
|
817
|
+
// Network none, volume read-only: proves the marker for this exact key and that the mount cannot be written.
|
|
818
|
+
const script = `const t=await ${marker}.text().catch(()=>"");if(t!==${JSON.stringify(key)})process.exit(2);try{await Bun.write(${JSON.stringify(readyFile)},"x");process.exit(3)}catch{}`;
|
|
819
|
+
const result = await command(["run", "--rm", "--name", name, ...labels, ...hardenedFor("none"), ...mount(volume, true), "--workdir", "/work", remote.image, "bun", "-e", script], { timeoutMs: 60_000 });
|
|
820
|
+
if (result.code === 255) throw new RemoteTransportError();
|
|
821
|
+
await gone();
|
|
822
|
+
return result.code === 0;
|
|
823
|
+
};
|
|
824
|
+
const prune = async () => {
|
|
825
|
+
// Bounded rule: keep the newest few of this project's own prepared volumes, always the current one; nothing else is touched.
|
|
826
|
+
const listed = await command(["volume", "ls", "--quiet", "--filter", `label=${DEPS_PROJECT}=${options.projectKey}`]);
|
|
827
|
+
if (listed.code !== 0) return;
|
|
828
|
+
const own = new RegExp(`^${DEPENDENCY_PREFIX}${options.projectKey}-[a-f0-9]{32}$`);
|
|
829
|
+
const names = listed.stdout.split("\n").map(line => line.trim()).filter(line => own.test(line));
|
|
830
|
+
if (names.length <= KEEP_DEPENDENCY_VOLUMES || names.length > 500) return;
|
|
831
|
+
const rows = await command(["volume", "inspect", ...names, "--format", `{"name":{{json .Name}},"created":{{json .CreatedAt}},"project":{{json (index .Labels "${DEPS_PROJECT}")}},"key":{{json (index .Labels "${DEPS_KEY}")}}}`]);
|
|
832
|
+
if (rows.code !== 0) return;
|
|
833
|
+
// Only a volume this runner demonstrably made is a candidate: exact name pattern, our project label and a key label that matches
|
|
834
|
+
// the name. A volume with an absent or inconsistent key label is someone else's or damaged: it is neither counted nor removed.
|
|
835
|
+
const volumes = rows.stdout.trim().split("\n").flatMap(line => { try { return [JSON.parse(line) as { name?: unknown; created?: unknown; project?: unknown; key?: unknown }]; } catch { return []; } })
|
|
836
|
+
.filter((row): row is { name: string; created: string; project: string; key: string } => typeof row.name === "string" && own.test(row.name) && row.project === options.projectKey && typeof row.created === "string"
|
|
837
|
+
&& typeof row.key === "string" && /^[a-f0-9]{64}$/.test(row.key) && row.name === dependencyVolume(options.projectKey, row.key))
|
|
838
|
+
.sort((a, b) => Date.parse(b.created) - Date.parse(a.created) || a.name.localeCompare(b.name));
|
|
839
|
+
const keep = new Set([volume, ...volumes.filter(row => row.name !== volume).slice(0, KEEP_DEPENDENCY_VOLUMES - 1).map(row => row.name)]);
|
|
840
|
+
for (const row of volumes) if (!keep.has(row.name)) await command(["volume", "rm", row.name]).catch(() => {});
|
|
841
|
+
};
|
|
842
|
+
let state = await owner();
|
|
843
|
+
if (state === "ours" && await verify()) { await prune(); return volume; }
|
|
844
|
+
if (state === "ours") ok(await command(["volume", "rm", volume])); // Half-prepared or tampered: discard, never repair in place.
|
|
845
|
+
if (state === "foreign") throw new RemoteDependencyError();
|
|
846
|
+
try {
|
|
847
|
+
ok(await command(["volume", "create", "--label", `${DEPS_PROJECT}=${options.projectKey}`, "--label", `${DEPS_KEY}=${key}`, volume]));
|
|
848
|
+
if (await owner() !== "ours") throw new RemoteDependencyError();
|
|
849
|
+
// The one step with a network. Same hardening as a check apart from that, the volume writable and a cache inside the work tmpfs.
|
|
850
|
+
ok(await command(["run", "--rm", "--name", name, ...labels, "--pull", "never", "--network", "none", "--user", "0:0", "--read-only", "--cap-drop", "ALL", "--cap-add", "CHOWN", "--security-opt", "no-new-privileges", "--pids-limit", "16", "--memory", "256m", "--mount", `type=volume,src=${volume},dst=/deps`, remote.image, "chown", "65532:65532", "/deps"], { timeoutMs: 60_000 }));
|
|
851
|
+
await gone();
|
|
852
|
+
ok(await command(["run", "--detach", "--rm", "--name", name, ...labels, ...hardenedFor("bridge"), "--env", "BUN_INSTALL_CACHE_DIR=/work/.bun-cache", ...mount(volume, false), "--workdir", "/work", remote.image, "sleep", "600"], { timeoutMs: 120_000 }));
|
|
853
|
+
await copy(command, source, name, signal);
|
|
854
|
+
ok(await command(execArgs(name, ["bun", "install", "--frozen-lockfile"]), { timeoutMs: 300_000 }));
|
|
855
|
+
ok(await command(execArgs(name, ["bun", "-e", `await Bun.write(${JSON.stringify(readyFile)},${JSON.stringify(key)})`]), { timeoutMs: 60_000 }));
|
|
856
|
+
await gone();
|
|
857
|
+
if (!await verify()) throw new RemoteDependencyError();
|
|
858
|
+
} catch (error) {
|
|
859
|
+
// A failed preparation never leaves a candidate behind; the next run would discard it anyway, because only a verified marker admits a volume.
|
|
860
|
+
await cleanup(command, name).catch(() => false);
|
|
861
|
+
await command(["volume", "rm", volume]).catch(() => {});
|
|
862
|
+
throw error;
|
|
863
|
+
}
|
|
864
|
+
await prune();
|
|
865
|
+
return volume;
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
/** Same lifecycle; each run first proves or prepares its lockfile-keyed read-only dependency volume (#793b). */
|
|
870
|
+
export class RemoteRunner extends ContainerRunner {
|
|
871
|
+
constructor(options: RunnerOptions) {
|
|
872
|
+
super(options, remoteRunnerTransport(options));
|
|
873
|
+
}
|
|
874
|
+
}
|