@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.
Files changed (109) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -6
  5. package/docs/agent-notes/benchmarks.md +1 -0
  6. package/docs/agent-notes/daemon.md +6 -2
  7. package/docs/agent-notes/local-worker.md +3 -1
  8. package/docs/agent-notes/tasks.md +11 -2
  9. package/docs/agent-notes/tests.md +7 -3
  10. package/docs/dogfood.md +22 -2
  11. package/docs/memory.md +1 -1
  12. package/docs/operations.md +162 -35
  13. package/docs/profiles.md +36 -0
  14. package/docs/quickstart.md +15 -5
  15. package/docs/sdlc.md +29 -3
  16. package/docs/security.md +44 -13
  17. package/docs/smoke.md +3 -3
  18. package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
  19. package/docs/verification/2026-10-25-0.18.0.md +76 -0
  20. package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
  21. package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
  22. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  23. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  24. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  25. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  26. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  27. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  28. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  29. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  30. package/docs/verification/claude-permission-transport-native.py +187 -0
  31. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  32. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  33. package/docs/verification/pi-140-model-free.ts +57 -0
  34. package/docs/verification/pi-140-native.mjs +21 -0
  35. package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
  36. package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
  37. package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
  38. package/docs/verified.json +24 -18
  39. package/package.json +2 -2
  40. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  41. package/plugins/agent-hub/server.js +364 -98
  42. package/src/adapters/agy/entry.ts +1 -1
  43. package/src/adapters/claude-channel.ts +7 -3
  44. package/src/adapters/claude-implementer.ts +5 -3
  45. package/src/adapters/compat.ts +32 -18
  46. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  47. package/src/adapters/definitions/builtin-metadata.ts +3 -3
  48. package/src/adapters/definitions/native-factories.ts +7 -2
  49. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  50. package/src/adapters/local-worker.ts +5 -3
  51. package/src/adapters/pi.ts +39 -3
  52. package/src/adapters/qwen-dual.ts +25 -0
  53. package/src/adapters/registry-types.ts +6 -2
  54. package/src/adapters/registry.ts +1 -0
  55. package/src/cli/agents.ts +69 -7
  56. package/src/cli/claude-shell-wrapper.ts +54 -0
  57. package/src/cli/console-state.ts +9 -2
  58. package/src/cli/help.ts +29 -28
  59. package/src/cli/identity.ts +87 -6
  60. package/src/cli/main.ts +198 -71
  61. package/src/cli/output.ts +11 -2
  62. package/src/cli/sdlc-output.ts +242 -0
  63. package/src/cli/task-output.ts +212 -0
  64. package/src/cli/upgrade-output.ts +57 -0
  65. package/src/hub/agent-drift.ts +61 -0
  66. package/src/hub/board.ts +31 -2
  67. package/src/hub/check-runner.ts +385 -25
  68. package/src/hub/claude-shell-wrapper.ts +349 -0
  69. package/src/hub/conductor.ts +5 -0
  70. package/src/hub/config-apply.ts +16 -7
  71. package/src/hub/config-edit-host.ts +1 -1
  72. package/src/hub/config-edit.ts +2 -2
  73. package/src/hub/config-tier.ts +3 -3
  74. package/src/hub/daemon.ts +530 -87
  75. package/src/hub/dogfood-profile.ts +248 -0
  76. package/src/hub/dogfood-read.ts +77 -14
  77. package/src/hub/dogfood.ts +5 -1
  78. package/src/hub/events.ts +1 -1
  79. package/src/hub/host-executable.ts +21 -1
  80. package/src/hub/hub-tools.ts +2 -2
  81. package/src/hub/instances.ts +18 -2
  82. package/src/hub/lifecycle.ts +1 -1
  83. package/src/hub/operational-authority.ts +67 -0
  84. package/src/hub/operational-view.ts +137 -0
  85. package/src/hub/orphan-process.ts +69 -0
  86. package/src/hub/permission-mode.ts +1 -1
  87. package/src/hub/read-only-shell-proof.ts +81 -0
  88. package/src/hub/read-only-shell.ts +162 -0
  89. package/src/hub/review-scope.ts +13 -0
  90. package/src/hub/reviewer-pool-config.ts +3 -2
  91. package/src/hub/reviewer-pool.ts +3 -0
  92. package/src/hub/routing.ts +23 -6
  93. package/src/hub/sdlc-merge.ts +299 -0
  94. package/src/hub/sdlc-metrics.ts +6 -4
  95. package/src/hub/sdlc-mirror.ts +129 -0
  96. package/src/hub/sdlc.ts +58 -12
  97. package/src/hub/settings.ts +11 -5
  98. package/src/hub/snapshots.ts +60 -0
  99. package/src/hub/task-repository.ts +31 -5
  100. package/src/hub/task-shell-command.ts +85 -0
  101. package/src/hub/task-shell-default.ts +78 -0
  102. package/src/hub/task-shell-proof.ts +171 -0
  103. package/src/hub/task-shell-sandbox.ts +61 -0
  104. package/src/hub/tasks.ts +378 -27
  105. package/src/local/deny.ts +91 -11
  106. package/src/local/sandbox.ts +67 -28
  107. package/src/local/shell-environment.ts +10 -0
  108. package/src/local/tools.ts +17 -4
  109. package/src/ui/index.html +3 -1
@@ -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
- } catch { if (this.closing || signal.aborted) return { available: false, reason: this.closing ? "stopping" : "check stopped" }; value = { available: false, reason: "container unavailable" }; }
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
- private async save(receipt: RunReceipt): Promise<void> {
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 }); check(); paths.add(entry.path);
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); if (!state.available) throw new Error(`runner unavailable: ${state.reason}`);
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 run = await command(["run", "--detach", "--rm", "--name", r.container, "--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
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
- const copy = await command(["cp", `${src}/.`, `${r.container}:/work`], 180_000);
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
- r.duration_ms = Math.max(0, this.now() - begun); r.tail = tail(outputTail.toString("utf8"), r.output_bytes > outputTail.length); if (r.status !== "stale") r.status = "finished";
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
+ }