@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.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +10 -8
- package/docs/agent-notes/daemon.md +5 -2
- package/docs/agent-notes/local-worker.md +2 -1
- package/docs/agent-notes/tasks.md +4 -1
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +9 -3
- package/docs/memory.md +1 -1
- package/docs/operations.md +66 -28
- package/docs/quickstart.md +9 -5
- package/docs/security.md +22 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
- package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/verified.json +14 -14
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +273 -68
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +2 -2
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/main.ts +30 -24
- package/src/cli/output.ts +4 -0
- package/src/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +14 -2
- package/src/hub/check-runner.ts +148 -18
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/daemon.ts +197 -35
- package/src/hub/host-executable.ts +20 -1
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +7 -0
- package/src/hub/operational-view.ts +6 -10
- package/src/hub/read-only-shell-proof.ts +7 -6
- package/src/hub/review-scope.ts +13 -0
- package/src/hub/sdlc-metrics.ts +6 -4
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +139 -13
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- 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(
|
|
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));
|
|
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
|
|
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(
|
|
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(
|
|
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},
|
|
2404
|
-
if(result.ok!==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);
|
package/src/hub/check-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
719
|
-
architecture =
|
|
720
|
-
if (
|
|
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
|
-
//
|
|
730
|
-
|
|
731
|
-
|
|
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
|
|
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
|
}
|