@staix/agent-hub 0.18.0 → 0.20.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 +30 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +6 -0
- package/docs/agent-notes/adapters.md +13 -10
- package/docs/agent-notes/bus.md +1 -1
- package/docs/agent-notes/daemon.md +11 -4
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +6 -1
- package/docs/agent-notes/tests.md +8 -4
- package/docs/dogfood.md +13 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +98 -28
- package/docs/quickstart.md +13 -5
- package/docs/sdlc.md +12 -0
- package/docs/security.md +24 -15
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +101 -8
- 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/2026-10-27-0.20.0.md +75 -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 +25 -17
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +373 -72
- package/src/adapters/acp.ts +8 -0
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/channel-peer.ts +7 -5
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +6 -3
- package/src/adapters/codex-appserver.ts +11 -1
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/conformance/kit.ts +10 -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/local-worker.ts +2 -0
- package/src/adapters/pi.ts +46 -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/adapters/reviewer-print.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/ci-watch.ts +140 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +349 -0
- package/src/hub/config-apply.ts +2 -2
- package/src/hub/config-edit.ts +2 -2
- package/src/hub/config-tier.ts +2 -2
- package/src/hub/config-trust.ts +1 -0
- package/src/hub/daemon.ts +282 -113
- 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/peers.ts +34 -0
- 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/settings.ts +1 -0
- package/src/hub/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +79 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +274 -16
- package/src/local/deny.ts +112 -11
- package/src/local/sandbox.ts +70 -29
- package/src/local/shell-environment.ts +10 -0
- package/src/ui/index.html +1 -0
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
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { parsePrEvidence, type SdlcGh } from "./sdlc.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #785 P1b: one bounded watcher per exact PR head. It starts when `hub_task_done` names a head whose CI was not green,
|
|
5
|
+
* ends on the first CI result or on this deadline, and is cancelled by a newer head or any task move. It is a timer in the
|
|
6
|
+
* hub, never a peer turn, and its gh reads (one `gh pr view` per poll) are the only periodic gh calls the hub makes.
|
|
7
|
+
* The longest hosted run is about 30 minutes (the job limits in `.github/workflows/check.yml` are 35 and 40 minutes).
|
|
8
|
+
*/
|
|
9
|
+
export const CI_WATCH_DEADLINE_MS = 45 * 60_000;
|
|
10
|
+
export const CI_WATCH_POLL_MS = 60_000;
|
|
11
|
+
/** Same default set as `readReviewReadiness`, which is the authoritative read the review admission makes. */
|
|
12
|
+
const DEFAULT_CHECKS = ["check (ubuntu-latest)", "check (macos-latest)", "seeded guards"];
|
|
13
|
+
const REPO = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
14
|
+
/** How many finished heads are remembered, so one head never notifies twice. */
|
|
15
|
+
const SETTLED_CAP = 256;
|
|
16
|
+
|
|
17
|
+
/** What one poll found. `incomplete` is every required check finished with none failed and not all passed (skipped, neutral, duplicated). */
|
|
18
|
+
export type CiPoll = "pending" | "passed" | "failed" | "incomplete" | "superseded" | "closed";
|
|
19
|
+
export type CiOutcome = "passed" | "failed" | "incomplete" | "deadline";
|
|
20
|
+
|
|
21
|
+
export interface CiWatch { id: number; head: string; repo: string; pr: number; deadlineAt: number }
|
|
22
|
+
|
|
23
|
+
/** One read of the PR: the head, its state and the required checks of that head. A failed or unreadable read is `pending`; the deadline bounds the retries. */
|
|
24
|
+
export async function pollCi(target: { repo: string; pr: number }, head: string, required: readonly string[], run: SdlcGh): Promise<CiPoll> {
|
|
25
|
+
if (!REPO.test(target.repo) || !Number.isSafeInteger(target.pr) || target.pr < 1) return "closed";
|
|
26
|
+
try {
|
|
27
|
+
const view = await run(["pr", "view", String(target.pr), "--repo", target.repo, "--json", "headRefOid,state,statusCheckRollup"], 10_000, undefined, 512 * 1024);
|
|
28
|
+
if (view.code !== 0) return "pending";
|
|
29
|
+
const body = JSON.parse(view.stdout);
|
|
30
|
+
if (body?.headRefOid !== head) return "superseded";
|
|
31
|
+
if (body.state !== "OPEN") return "closed";
|
|
32
|
+
// The same check-state mapping as every other PR read; files are not requested here.
|
|
33
|
+
const evidence = parsePrEvidence({ ...body, files: [], changedFiles: 0 });
|
|
34
|
+
const rows = (required.length ? required : DEFAULT_CHECKS).map(name => evidence.checks.filter(check => check.name === name));
|
|
35
|
+
if (rows.some(row => row.some(check => check.state === "failed"))) return "failed";
|
|
36
|
+
if (rows.some(row => row.length === 0 || row.some(check => check.state === "pending"))) return "pending";
|
|
37
|
+
return rows.every(row => row.length === 1 && row[0]!.state === "passed") ? "passed" : "incomplete";
|
|
38
|
+
} catch { return "pending"; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface CiWatchersOptions {
|
|
42
|
+
deadlineMs?: number;
|
|
43
|
+
pollMs?: number;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
poll: (watch: CiWatch) => Promise<CiPoll>;
|
|
46
|
+
/** False once the task moved or its head changed: the watcher ends without a notice. */
|
|
47
|
+
current: (watch: CiWatch) => boolean;
|
|
48
|
+
finish: (watch: CiWatch, outcome: CiOutcome) => void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* At most one watcher per task, and one per exact head. Timers are chained `setTimeout`s (never an interval), unref'd, and cleared
|
|
53
|
+
* on every end, so nothing outlives its deadline, a newer head or `stopAll`. Watchers are not restored after a hub restart:
|
|
54
|
+
* the next `hub_task_done` starts one (ponytail: persist them only if restarts during CI turn out to be common).
|
|
55
|
+
*/
|
|
56
|
+
export class CiWatchers {
|
|
57
|
+
private readonly active = new Map<number, { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }>();
|
|
58
|
+
private readonly settled = new Map<number, { head: string; outcome: CiOutcome }>();
|
|
59
|
+
private readonly deadlineMs: number;
|
|
60
|
+
private readonly pollMs: number;
|
|
61
|
+
private readonly now: () => number;
|
|
62
|
+
|
|
63
|
+
constructor(private readonly o: CiWatchersOptions) {
|
|
64
|
+
this.deadlineMs = o.deadlineMs ?? CI_WATCH_DEADLINE_MS;
|
|
65
|
+
this.pollMs = o.pollMs ?? CI_WATCH_POLL_MS;
|
|
66
|
+
this.now = o.now ?? Date.now;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
get size(): number { return this.active.size; }
|
|
70
|
+
|
|
71
|
+
/** Starts a watcher for this exact head; false when this head is already watched or already reported. A different head replaces the old watcher. */
|
|
72
|
+
start(target: { id: number; head: string; repo: string; pr: number }): boolean {
|
|
73
|
+
const have = this.active.get(target.id);
|
|
74
|
+
if (have?.watch.head === target.head || this.settled.get(target.id)?.head === target.head) return false;
|
|
75
|
+
if (have) this.cancel(target.id);
|
|
76
|
+
const entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> } = { watch: { ...target, deadlineAt: this.now() + this.deadlineMs } };
|
|
77
|
+
this.active.set(target.id, entry);
|
|
78
|
+
this.schedule(entry);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The result already reported for this exact head, if any. */
|
|
83
|
+
outcomeOf(id: number, head: string): CiOutcome | undefined {
|
|
84
|
+
const done = this.settled.get(id);
|
|
85
|
+
return done?.head === head ? done.outcome : undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Ends the task's watcher (a given head only when `head` is named). No notice. */
|
|
89
|
+
cancel(id: number, head?: string): void {
|
|
90
|
+
const entry = this.active.get(id);
|
|
91
|
+
if (!entry || head !== undefined && entry.watch.head !== head) return;
|
|
92
|
+
clearTimeout(entry.timer);
|
|
93
|
+
this.active.delete(id);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
stopAll(): void {
|
|
97
|
+
for (const entry of this.active.values()) clearTimeout(entry.timer);
|
|
98
|
+
this.active.clear();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private schedule(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): void {
|
|
102
|
+
const delay = Math.max(0, Math.min(this.pollMs, entry.watch.deadlineAt - this.now()));
|
|
103
|
+
entry.timer = setTimeout(() => { void this.tick(entry); }, delay);
|
|
104
|
+
entry.timer.unref?.();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private end(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): boolean {
|
|
108
|
+
if (this.active.get(entry.watch.id) !== entry) return false;
|
|
109
|
+
clearTimeout(entry.timer);
|
|
110
|
+
this.active.delete(entry.watch.id);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private report(entry: { watch: CiWatch }, outcome: CiOutcome): void {
|
|
115
|
+
if (outcome !== "deadline") {
|
|
116
|
+
this.settled.delete(entry.watch.id);
|
|
117
|
+
this.settled.set(entry.watch.id, { head: entry.watch.head, outcome });
|
|
118
|
+
while (this.settled.size > SETTLED_CAP) this.settled.delete(this.settled.keys().next().value as number);
|
|
119
|
+
}
|
|
120
|
+
void Promise.resolve().then(() => this.o.finish(entry.watch, outcome)).catch(() => { /* the finisher guards its own notices */ });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A watcher never throws out of its timer: a `current` or `finish` that fails (a closed board at shutdown) ends it. */
|
|
124
|
+
private async tick(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): Promise<void> {
|
|
125
|
+
try { await this.step(entry); } catch { this.end(entry); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async step(entry: { watch: CiWatch; timer?: ReturnType<typeof setTimeout> }): Promise<void> {
|
|
129
|
+
entry.timer = undefined;
|
|
130
|
+
if (this.active.get(entry.watch.id) !== entry) return;
|
|
131
|
+
if (!this.o.current(entry.watch)) { this.end(entry); return; }
|
|
132
|
+
if (this.now() >= entry.watch.deadlineAt) { if (this.end(entry)) this.report(entry, "deadline"); return; }
|
|
133
|
+
let result: CiPoll;
|
|
134
|
+
try { result = await this.o.poll(entry.watch); } catch { result = "pending"; }
|
|
135
|
+
if (this.active.get(entry.watch.id) !== entry) return; // cancelled or replaced during the read
|
|
136
|
+
if (!this.o.current(entry.watch) || result === "superseded" || result === "closed") { this.end(entry); return; }
|
|
137
|
+
if (result === "pending") { this.schedule(entry); return; }
|
|
138
|
+
if (this.end(entry)) this.report(entry, result);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { closeSync, fstatSync, openSync, readSync, constants } from "node:fs";
|
|
2
2
|
import { isAbsolute, join } from "node:path";
|
|
3
3
|
import { claudeStateFile } from "./claude-state.ts";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { isWsPeer, type WsPeer } from "../adapters/channel-peer.ts";
|
|
6
|
+
import { isFixedBuiltinDefinition, type AdapterDefinition } from "../adapters/registry.ts";
|
|
7
|
+
import type { PeerAdapter } from "./peers.ts";
|
|
4
8
|
|
|
5
|
-
export interface
|
|
9
|
+
export interface ClaudeLaunchRecordProof {
|
|
6
10
|
launchId: string;
|
|
7
11
|
executionCwd?: string;
|
|
8
12
|
privateRecord: boolean;
|
|
@@ -12,7 +16,7 @@ export interface ClaudeLaunchProof {
|
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
/** One bounded reader for current managed launch metadata; no native identity or payload escapes. */
|
|
15
|
-
export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string):
|
|
19
|
+
export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId: string): ClaudeLaunchRecordProof | undefined {
|
|
16
20
|
let fd: number | undefined;
|
|
17
21
|
try {
|
|
18
22
|
fd = openSync(join(stateDir, claudeStateFile("claude-launch", peer)), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -31,3 +35,64 @@ export function readClaudeLaunchProof(stateDir: string, peer: string, instanceId
|
|
|
31
35
|
} catch { return; }
|
|
32
36
|
finally { if (fd !== undefined) closeSync(fd); }
|
|
33
37
|
}
|
|
38
|
+
|
|
39
|
+
/** Host-only launch and socket-generation evidence; adapter-reported generations cannot establish it. */
|
|
40
|
+
export interface ClaudeLaunchProof {
|
|
41
|
+
permissionLaunch(peer: string): { launchId: string; unattended: boolean; hookPurpose?: string } | undefined;
|
|
42
|
+
helloLaunch(peer: string, launchId: unknown): "admit" | "refuse";
|
|
43
|
+
expectedLaunch(peer: string): string | undefined;
|
|
44
|
+
owner(peer: string): WsPeer | undefined;
|
|
45
|
+
bindGeneration(peer: string, generation: string): void;
|
|
46
|
+
verified(peer: string): boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createClaudeLaunchProof(opts: {
|
|
50
|
+
stateDir: string;
|
|
51
|
+
cwd: string;
|
|
52
|
+
instanceId: string;
|
|
53
|
+
definitionOf(peer: string): AdapterDefinition | undefined;
|
|
54
|
+
peer(peer: string): PeerAdapter | undefined;
|
|
55
|
+
}): ClaudeLaunchProof {
|
|
56
|
+
const generations = new Map<string, string>();
|
|
57
|
+
function channel(peer: string): boolean {
|
|
58
|
+
const definition = opts.definitionOf(peer);
|
|
59
|
+
return isFixedBuiltinDefinition(definition) && definition?.transport === "claude-channel";
|
|
60
|
+
}
|
|
61
|
+
function owner(peer: string): WsPeer | undefined {
|
|
62
|
+
if (!channel(peer)) return;
|
|
63
|
+
const current = opts.peer(peer);
|
|
64
|
+
return isWsPeer(current) ? current : undefined;
|
|
65
|
+
}
|
|
66
|
+
function permissionLaunch(peer: string): ReturnType<ClaudeLaunchProof["permissionLaunch"]> {
|
|
67
|
+
if (!channel(peer)) return;
|
|
68
|
+
const row = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
|
|
69
|
+
return row?.permissionHook ? row : undefined;
|
|
70
|
+
}
|
|
71
|
+
function helloLaunch(peer: string, launchId: unknown): "admit" | "refuse" {
|
|
72
|
+
if (!channel(peer) || launchId === undefined) return "admit";
|
|
73
|
+
let currentLaunch: unknown;
|
|
74
|
+
try {
|
|
75
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
|
|
76
|
+
if (launch) currentLaunch = launch.launchId;
|
|
77
|
+
} catch { /* No current launch evidence: retain plain/pre-daemon attachment compatibility. */ }
|
|
78
|
+
return currentLaunch !== undefined && launchId !== currentLaunch ? "refuse" : "admit";
|
|
79
|
+
}
|
|
80
|
+
function expectedLaunch(peer: string): string | undefined {
|
|
81
|
+
if (!channel(peer)) return;
|
|
82
|
+
let expectedLaunch: string | undefined;
|
|
83
|
+
try {
|
|
84
|
+
const rows = JSON.parse(readFileSync(join(opts.stateDir, "terminal-recovery.json"), "utf8"));
|
|
85
|
+
expectedLaunch = Array.isArray(rows) ? rows.find(row => row?.peer === peer && opts.definitionOf(row.peer)?.transport === "claude-channel" && row?.projectRoot === opts.cwd && row?.instanceId === opts.instanceId)?.launchId : undefined;
|
|
86
|
+
} catch { /* an ordinary terminal has no Orca recovery identity */ }
|
|
87
|
+
try {
|
|
88
|
+
const launch = readClaudeLaunchProof(opts.stateDir, peer, opts.instanceId);
|
|
89
|
+
if (launch) expectedLaunch = launch.launchId;
|
|
90
|
+
} catch { /* older/manual launches do not have a launcher marker */ }
|
|
91
|
+
return expectedLaunch;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
permissionLaunch, helloLaunch, expectedLaunch, owner,
|
|
95
|
+
bindGeneration(peer, generation) { if (owner(peer)) generations.set(peer, generation); },
|
|
96
|
+
verified(peer) { const current = owner(peer); return !!current && generations.get(peer) === current.sessionGeneration; },
|
|
97
|
+
};
|
|
98
|
+
}
|