@staix/agent-hub 0.18.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 (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -8
  5. package/docs/agent-notes/daemon.md +5 -2
  6. package/docs/agent-notes/local-worker.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -1
  8. package/docs/agent-notes/tests.md +6 -3
  9. package/docs/dogfood.md +9 -3
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +66 -28
  12. package/docs/quickstart.md +9 -5
  13. package/docs/security.md +22 -13
  14. package/docs/smoke.md +3 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
  16. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  17. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  18. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  19. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  20. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  21. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  22. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  23. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  24. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  25. package/docs/verification/claude-permission-transport-native.py +187 -0
  26. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  27. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  28. package/docs/verification/pi-140-model-free.ts +57 -0
  29. package/docs/verification/pi-140-native.mjs +21 -0
  30. package/docs/verified.json +14 -14
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +273 -68
  34. package/src/adapters/agy/entry.ts +1 -1
  35. package/src/adapters/claude-channel.ts +7 -3
  36. package/src/adapters/claude-implementer.ts +5 -3
  37. package/src/adapters/compat.ts +32 -18
  38. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  39. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  40. package/src/adapters/definitions/native-factories.ts +7 -2
  41. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  42. package/src/adapters/pi.ts +39 -3
  43. package/src/adapters/qwen-dual.ts +25 -0
  44. package/src/adapters/registry-types.ts +6 -2
  45. package/src/adapters/registry.ts +1 -0
  46. package/src/cli/agents.ts +69 -7
  47. package/src/cli/claude-shell-wrapper.ts +54 -0
  48. package/src/cli/main.ts +30 -24
  49. package/src/cli/output.ts +4 -0
  50. package/src/hub/agent-drift.ts +61 -0
  51. package/src/hub/board.ts +14 -2
  52. package/src/hub/check-runner.ts +148 -18
  53. package/src/hub/claude-shell-wrapper.ts +349 -0
  54. package/src/hub/config-edit.ts +2 -2
  55. package/src/hub/config-tier.ts +2 -2
  56. package/src/hub/daemon.ts +197 -35
  57. package/src/hub/host-executable.ts +20 -1
  58. package/src/hub/instances.ts +18 -2
  59. package/src/hub/lifecycle.ts +1 -1
  60. package/src/hub/operational-authority.ts +7 -0
  61. package/src/hub/operational-view.ts +6 -10
  62. package/src/hub/read-only-shell-proof.ts +7 -6
  63. package/src/hub/review-scope.ts +13 -0
  64. package/src/hub/sdlc-metrics.ts +6 -4
  65. package/src/hub/task-shell-command.ts +85 -0
  66. package/src/hub/task-shell-default.ts +78 -0
  67. package/src/hub/task-shell-proof.ts +171 -0
  68. package/src/hub/task-shell-sandbox.ts +61 -0
  69. package/src/hub/tasks.ts +139 -13
  70. package/src/local/deny.ts +91 -11
  71. package/src/local/sandbox.ts +67 -28
  72. package/src/local/shell-environment.ts +10 -0
  73. package/src/ui/index.html +1 -0
package/src/cli/main.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { conductorOperation } from "../hub/operational-authority.ts";
2
+ import { conductorOperation, OPERATIONAL_PERSON_GUIDANCE } from "../hub/operational-authority.ts";
3
3
  import type { DeliverableRow } from "../hub/tasks.ts";
4
4
  import { kimiSourceRow } from "../hub/budget-notes.ts";
5
5
  import { hubGitSync, hubGitEnv } from "../hub/git.ts";
@@ -487,12 +487,17 @@ function startupDiagnostic(error: unknown): string {
487
487
  return message.replace(/[\p{Cc}\p{Cf}]/gu, " ").replace(/\s+/g, " ").trim().slice(0, 512) || "invalid startup configuration";
488
488
  }
489
489
 
490
- function fail(message: string, refusalAudited=false): never {
491
- if(operationalAuditReady){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
490
+ function fail(message: string, refusalAudited=false, authorityRefused?:boolean): never {
491
+ if(operationalAuditReady && authorityRefused!==false){if(refusalAudited)operationalRefusalRecorded=true;if(!operationalRefusalRecorded)audit("refused");}
492
492
  console.error(`ahub: ${message}`);
493
493
  process.exit(1);
494
494
  }
495
495
 
496
+ function operationalIncomplete(result: {complete?:boolean;incompleteReason?:unknown;truncated?:boolean}, history: "log"|"event"): void {
497
+ if(result.complete===false)console.error(`ahub: operational output is incomplete: ${typeof result.incompleteReason==="string"?terminalText(result.incompleteReason).replace(/[\r\n]+/g," ").slice(0,200):"public privacy or history coverage unavailable"}`);
498
+ else if(result.truncated)console.error(`ahub: operational ${history} history is truncated`);
499
+ }
500
+
496
501
  async function taskOp(op: string, a: Record<string, unknown>, throwOnError = false, connection?: ControlClient, timeoutMs?: number): Promise<string> {
497
502
  const hub = connection ?? await connect();
498
503
  try {
@@ -501,11 +506,11 @@ async function taskOp(op: string, a: Record<string, unknown>, throwOnError = fal
501
506
  if (!res.ok) {
502
507
  if (throwOnError) throw new Error(res.error);
503
508
  if(res.refusalAudited===true)operationalRefusalRecorded=true;
504
- audit("refused");
509
+ if(!operationalAuditReady || res.authorityRefused!==false)audit("refused");
505
510
  // #711: an agent shell refused something only the person may do is told why it counts as an agent. The refusal and the authority stay as they were.
506
511
  const cause = agentShellCause(identity, String(res.error));
507
512
  if (cause) console.error(`ahub: ${res.error}\n${cause}`), process.exit(1);
508
- fail(res.error);
513
+ fail(res.error,res.refusalAudited===true,res.authorityRefused);
509
514
  }
510
515
  return res.text;
511
516
  } finally { if (!connection) hub.close(); }
@@ -1065,7 +1070,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1065
1070
  const { one, rest: flags } = takeFlags(rest, ["--peer"], []);
1066
1071
  if (flags.length) fail("usage: ahub queue list [--peer <id>] [--json] [--full] [--color=auto|always|never]");
1067
1072
  const result = await hub.request({ t: "queue", op: "list", ...(one["--peer"] ? { peer: one["--peer"] } : {}) });
1068
- if (!result.ok) fail(result.error,result.refusalAudited===true);
1073
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1069
1074
  if (options.json) return console.log(JSON.stringify(result.deliveries, null, 2));
1070
1075
  printOutput(renderQueue(result.deliveries, options.columns, Date.now()), options.color);
1071
1076
  return;
@@ -1077,7 +1082,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1077
1082
  if (others.length || !one["--peer"] || (one["--state"] !== undefined && !["queued", "needs_review"].includes(one["--state"]))) fail("usage: ahub queue discard --peer <id> --reason <text> [--state queued|needs_review] [--yes]");
1078
1083
  if (yes && !one["--reason"]?.trim()) fail("ahub queue discard needs --reason <text> with --yes: it is recorded on every discarded delivery");
1079
1084
  const result = await hub.request({ t: "queue", op: "discard", peer: one["--peer"], ...(one["--state"] ? { state: one["--state"] } : {}), ...(yes ? { confirmed: true, reason: one["--reason"] } : {}) }, 60_000);
1080
- if (!result.ok) fail(result.error,result.refusalAudited===true);
1085
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1081
1086
  if (options.json) return console.log(JSON.stringify(result, null, 2));
1082
1087
  if (!yes) return console.log(`${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}; nothing discarded. Run again with --reason <text> --yes to discard them.`);
1083
1088
  console.log(`discarded ${result.discarded} of ${result.count} ${result.state} deliver${result.count === 1 ? "y" : "ies"} for ${result.peer}${result.refused ? `; ${result.refused} refused (${Object.entries(result.refusedBy ?? {}).map(([why, n]) => `${why} ${n}`).join(", ")})` : ""}`);
@@ -1088,7 +1093,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1088
1093
  if (operation === "show") {
1089
1094
  if (flags.length) fail("usage: ahub queue show <id>");
1090
1095
  const shown = await hub.request({ t: "queue", op: "show", id });
1091
- if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
1096
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
1092
1097
  if (options.json) return console.log(JSON.stringify(shown.delivery, null, 2));
1093
1098
  return printOutput(renderQueueShow(shown.delivery, options.columns, Date.now()), options.color);
1094
1099
  }
@@ -1098,11 +1103,11 @@ const commands: Record<string, () => Promise<void> | void> = {
1098
1103
  let revision = parsedRevision;
1099
1104
  if (revision === undefined) {
1100
1105
  const shown = await hub.request({ t: "queue", op: "show", id });
1101
- if (!shown.ok) fail(shown.error,shown.refusalAudited===true);
1106
+ if (!shown.ok) fail(shown.error,shown.refusalAudited===true,shown.authorityRefused);
1102
1107
  revision = shown.delivery.revision;
1103
1108
  }
1104
1109
  const result = await hub.request({ t: "queue", op: "resolve", id, revision, action: one["--action"], reason: one["--reason"] });
1105
- if (!result.ok) fail(result.error,result.refusalAudited===true);
1110
+ if (!result.ok) fail(result.error,result.refusalAudited===true,result.authorityRefused);
1106
1111
  if (options.json) console.log(JSON.stringify(result.delivery, null, 2));
1107
1112
  else printOutput(renderQueueShow(result.delivery, options.columns, Date.now()), options.color);
1108
1113
  } finally { hub.close(); }
@@ -1566,7 +1571,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1566
1571
  const reply = await hub.request({ t: "peer_stop", peer }, 35_000);
1567
1572
  if (reply.ok !== true) {
1568
1573
  const error = String(reply.error ?? "peer stop request refused");
1569
- fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true);
1574
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "peer_stop"/i.test(error) ? `${error}; upgrade the running hub to use ahub stop` : error,reply.refusalAudited===true,reply.authorityRefused);
1570
1575
  }
1571
1576
  console.log(`${peer}: ${reply.state}`);
1572
1577
  } finally { hub.close(); }
@@ -1602,7 +1607,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1602
1607
  const hub = await connect();
1603
1608
  try {
1604
1609
  requireNamedPeerFeature(hub, peer);
1605
- const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused"),reply.refusalAudited===true); return reply; };
1610
+ const request = async (message: Record<string, unknown>) => { const reply = await hub.request(message, 35_000); if (reply.ok !== true) fail(String(reply.error ?? "roster request refused"),reply.refusalAudited===true,reply.authorityRefused); return reply; };
1606
1611
  if (sub === "list") {
1607
1612
  const view = await request({ t: "settings_get" });
1608
1613
  if (!Array.isArray(view.roster)) fail("upgrade the running hub to use ahub roster");
@@ -1668,7 +1673,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1668
1673
  const reply = await hub.request(message, 35_000);
1669
1674
  if (reply.ok !== true) {
1670
1675
  const error = String(reply.error ?? "settings request refused");
1671
- fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true);
1676
+ fail(/unknown (?:control )?(?:message|request|command)(?:\b|:)|this hub does not know "settings_/i.test(error) ? `${error}; upgrade the running hub to use ahub settings` : error,reply.refusalAudited===true,reply.authorityRefused);
1672
1677
  }
1673
1678
  return reply;
1674
1679
  };
@@ -1798,14 +1803,14 @@ const commands: Record<string, () => Promise<void> | void> = {
1798
1803
 
1799
1804
  logs: async () => {
1800
1805
  if (identity.role !== "tools") return exec("tail", [args.includes("-f") ? "-f" : "-n100", join(stateDir, "hub.log")]);
1801
- const hub=await connect();const pending:string[]=[];let ready=false;
1802
- hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
1806
+ const hub=await connect();const pending:string[]=[];let ready=false,incompleteShown=false;
1807
+ hub.onPush=message=>{if(message.t==="operational_log" && typeof message.line==="string"){if(message.complete===false && !incompleteShown){operationalIncomplete(message,"log");incompleteShown=true;}if(ready)console.log(message.line);else if(pending.length<100)pending.push(message.line);}};
1803
1808
  try {
1804
1809
  const result=await hub.request({t:"conductor_operational",op:"logs",follow:args.includes("-f")});
1805
- if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true);
1810
+ if(result.ok!==true)fail(String(result.error??"operational logs unavailable"),result.refusalAudited===true,result.authorityRefused);
1806
1811
  for(const line of result.lines??[])console.log(line);
1807
1812
  ready=true;for(const line of pending)console.log(line);
1808
- if(result.truncated)console.error("ahub: operational log history is truncated");
1813
+ if(!incompleteShown){operationalIncomplete(result,"log");if(result.complete===false)incompleteShown=true;}
1809
1814
  if(args.includes("-f"))await new Promise<void>(resolve=>{
1810
1815
  const finish=()=>{process.off("SIGINT",finish);resolve();};
1811
1816
  hub.onClose=finish;process.once("SIGINT",finish);
@@ -1815,7 +1820,7 @@ const commands: Record<string, () => Promise<void> | void> = {
1815
1820
  export: async () => {
1816
1821
  if(identity.role==="tools") {
1817
1822
  const hub=await connect();
1818
- try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true);for(const row of result.events??[])console.log(JSON.stringify(row));if(result.truncated)console.error("ahub: operational event history is truncated");}
1823
+ try {const result=await hub.request({t:"conductor_operational",op:"export",since:since()});if(result.ok!==true)fail(String(result.error??"operational export unavailable"),result.refusalAudited===true,result.authorityRefused);for(const row of result.events??[])console.log(JSON.stringify(row));operationalIncomplete(result,"event");}
1819
1824
  finally {hub.close();}
1820
1825
  return;
1821
1826
  }
@@ -2230,7 +2235,7 @@ const commands: Record<string, () => Promise<void> | void> = {
2230
2235
  }
2231
2236
  if (args.includes("--orphans")) {
2232
2237
  if(identity.role==="tools" && args.includes("--kill")) {
2233
- const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2238
+ const hub=await connect();try {const result=await hub.request({t:"conductor_operational",op:"orphans"},10_000);if(result.ok!==true)fail(String(result.error??"orphan inspection unavailable"),result.refusalAudited===true,result.authorityRefused);console.log(JSON.stringify(result,null,2));}finally{hub.close();}return;
2234
2239
  }
2235
2240
  return orphanDoctor(args.includes("--kill"), options);
2236
2241
  }
@@ -2392,16 +2397,17 @@ function inspectDoctorRouting(cwd: string): { routing?: Routing; errors: string[
2392
2397
  async function runConductorCommand(): Promise<void> {
2393
2398
  const operation=conductorOperation(cmd,args);
2394
2399
  if(operation) {
2395
- const denial=()=>peerCommandRefusal(identity.role==="tools"?identity.peer:"unknown",commandLabel);
2400
+ const unavailable=`conductor authorization unavailable; nothing was executed; ${OPERATIONAL_PERSON_GUIDANCE}`;
2396
2401
  const descriptor=readControl(stateDir);
2397
- if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(denial());
2402
+ if(!descriptor?.instanceId || !descriptor.projectId || !descriptor.pid || !descriptor.pidSignature || processLiveness(descriptor.pid,descriptor.pidSignature)!=="live")fail(unavailable);
2398
2403
  let hub:ControlClient;
2399
- try {hub=await connect(750);} catch {fail(denial());}
2404
+ try {hub=await connect(3000);} catch {fail(unavailable);}
2400
2405
  let release:(()=>void)|undefined;
2401
2406
  try {
2402
2407
  let result:any;
2403
- try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},750);} catch {fail(denial());}
2404
- if(result.ok!==true || !hub.instanceId)fail(`${denial()}${result.error?`; ${String(result.error)}`:""}`,result.refusalAudited===true);
2408
+ try {result=await hub.request({t:"conductor_operational",op:"authorize",command:operation},3000);} catch {fail(unavailable);}
2409
+ if(result.ok!==true)fail(result.authorityRefused===true || result.authorityRefused===false?String(result.error??unavailable):unavailable,result.refusalAudited===true,result.authorityRefused);
2410
+ if(!hub.instanceId)fail(unavailable);
2405
2411
  release=ControlClient.pinInstance(stateDir,hub.instanceId);
2406
2412
  await commands[cmd]!();
2407
2413
  } finally {release?.();hub.close();}
package/src/cli/output.ts CHANGED
@@ -14,6 +14,8 @@ export interface StatusOutput {
14
14
  retainedConfig?: readonly { key: string; reason: string }[];
15
15
  /** #572 AC3: advisory, present while the project root checkout is on a branch other than the default. */
16
16
  mainCheckout?: string;
17
+ /** #843 AC3: installed agent CLIs not on a verified version; ids, version, state and a receipt path only. */
18
+ agentDrift?: readonly { id: string; version: string; state: string; receipt: string }[];
17
19
  taskStalls?: readonly { task: number; cause: { kind: string; peer?: string; dependency?: number; code: string } }[];
18
20
  pendingTaskOffers?: readonly { task: number; peer: string }[];
19
21
  isolatedChecks?: { pending?: number; orphans?: string[]; unknownReceipts?: readonly string[]; maintenanceUnavailable?: boolean };
@@ -204,6 +206,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
204
206
  if (data.cwd) out.push(...lines(data.cwd, columns));
205
207
  for (const pending of data.pendingRestart ?? []) out.push(...detail("pending restart", `${pending.key}: ${pending.reason}`, columns, "attention"));
206
208
  if (data.mainCheckout) out.push(...detail("main checkout", data.mainCheckout, columns, "attention"));
209
+ for (const drift of data.agentDrift ?? []) out.push(...detail("agent CLI drift", `${drift.id} ${drift.version} ${drift.state}; last receipt ${drift.receipt}`, columns, "attention"));
207
210
  if (data.retainedConfig?.length) {
208
211
  out.push(...detail("config retained", `${data.retainedConfig.map(row => row.key).join(", ")}; run ahub settings reload`, columns, "attention"));
209
212
  for (const pending of data.retainedConfig) out.push(...detail("retained reason", `${pending.key}: ${pending.reason}`, columns, "attention"));
@@ -262,6 +265,7 @@ export function renderStatus(data: StatusOutput, columns?: number, now = Date.no
262
265
  if (p.permissionNote) out.push(...detail("permission", p.permissionNote, columns, "muted"));
263
266
  if (typeof p.statusNotice?.message === "string" && p.statusNotice.message) out.push(...detail("status notice", p.statusNotice.message, columns, "attention"));
264
267
  if (p.requestedModel && p.servedBy && p.requestedModel !== p.servedBy) out.push(...detail("requested model", p.requestedModel, columns));
268
+ if (p.reviewScope === "code" || p.reviewScope === "full") out.push(...detail("review scope", p.reviewScope === "code" ? "code only (cannot run tests or read CI; the merger gates on exact-head CI)" : "full", columns, "muted")); // #852
265
269
  if (typeof p.modelSource === "string" && p.modelSource) out.push(...detail("model source", flat(p.modelSource), columns, "muted")); // an extra Jcode instance (#685): names only, never a URL or token
266
270
  if (typeof p.importantWaitingSince === "number" && p.importantWaitingNote) out.push(...detail("important waiting", `${p.importantWaitingNote.replace(/^\d+ important messages? waiting/, "important messages waiting")}`, columns, "attention"));
267
271
  if (typeof p.oldestQueuedAt === "number") out.push(...detail(`${id} oldest queued`, relative(p.oldestQueuedAt, now), columns, "muted"));
@@ -0,0 +1,61 @@
1
+ import { COMPAT, type CompatId } from "../adapters/compat.ts";
2
+ import type { AgentVersionRow } from "../cli/agents.ts";
3
+ import { HUB, newEnvelope, sanitize, type PeerId } from "./envelope.ts";
4
+
5
+ /** #843 AC3: an installed gated CLI whose version is not `verified`. Ids, version, state and one receipt path, never output of the CLI. */
6
+ export interface DriftedAgent { id: CompatId; version: string; state: "in-line" | "trusted" | "refused"; receipt: string }
7
+
8
+ /** A version string that is safe to carry in a notice and a marker: what `COMPAT[id].parse` yields for a real CLI. */
9
+ const SAFE_VERSION = /^[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}$/;
10
+
11
+ /**
12
+ * Pure: which rows are drift. A missing executable is not drift (no executable, no version), and a CLI whose
13
+ * version could not be read has no (id, version) to announce, so `unknown` is never drift either.
14
+ */
15
+ export function driftedAgents(rows: readonly AgentVersionRow[]): DriftedAgent[] {
16
+ const out: DriftedAgent[] = [];
17
+ for (const row of rows) {
18
+ const { verdict } = row;
19
+ if (!row.executable || verdict.state === "verified" || verdict.state === "unknown") continue;
20
+ const version = verdict.version;
21
+ if (!version || !SAFE_VERSION.test(version)) continue;
22
+ out.push({ id: row.id, version, state: verdict.state, receipt: COMPAT[row.id].receipt });
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /** Durable key of one notice: the (id, version) and the planner it was queued for. */
28
+ export const driftMarker = (agent: Pick<DriftedAgent, "id" | "version">, planner: PeerId): string => `${agent.id}:${agent.version}@${planner}`;
29
+
30
+ export const driftNoticeBody = (agent: DriftedAgent): string =>
31
+ sanitize(`Agent CLI drift: ${agent.id} ${agent.version} ${agent.state}; last receipt ${agent.receipt}`);
32
+
33
+ export interface DriftDeps {
34
+ /** True when the (id, version, planner) marker is new and now recorded (durable: `Board.recordAgentDriftNotice`). */
35
+ record(marker: string): boolean;
36
+ /** Undo a record whose notice could not be queued, so the next sweep tries again. */
37
+ forget(marker: string): void;
38
+ /** The effective planner holders from the settled role map. */
39
+ planners(): readonly PeerId[];
40
+ isRostered(peer: PeerId): boolean;
41
+ publish(env: ReturnType<typeof newEnvelope>): PeerId[];
42
+ }
43
+
44
+ /**
45
+ * One notice per (id, version) to each effective planner, kept durably per recipient: a planner that was queued is
46
+ * never told again, and one that could not be queued (or was not rostered yet) is retried alone by the next sweep.
47
+ * With no rostered planner nothing is recorded. Returns the notices queued.
48
+ */
49
+ export function noticeDrift(drift: readonly DriftedAgent[], deps: DriftDeps): number {
50
+ const planners = deps.planners().filter(peer => deps.isRostered(peer));
51
+ let sent = 0;
52
+ for (const agent of drift) for (const planner of planners) {
53
+ const marker = driftMarker(agent, planner);
54
+ if (!deps.record(marker)) continue;
55
+ try {
56
+ if (!deps.publish(newEnvelope(HUB, driftNoticeBody(agent), { to: [planner], kind: "task" })).includes(planner)) throw new Error("the notice was not queued");
57
+ sent++;
58
+ } catch { deps.forget(marker); }
59
+ }
60
+ return sent;
61
+ }
package/src/hub/board.ts CHANGED
@@ -72,6 +72,10 @@ export interface HistoryEntry {
72
72
  reviewCost?: ReviewCost;
73
73
  /** #223 R4: on a verdict, whether the reviewer's context was new for it; set by the hub, never by the reviewer. */
74
74
  reviewContext?: "fresh" | "continuing";
75
+ /** #852: a verdict by a `code`-scope reviewer says so; a `full` reviewer's verdict carries nothing, so existing rows are unchanged. */
76
+ reviewScope?: "code";
77
+ /** #852 AC3: a `code`-scope reviewer's changes_requested with no open finding. It is recorded, returns the task to its owner and counts toward nothing. */
78
+ evidenceOnly?: true;
75
79
  /** Structured review report bound to the reviewed artifact, stored in the existing history JSON. */
76
80
  reviewFindings?: ReviewFindingSet;
77
81
  /**
@@ -259,6 +263,8 @@ export class Board {
259
263
  this.db.run("CREATE TABLE IF NOT EXISTS task_stalls (task INTEGER PRIMARY KEY, payload TEXT NOT NULL)");
260
264
  // #481 S7: one row per log-tier band breach episode already announced, so a restart never repeats the notice.
261
265
  this.db.run("CREATE TABLE IF NOT EXISTS band_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
266
+ // #843 AC3: one row per (agent id, installed version, planner) already queued, so a restart never repeats it.
267
+ this.db.run("CREATE TABLE IF NOT EXISTS agent_drift_notices (marker TEXT PRIMARY KEY, at INTEGER NOT NULL)");
262
268
  // Derived status-cache generation: only Board task/stall writes advance it, never journal writes.
263
269
  this.db.run("CREATE TABLE IF NOT EXISTS board_status_revision (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL)");
264
270
  this.db.run("INSERT OR IGNORE INTO board_status_revision VALUES (1,0)");
@@ -373,6 +379,12 @@ export class Board {
373
379
  if (!/^[a-z_]{1,64}:#[1-9][0-9]{0,9}:log$/.test(marker)) throw new Error("invalid band notice marker");
374
380
  return this.db.query("INSERT OR IGNORE INTO band_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
375
381
  }
382
+ /** True when the (id, version, planner) marker was new and is now recorded; false when it was announced before. */
383
+ recordAgentDriftNotice(marker: string): boolean {
384
+ if (!/^[a-z][a-z0-9-]{0,31}:[0-9A-Za-z][0-9A-Za-z.+_-]{0,63}@[0-9A-Za-z][0-9A-Za-z._-]{0,63}$/.test(marker)) throw new Error("invalid agent drift marker");
385
+ return this.db.query("INSERT OR IGNORE INTO agent_drift_notices(marker, at) VALUES(?, ?)").run(marker, Date.now()).changes > 0;
386
+ }
387
+ forgetAgentDriftNotice(marker: string): void { this.db.query("DELETE FROM agent_drift_notices WHERE marker=?").run(marker); }
376
388
  clearStall(task: number): void { this.taskWrite(() => this.db.query("DELETE FROM task_stalls WHERE task=?").run(task)); }
377
389
  stalls(): TaskStall[] {
378
390
  return (this.db.query("SELECT payload FROM task_stalls ORDER BY task").all() as { payload: string }[]).flatMap(row => {
@@ -398,7 +410,7 @@ export class Board {
398
410
 
399
411
  /** The only way a task changes. Validates the move, records who did what, returns the new row. */
400
412
  /** A `plan` in the patch replaces the old one whole: a new plan is the owner's current intent, not an addition. */
401
- update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
413
+ update(id: number, by: PeerId, event: string, patch: Partial<Pick<Task, "state" | "owner" | "planner" | "reviewer" | "reserved" | "refs" | "plan" | "rejections" | "signals" | "sdlc" | "role" | "cancelled" | "codeSymbols">>, note?: string, extra: Pick<HistoryEntry, "plannerReason" | "planAuthor" | "selfReview" | "profile" | "sweep" | "reason" | "from" | "capacity" | "withheld" | "noWrites" | "reviewCost" | "reviewContext" | "reviewScope" | "evidenceOnly" | "reviewFindings" | "unmetCriteria" | "deliverables" | "planObservation" | "planDeparture" | "planDepartureInvalidated" | keyof ReviewerHistory> = {}, expectedFlowRevision?: number, codeSymbolsAuthority?: typeof HOST_CODE_SYMBOLS): Task {
402
414
  const task = this.get(id);
403
415
  if (!task) throw new Error(`no task #${id}`);
404
416
  if (expectedFlowRevision !== undefined && (!Number.isSafeInteger(expectedFlowRevision) || expectedFlowRevision < 0 || task.history.length !== expectedFlowRevision)) throw new Error("task flow revision changed");
@@ -454,7 +466,7 @@ export class Board {
454
466
  // Disabling reporting must not let plan or repository refs edits/reverts resurrect prior consent.
455
467
  const repositoryRefsChanged = patch.refs !== undefined && JSON.stringify([task.refs.repo, task.refs.paths]) !== JSON.stringify([next.refs.repo, next.refs.paths]);
456
468
  const departureInvalidated = extra.planDepartureInvalidated === true || hasDeparture && (planChanged || repositoryRefsChanged);
457
- const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
469
+ const history = [...task.history, { at: Date.now(), by, event, ...plannerHistory, ...authorHistory, ...(extra.selfReview === true ? { selfReview: true as const } : {}), ...(planRevision ? { planRevision } : {}), ...(extra.planObservation ? { planObservation: extra.planObservation } : {}), ...(extra.planDeparture ? { planDeparture: extra.planDeparture } : {}), ...(departureInvalidated ? { planDepartureInvalidated: true as const } : {}), ...reviewMetadata, ...(note ? { note } : {}), ...(patch.cancelled?.supersededBy ? { supersededBy: patch.cancelled.supersededBy } : {}), ...("owner" in patch ? { owner: next.owner } : {}), ...(extra.profile ? { profile: extra.profile } : {}), ...(extra.sweep ? { sweep: extra.sweep } : {}), ...(extra.reason && TASK_MOVE_REASONS.includes(extra.reason) ? { reason: extra.reason } : {}), ...(extra.from ? { from: extra.from } : {}), ...(extra.capacity === true ? { capacity: true as const } : {}), ...(extra.withheld && (note || reviewFindings) ? { withheld: true as const } : {}), ...(reviewFindings ? { reviewFindings } : {}), ...(unmetCriteria?.length ? { unmetCriteria } : {}), ...(deliverables ? { deliverables } : {}), ...(extra.reviewContext === "fresh" || extra.reviewContext === "continuing" ? { reviewContext: extra.reviewContext } : {}), ...(extra.reviewScope === "code" ? { reviewScope: "code" as const } : {}), ...(extra.evidenceOnly === true && event === "changes_requested" ? { evidenceOnly: true as const } : {}), ...(extra.reviewCost ? { reviewCost: extra.reviewCost } : {}), ...(event === "done" && extra.noWrites === true ? { noWrites: true as const } : {}) }];
458
470
  this.taskWrite(() => {
459
471
  const result = this.db.query("UPDATE tasks SET state = ?, owner = ?, reviewer = ?, reserved = ?, refs = ?, plan = ?, rejections = ?, signals = ?, history = ?, sdlc = ?, task_role = ?, cancelled = ?, updated = ?, planner = ? WHERE id = ?")
460
472
  .run(next.state, next.owner, next.reviewer, next.reserved ?? null, JSON.stringify(next.refs), JSON.stringify(next.plan ?? {}), next.rejections, JSON.stringify(next.signals), JSON.stringify(history), next.sdlc ? JSON.stringify(next.sdlc) : null, next.role ?? null, next.cancelled ? JSON.stringify(next.cancelled) : null, Date.now(), planner, id);
@@ -53,8 +53,10 @@ interface RunnerTransport {
53
53
  kind: "remote"; image: string; platform: string;
54
54
  probe(command: ContainerCommand, cleanup: ContainerCommand): Promise<RunnerState>;
55
55
  isolate(command: ContainerCommand): Promise<void>;
56
- runArgs(receipt: RunReceipt, labels: string[]): string[];
56
+ runArgs(receipt: RunReceipt, labels: string[], volume?: string): string[];
57
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>;
58
60
  execArgs(container: string, words: readonly string[]): string[];
59
61
  cleanup(command: ContainerCommand, name: string): Promise<boolean>;
60
62
  listArgs: string[];
@@ -111,6 +113,7 @@ function runContainer(executable: string, args: string[], options: { signal?: Ab
111
113
  export class ContainerRunner {
112
114
  private readonly command: ContainerCommand;
113
115
  private readonly entries = new Map<string, Entry>();
116
+ private readonly saves = new Map<string, Promise<void>>();
114
117
  private readonly queue: Entry[] = [];
115
118
  private active?: Entry;
116
119
  private closing = false;
@@ -233,11 +236,25 @@ export class ContainerRunner {
233
236
  await this.save(receipt);
234
237
  if (entry === this.active && entry) await this.cleanup(receipt.container);
235
238
  }
236
- 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> {
237
253
  await mkdir(this.records, { recursive: true, mode: 0o700 });
238
254
  const path = join(this.records, `${receipt.run}.json`);
239
- const temp = `${path}.${process.pid}.tmp`;
240
- 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; }
241
258
  }
242
259
  private async drain(): Promise<void> {
243
260
  if (this.active) return;
@@ -409,7 +426,10 @@ export class ContainerRunner {
409
426
  attemptedContainer = true;
410
427
  // Cold VM startup and a <=1.5 GiB source/dependency transfer need their own bounded allowances.
411
428
  const labels = ["--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`];
412
- const run = await command(this.transport?.runArgs(r, labels) ?? ["run", "--detach", "--rm", "--name", r.container, ...labels, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
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);
413
433
  this.ensureCurrent(entry);
414
434
  if (run.code !== 0) { if (this.transport) throw new RemoteTransportError(); throw new Error("runner unavailable: container start failed"); }
415
435
  if (this.transport) await this.transport.copy(transportCommand, src, r.container, entry.abort.signal);
@@ -631,8 +651,50 @@ function runRemoteDocker(executable: string, remote: RemoteRunnerConfig, args: s
631
651
  });
632
652
  });
633
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)}`; }
634
694
  /** Distinguish transport failures from deterministic request/snapshot refusals without leaking remote stderr. */
635
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"); } }
636
698
 
637
699
  /** Same production strategy for transport-only component/native checks; production uses RemoteRunner's dependency gate. */
638
700
  export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
@@ -677,6 +739,12 @@ export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
677
739
  } catch { return false; }
678
740
  };
679
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;
680
748
  const execArgs = (container: string, words: readonly string[]) => ["exec", "--user", "65532:65532", "--workdir", "/work", container, ...words];
681
749
  const copy = async (command: ContainerCommand, source: string, container: string, signal: AbortSignal) => {
682
750
  try {
@@ -714,10 +782,11 @@ export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
714
782
  const launched = await command(["run", "--detach", "--rm", "--name", name, "--label", `agenthub.project=${options.projectKey}`, "--label", `agenthub.probe=${probeId}`, ...hardening, remote.image, "sleep", "600"]);
715
783
  if (launched.code !== 0) throw new RemoteTransportError();
716
784
  await copy(command, source, name, new AbortController().signal);
717
- 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}));']));
718
- const observed = result.code === 0 ? JSON.parse(result.stdout) as { marker?: string; arch?: string; platform?: string } : undefined;
719
- architecture = observed?.arch === "x64" ? "amd64" : observed?.arch;
720
- if (observed?.marker !== "AHUB_REMOTE_READY" || observed.platform !== "linux" || architecture !== metadata.arch) throw new RemoteTransportError();
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! };
721
790
  } finally { clean = await cleanup(teardown, name); }
722
791
  if (!clean) throw new RemoteTransportError();
723
792
  return { available: true, platform: `linux/${architecture} (remote docker ${server.Version!.match(/^\d+\.\d+\.\d+/)![0]})` };
@@ -725,20 +794,81 @@ export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
725
794
  finally { if (probeRoot) await rm(probeRoot, { recursive: true, force: true }).catch(() => {}); }
726
795
  },
727
796
  async isolate() { /* Fixed --network none needs no mutable host network. */ },
728
- runArgs(receipt, labels) {
729
- // ponytail: root and prepared dependency readiness remain reserved until 793b; no host path is used.
730
- return ["run", "--detach", "--rm", "--name", receipt.container, ...labels, ...hardening,
731
- "--mount", `type=volume,src=agenthub-check-deps-unprepared-${options.projectKey},dst=/work/node_modules,readonly`, "--workdir", "/work", remote.image, "sleep", "600"];
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;
732
866
  },
733
867
  };
734
868
  }
735
- /** Same lifecycle, but no production checks capability until 793b proves prepared dependencies. */
869
+ /** Same lifecycle; each run first proves or prepares its lockfile-keyed read-only dependency volume (#793b). */
736
870
  export class RemoteRunner extends ContainerRunner {
737
871
  constructor(options: RunnerOptions) {
738
872
  super(options, remoteRunnerTransport(options));
739
873
  }
740
- override async available(signal?: AbortSignal): Promise<RunnerState> {
741
- const transport = await super.available(signal);
742
- return transport.available ? { ...transport, available: false, reason: "remote dependencies unprepared" } : transport;
743
- }
744
874
  }