@staix/agent-hub 0.16.0 → 0.18.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 +34 -0
- package/README.md +2 -2
- package/docs/agent-notes/README.md +5 -0
- package/docs/agent-notes/adapters.md +20 -5
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +5 -3
- package/docs/agent-notes/local-worker.md +1 -0
- package/docs/agent-notes/tasks.md +9 -3
- package/docs/agent-notes/tests.md +6 -3
- package/docs/dogfood.md +21 -4
- package/docs/memory.md +1 -1
- package/docs/operations.md +150 -34
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +14 -4
- package/docs/roles.md +1 -0
- package/docs/sdlc.md +39 -13
- package/docs/security.md +41 -9
- package/docs/smoke.md +51 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +56 -10
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +3 -1
- package/docs/verification/2026-10-25-0.17.0.md +74 -0
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-0164-reviewer-tool-call.md +52 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-kimi-231-reviewer-compat.md +48 -0
- package/docs/verification/2026-10-25-kimi-usage-ac11.md +48 -0
- package/docs/verification/2026-10-25-qwen-dual-output-hub-e2e.md +23 -0
- package/docs/verification/2026-10-25-qwen-dual-output-t0.md +20 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +46 -0
- package/docs/verification/2026-10-25-reviewer-no-verdict-diagnostics.md +35 -0
- package/docs/verification/profiles/YYYY-MM-DD-general.md +46 -0
- package/docs/verification/profiles/YYYY-MM-DD-research.md +61 -0
- package/docs/verification/profiles/YYYY-MM-DD-writing.md +46 -0
- package/docs/verified.json +28 -19
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1115 -192
- package/src/adapters/codex-review-isolation.ts +10 -1
- package/src/adapters/compat.ts +7 -2
- package/src/adapters/conformance/enrollment.ts +18 -4
- package/src/adapters/conformance/factories.ts +4 -1
- package/src/adapters/conformance/kit.ts +11 -0
- package/src/adapters/definitions/builtin-metadata.ts +1 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +32 -4
- package/src/adapters/definitions/native-cli-preview-qwen.ts +9 -1
- package/src/adapters/definitions/native-factories.ts +8 -0
- package/src/adapters/definitions/native-recovery.ts +8 -0
- package/src/adapters/definitions/qwen.ts +7 -4
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/qwen-dual.ts +578 -0
- package/src/adapters/registry-types.ts +5 -1
- package/src/adapters/registry.ts +9 -1
- package/src/adapters/reviewer-mcp.ts +12 -6
- package/src/adapters/reviewer-print.ts +100 -17
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +190 -69
- package/src/cli/output.ts +7 -2
- package/src/cli/sdlc-output.ts +242 -0
- package/src/cli/task-output.ts +212 -0
- package/src/cli/upgrade-output.ts +57 -0
- package/src/cli/upgrade.ts +4 -3
- package/src/hub/board.ts +17 -0
- package/src/hub/check-runner.ts +252 -22
- package/src/hub/conductor.ts +5 -0
- package/src/hub/config-apply.ts +16 -7
- package/src/hub/config-edit-host.ts +1 -1
- package/src/hub/config-tier.ts +1 -1
- package/src/hub/daemon.ts +487 -94
- package/src/hub/dogfood-profile.ts +248 -0
- package/src/hub/dogfood-read.ts +77 -14
- package/src/hub/dogfood.ts +5 -1
- package/src/hub/events.ts +1 -1
- package/src/hub/host-executable.ts +2 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/memory-plan-guard.ts +8 -33
- package/src/hub/operational-authority.ts +60 -0
- package/src/hub/operational-view.ts +141 -0
- package/src/hub/orphan-process.ts +69 -0
- package/src/hub/permission-mode.ts +1 -1
- package/src/hub/read-only-shell-proof.ts +80 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/reviewer-pool-config.ts +3 -2
- package/src/hub/reviewer-pool.ts +3 -0
- package/src/hub/routing.ts +23 -6
- package/src/hub/sdlc-merge.ts +299 -0
- package/src/hub/sdlc-mirror.ts +129 -0
- package/src/hub/sdlc.ts +58 -12
- package/src/hub/settings.ts +11 -5
- package/src/hub/snapshots.ts +60 -0
- package/src/hub/task-repository.ts +31 -5
- package/src/hub/tasks.ts +266 -28
- package/src/local/tools.ts +17 -4
- package/src/memory/brief.ts +45 -5
- package/src/ui/index.html +2 -1
package/src/hub/check-runner.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
1
2
|
import { spawn } from "node:child_process";
|
|
2
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
4
|
import { constants, closeSync, fstatSync, openSync, readFileSync } from "node:fs";
|
|
4
|
-
import { lstat, mkdir, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
5
7
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
8
|
import { hubGitEnv, hubGitSync } from "./git.ts";
|
|
9
|
+
import { stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
7
10
|
import { hostExecutable } from "./host-executable.ts";
|
|
8
11
|
import { PreEffectToolRefusal } from "./tool-refusal.ts";
|
|
9
12
|
import { processLiveness, processSignature } from "../pi/process-signature.ts";
|
|
@@ -21,7 +24,7 @@ export interface CheckRequest {
|
|
|
21
24
|
}
|
|
22
25
|
export interface RunReceipt extends CheckRequest {
|
|
23
26
|
run: string; sha: string; tree: string; lockfile: string; excluded: number; dependency_files: number;
|
|
24
|
-
runner: "container"; image: string; platform: string; container: string;
|
|
27
|
+
runner: "container" | "remote"; image: string; platform: string; container: string;
|
|
25
28
|
hub_pid: number; hub_signature: string; created: number;
|
|
26
29
|
status: "queued" | "running" | "finished" | "stale";
|
|
27
30
|
exit: number | null; timed_out: boolean; cancelled: boolean; interrupted: boolean;
|
|
@@ -29,9 +32,9 @@ export interface RunReceipt extends CheckRequest {
|
|
|
29
32
|
error?: string;
|
|
30
33
|
}
|
|
31
34
|
export interface RunHandle { run: string; done: Promise<RunReceipt> }
|
|
32
|
-
export interface CommandResult { code: number | null; stdout: string }
|
|
35
|
+
export interface CommandResult { code: number | null; stdout: string; outputBytes?: number }
|
|
33
36
|
/** Fixed-argv transport seam. The production implementation below is the only container spawner. */
|
|
34
|
-
export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void }) => Promise<CommandResult>;
|
|
37
|
+
export type ContainerCommand = (args: string[], options?: { signal?: AbortSignal; timeoutMs?: number; output?: (data: Buffer) => void; input?: ReadableStream<Uint8Array> }) => Promise<CommandResult>;
|
|
35
38
|
export interface RunnerOptions {
|
|
36
39
|
projectDir: string; stateDir: string; storageDir: string; projectKey: string;
|
|
37
40
|
enabled: boolean | (() => boolean); isDenied: (path: string) => boolean;
|
|
@@ -39,10 +42,25 @@ export interface RunnerOptions {
|
|
|
39
42
|
/** Daemon authority fence, re-evaluated across every asynchronous stage. */
|
|
40
43
|
isCurrent?: (request: CheckRequest) => boolean;
|
|
41
44
|
/** Test seams; never supplied from a tool request or config. */
|
|
42
|
-
command?: ContainerCommand; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
|
|
45
|
+
command?: ContainerCommand; remote?: RemoteRunnerConfig; timeoutMs?: number; lockWaitMs?: number; lockPollMs?: number;
|
|
43
46
|
schedule?: (callback: () => void, ms: number) => () => void;
|
|
44
47
|
now?: () => number; liveness?: typeof processLiveness; signature?: typeof processSignature;
|
|
45
48
|
}
|
|
49
|
+
export interface RemoteRunnerConfig { host: string; image: string; root: string }
|
|
50
|
+
/** Only the backend mechanics vary; task authority, staging, receipts and leases remain one lifecycle. */
|
|
51
|
+
interface RunnerTransport {
|
|
52
|
+
command: ContainerCommand;
|
|
53
|
+
kind: "remote"; image: string; platform: string;
|
|
54
|
+
probe(command: ContainerCommand, cleanup: ContainerCommand): Promise<RunnerState>;
|
|
55
|
+
isolate(command: ContainerCommand): Promise<void>;
|
|
56
|
+
runArgs(receipt: RunReceipt, labels: string[]): string[];
|
|
57
|
+
copy(command: ContainerCommand, source: string, container: string, signal: AbortSignal): Promise<void>;
|
|
58
|
+
execArgs(container: string, words: readonly string[]): string[];
|
|
59
|
+
cleanup(command: ContainerCommand, name: string): Promise<boolean>;
|
|
60
|
+
listArgs: string[];
|
|
61
|
+
list(stdout: string): unknown[];
|
|
62
|
+
redact(text: string): string;
|
|
63
|
+
}
|
|
46
64
|
interface Entry { receipt: RunReceipt; resolve: (receipt: RunReceipt) => void; abort: AbortController }
|
|
47
65
|
function safePath(path: string): boolean {
|
|
48
66
|
return !!path && !isAbsolute(path) && !path.includes("\\") && !path.includes("\0") && path.split("/").every(p => p !== ".." && p !== "." && p !== "");
|
|
@@ -108,9 +126,9 @@ export class ContainerRunner {
|
|
|
108
126
|
private readonly records: string;
|
|
109
127
|
private readonly root: string;
|
|
110
128
|
private readonly retentionTimer: ReturnType<typeof setInterval>;
|
|
111
|
-
constructor(private readonly options: RunnerOptions) {
|
|
129
|
+
constructor(private readonly options: RunnerOptions, private readonly transport?: RunnerTransport) {
|
|
112
130
|
if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
|
|
113
|
-
this.command = options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
|
|
131
|
+
this.command = transport?.command ?? options.command ?? (async (args, opts) => runContainer(hostExecutable("container", options.projectDir), args, opts));
|
|
114
132
|
this.now = options.now ?? Date.now;
|
|
115
133
|
this.signature = (options.signature ?? processSignature)(process.pid) ?? "";
|
|
116
134
|
this.records = join(options.stateDir, "checks");
|
|
@@ -153,6 +171,15 @@ export class ContainerRunner {
|
|
|
153
171
|
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
154
172
|
return result;
|
|
155
173
|
};
|
|
174
|
+
if (this.transport) {
|
|
175
|
+
value = await this.transport.probe(async (args, options) => {
|
|
176
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
177
|
+
const result = await this.command(args, { ...options, signal, timeoutMs: 15_000 });
|
|
178
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
179
|
+
return result;
|
|
180
|
+
}, (args, options) => this.command(args, { ...options, timeoutMs: 15_000 }));
|
|
181
|
+
if (this.closing || signal.aborted) throw new Error("check stopped");
|
|
182
|
+
} else {
|
|
156
183
|
const version = await probe(["--version"]);
|
|
157
184
|
const service = await probe(["system", "status"]);
|
|
158
185
|
const image = await probe(["image", "inspect", CHECK_IMAGE]);
|
|
@@ -164,7 +191,8 @@ export class ContainerRunner {
|
|
|
164
191
|
: network.code === 0 && !isolatedNetwork(network.stdout)
|
|
165
192
|
? { available: false, reason: "network isolation unverified" }
|
|
166
193
|
: { available: true, platform: `linux/arm64 (apple-container ${version.stdout.trim().slice(0, 80)})` };
|
|
167
|
-
|
|
194
|
+
}
|
|
195
|
+
} catch { if (this.closing || signal.aborted) return { available: false, reason: this.closing ? "stopping" : "check stopped" }; value = { available: false, reason: this.transport ? "remote runner unavailable" : "container unavailable" }; }
|
|
168
196
|
const checkedAt = this.now();
|
|
169
197
|
value.checkedAt = checkedAt;
|
|
170
198
|
this.availableCache = { at: checkedAt, value };
|
|
@@ -179,7 +207,7 @@ export class ContainerRunner {
|
|
|
179
207
|
if (this.entries.size >= 3) throw new PreEffectToolRefusal("busy");
|
|
180
208
|
const run = randomUUID();
|
|
181
209
|
const receipt: RunReceipt = { ...request, files: request.files ? [...request.files] : undefined, run, sha: "", tree: "", lockfile: "", excluded: 0, dependency_files: 0,
|
|
182
|
-
runner: "container", image: CHECK_IMAGE, platform: "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
|
|
210
|
+
runner: this.transport?.kind ?? "container", image: this.transport?.image ?? CHECK_IMAGE, platform: this.transport?.platform ?? "linux/arm64 (apple-container unknown)", container: `agenthub-check-${run}`, hub_pid: process.pid, hub_signature: this.signature, created: this.now(), status: "queued", exit: null,
|
|
183
211
|
timed_out: false, cancelled: false, interrupted: false, duration_ms: 0, tail: "", output_bytes: 0, truncated: false, output_withheld: false };
|
|
184
212
|
let resolveResult!: (receipt: RunReceipt) => void;
|
|
185
213
|
const done = new Promise<RunReceipt>(r => { resolveResult = r; });
|
|
@@ -282,13 +310,23 @@ export class ContainerRunner {
|
|
|
282
310
|
if (data.status !== 0 || data.error) throw new Error("source blob unavailable");
|
|
283
311
|
const blob = Buffer.from(data.stdout);
|
|
284
312
|
bytes += blob.length; if (bytes > 512 * 1024 * 1024) throw new Error("source exceeds byte limit");
|
|
285
|
-
const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: entry.mode === "100755" ? 0o700 : 0o600 });
|
|
313
|
+
const dest = join(src, entry.path); await mkdir(dirname(dest), { recursive: true }); check(); await writeFile(dest, blob, { mode: this.transport ? entry.mode === "100755" ? 0o755 : 0o644 : entry.mode === "100755" ? 0o700 : 0o600 });
|
|
314
|
+
if (this.transport) {
|
|
315
|
+
// chmod is intentional: the hub may have umask 077, but the remote non-root uid must traverse/read.
|
|
316
|
+
await chmod(dest, entry.mode === "100755" ? 0o755 : 0o644);
|
|
317
|
+
for (let directory = dirname(dest); directory !== dirname(src); directory = dirname(directory)) await chmod(directory, 0o755);
|
|
318
|
+
}
|
|
319
|
+
check(); paths.add(entry.path);
|
|
286
320
|
}
|
|
287
321
|
if (receipt.files?.some(file => !paths.has(file))) throw new Error("test file unavailable in snapshot");
|
|
288
322
|
if (!paths.has("bun.lock")) throw new Error("lockfile unavailable");
|
|
289
323
|
const lock = await readFile(join(src, "bun.lock")); check();
|
|
290
|
-
const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
|
|
291
324
|
receipt.lockfile = hash(lock);
|
|
325
|
+
if (this.transport) {
|
|
326
|
+
// Source only: Mac dependencies must never be transported to a Linux runner.
|
|
327
|
+
await chmod(src, 0o755); check(); return;
|
|
328
|
+
}
|
|
329
|
+
const hostLock = await this.regularFile(join(this.options.projectDir, "bun.lock")); check();
|
|
292
330
|
if (receipt.lockfile !== hash(hostLock)) throw new Error("lockfile differs; dependencies unavailable");
|
|
293
331
|
const deps = join(this.options.projectDir, "node_modules");
|
|
294
332
|
const dest = join(src, "node_modules");
|
|
@@ -333,7 +371,8 @@ export class ContainerRunner {
|
|
|
333
371
|
try {
|
|
334
372
|
this.ensureCurrent(entry);
|
|
335
373
|
await this.save(r); this.ensureCurrent(entry);
|
|
336
|
-
const state = await this.available(entry.abort.signal); this.ensureCurrent(entry);
|
|
374
|
+
const state = await this.available(entry.abort.signal); this.ensureCurrent(entry);
|
|
375
|
+
if (!state.available) { if (this.transport && state.reason === "remote runner unavailable") throw new RemoteTransportError(); throw new Error(`runner unavailable: ${state.reason}`); }
|
|
337
376
|
r.platform = state.platform!;
|
|
338
377
|
release = await this.heavy(entry); this.ensureCurrent(entry);
|
|
339
378
|
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
@@ -346,43 +385,54 @@ export class ContainerRunner {
|
|
|
346
385
|
if (!entry.abort.signal.aborted && this.now() >= deadline) expire();
|
|
347
386
|
if (entry.abort.signal.aborted) throw new Error("check stopped");
|
|
348
387
|
};
|
|
349
|
-
const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void) => {
|
|
388
|
+
const command = async (args: string[], limit = 15_000, output?: (data: Buffer) => void, input?: ReadableStream<Uint8Array>) => {
|
|
350
389
|
checkRunning();
|
|
351
|
-
const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}) });
|
|
390
|
+
const result = await this.command(args, { signal: entry.abort.signal, timeoutMs: Math.min(limit, deadline - this.now()), ...(output ? { output } : {}), ...(input ? { input } : {}) });
|
|
352
391
|
checkRunning(); // A transport may return success after cancellation; it cannot authorize another stage.
|
|
353
392
|
return result;
|
|
354
393
|
};
|
|
394
|
+
await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700); checkRunning();
|
|
355
395
|
await this.snapshot(r, src, entry.abort.signal, checkRunning);
|
|
356
396
|
this.ensureCurrent(entry);
|
|
357
397
|
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
398
|
+
const transportCommand: ContainerCommand = (args, options) => command(args, options?.timeoutMs, options?.output, options?.input);
|
|
399
|
+
if (this.transport) await this.transport.isolate(transportCommand);
|
|
400
|
+
else {
|
|
358
401
|
const network = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
|
|
359
402
|
if (network.code !== 0 && (await command(["network", "create", "--internal", NETWORK])).code !== 0) throw new Error("runner unavailable: internal network unavailable");
|
|
360
403
|
this.ensureCurrent(entry);
|
|
361
404
|
// Refuse an existing non-internal network rather than trusting its name.
|
|
362
405
|
const info = await command(["network", "inspect", NETWORK]); this.ensureCurrent(entry);
|
|
363
406
|
if (info.code !== 0 || !isolatedNetwork(info.stdout)) throw new Error("runner unavailable: network isolation unverified");
|
|
407
|
+
}
|
|
364
408
|
r.status = "running"; await this.save(r); this.ensureCurrent(entry);
|
|
365
409
|
attemptedContainer = true;
|
|
366
410
|
// Cold VM startup and a <=1.5 GiB source/dependency transfer need their own bounded allowances.
|
|
367
|
-
const
|
|
411
|
+
const labels = ["--label", `agenthub.project=${this.options.projectKey}`, "--label", `agenthub.hub=${process.pid}:${this.signature}`, "--label", `agenthub.run=${r.run}`];
|
|
412
|
+
const run = await command(this.transport?.runArgs(r, labels) ?? ["run", "--detach", "--rm", "--name", r.container, ...labels, "--network", NETWORK, "--no-dns", "--cpus", "2", "--memory", "4g", "--workdir", "/work", CHECK_IMAGE, "sleep", "600"], 120_000);
|
|
368
413
|
this.ensureCurrent(entry);
|
|
369
|
-
if (run.code !== 0) throw new Error("runner unavailable: container start failed");
|
|
370
|
-
|
|
414
|
+
if (run.code !== 0) { if (this.transport) throw new RemoteTransportError(); throw new Error("runner unavailable: container start failed"); }
|
|
415
|
+
if (this.transport) await this.transport.copy(transportCommand, src, r.container, entry.abort.signal);
|
|
416
|
+
else {
|
|
417
|
+
const copy = await command(["cp", `${src}/.`, `${r.container}:/work`], 180_000);
|
|
418
|
+
if (copy.code !== 0) throw new Error("container source transfer failed");
|
|
419
|
+
}
|
|
371
420
|
this.ensureCurrent(entry);
|
|
372
|
-
if (copy.code !== 0) throw new Error("container source transfer failed");
|
|
373
421
|
if (entry.abort.signal.aborted) throw new Error("cancelled");
|
|
374
422
|
const words = r.check === "test" ? ["bun", "test", ...r.files!] : ["bun", "x", "--no-install", "tsc", "--noEmit"];
|
|
375
|
-
const result = await command(["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
|
|
423
|
+
const result = await command(this.transport?.execArgs(r.container, words) ?? ["exec", "--workdir", "/work", r.container, ...words], 600_000, data => {
|
|
376
424
|
r.output_bytes += data.length;
|
|
377
425
|
if (output.length < MAX_OUTPUT) output = Buffer.concat([output, data.subarray(0, MAX_OUTPUT - output.length)]);
|
|
378
426
|
outputTail = Buffer.concat([outputTail, data]).subarray(-16_384);
|
|
379
427
|
r.truncated = r.output_bytes > MAX_OUTPUT;
|
|
380
428
|
});
|
|
381
429
|
this.ensureCurrent(entry);
|
|
430
|
+
if (this.transport && result.code === 255) { r.output_withheld = true; throw new RemoteTransportError(); }
|
|
431
|
+
if (result.outputBytes !== undefined) { r.output_bytes = result.outputBytes; r.truncated = r.output_bytes > MAX_OUTPUT; }
|
|
382
432
|
r.exit = entry.abort.signal.aborted ? null : result.code;
|
|
383
433
|
} catch (error) {
|
|
384
434
|
const code = (error as NodeJS.ErrnoException)?.code;
|
|
385
|
-
r.error = entry.abort.signal.aborted ? "check stopped" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
|
|
435
|
+
r.error = entry.abort.signal.aborted ? "check stopped" : error instanceof RemoteTransportError ? "remote runner unavailable" : code ? "check filesystem or runtime unavailable" : error instanceof Error ? error.message : "check unavailable";
|
|
386
436
|
}
|
|
387
437
|
finally {
|
|
388
438
|
cancelDeadline?.();
|
|
@@ -391,8 +441,11 @@ export class ContainerRunner {
|
|
|
391
441
|
if (!removed) { r.error = "container cleanup unconfirmed; heavy slot retained"; r.exit = null; }
|
|
392
442
|
await rm(src, { recursive: true, force: true }).catch(() => {});
|
|
393
443
|
if (release && removed) await release().catch(() => {});
|
|
394
|
-
|
|
444
|
+
const rawTail = outputTail.toString("utf8"), safeTail = this.transport?.redact(rawTail) ?? rawTail;
|
|
445
|
+
r.output_withheld ||= safeTail !== rawTail;
|
|
446
|
+
r.duration_ms = Math.max(0, this.now() - begun); r.tail = tail(safeTail, r.output_bytes > outputTail.length); if (r.status !== "stale") r.status = "finished";
|
|
395
447
|
if (!this.current(r)) { r.status = "stale"; }
|
|
448
|
+
if (this.transport) { const raw = output.toString("utf8"), safe = this.transport.redact(raw); r.output_withheld ||= safe !== raw; output = Buffer.from(safe); }
|
|
396
449
|
try { await mkdir(dir, { recursive: true, mode: 0o700 }); await writeFile(join(dir, "output.log"), r.truncated ? Buffer.concat([output.subarray(0, MAX_OUTPUT - 7), Buffer.from("\n[cut]\n")]) : output, { mode: 0o600 }); await this.save(r); } catch { r.error = "check receipt storage unavailable"; r.tail = ""; r.output_withheld = true; }
|
|
397
450
|
await this.maintain(); // Maintenance never changes an outcome already saved for this run.
|
|
398
451
|
if (!this.current(r)) { r.status = "stale"; await this.save(r).catch(() => {}); }
|
|
@@ -400,6 +453,7 @@ export class ContainerRunner {
|
|
|
400
453
|
}
|
|
401
454
|
}
|
|
402
455
|
private async cleanup(name: string): Promise<boolean> {
|
|
456
|
+
if (this.transport) return this.transport.cleanup(this.command, name);
|
|
403
457
|
await this.command(["kill", name]).catch(() => {});
|
|
404
458
|
await this.command(["rm", name]).catch(() => {});
|
|
405
459
|
try {
|
|
@@ -458,13 +512,18 @@ export class ContainerRunner {
|
|
|
458
512
|
if (!this.enabled()) { await this.maintain(); return unknown; }
|
|
459
513
|
try {
|
|
460
514
|
let containers: unknown;
|
|
461
|
-
try { const list = await this.command(["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = JSON.parse(list.stdout); } catch { return unknown; }
|
|
515
|
+
try { const list = await this.command(this.transport?.listArgs ?? ["list", "--all", "--format", "json"]); if (list.code !== 0) return unknown; containers = this.transport?.list(list.stdout) ?? JSON.parse(list.stdout); } catch { return unknown; }
|
|
462
516
|
if (!Array.isArray(containers)) return unknown;
|
|
463
517
|
for (const container of containers) {
|
|
464
518
|
const c = container as { id?: string; configuration?: { id?: string; labels?: Record<string, string> }; labels?: Record<string, string> };
|
|
465
519
|
const labels = c.configuration?.labels ?? c.labels;
|
|
466
520
|
if (labels?.["agenthub.project"] !== this.options.projectKey) continue;
|
|
467
521
|
const name = c.configuration?.id ?? c.id;
|
|
522
|
+
const probe = labels["agenthub.probe"];
|
|
523
|
+
// Probes have no task receipt and self-expire after the fixed 600-second idle process.
|
|
524
|
+
// Recognizing their exact shape is no authority to kill them or other unknown owners.
|
|
525
|
+
if (this.transport && typeof probe === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(probe)
|
|
526
|
+
&& name === `agenthub-check-probe-${probe}` && !labels["agenthub.run"]) continue;
|
|
468
527
|
const run = labels["agenthub.run"];
|
|
469
528
|
if (!name || !run || name !== `agenthub-check-${run}`) { unknown.push("orphan check: unknown owner"); continue; }
|
|
470
529
|
let r: RunReceipt | undefined;
|
|
@@ -512,3 +571,174 @@ export class ContainerRunner {
|
|
|
512
571
|
}
|
|
513
572
|
}
|
|
514
573
|
}
|
|
574
|
+
|
|
575
|
+
/** Machine-local data only; every failure is value-free so host/account names never enter diagnostics. */
|
|
576
|
+
export function remoteRunnerConfig(value: unknown): RemoteRunnerConfig {
|
|
577
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid remote runner configuration");
|
|
578
|
+
const raw = value as Record<string, unknown>;
|
|
579
|
+
if (Object.keys(raw).sort().join(",") !== "host,image,root"
|
|
580
|
+
|| typeof raw.host !== "string" || raw.host.length > 288 || !/^(?:[a-z_][a-z0-9_-]{0,31}@)?[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?$/.test(raw.host)
|
|
581
|
+
|| typeof raw.image !== "string" || !/^[a-z0-9][a-z0-9./:_-]{0,200}@sha256:[a-f0-9]{64}$/.test(raw.image)
|
|
582
|
+
|| typeof raw.root !== "string" || raw.root.length > 1024 || !/^(?:\/[A-Za-z0-9._-]+)+\/?$/.test(raw.root) || raw.root.split("/").some(part => part === "." || part === "..")) throw new Error("invalid remote runner configuration");
|
|
583
|
+
return { host: raw.host, image: raw.image, root: raw.root };
|
|
584
|
+
}
|
|
585
|
+
/** OpenSSH joins its remote argv into a shell command. Quote every token, including fixed Docker arguments. */
|
|
586
|
+
export function remoteDockerArgs(host: string, args: readonly string[], mergeStderr = false): string[] {
|
|
587
|
+
const quote = (word: string) => `'${word.replaceAll("'", `'"'"'`)}'`;
|
|
588
|
+
return ["-T", "-a", "-x", "-o", "ClearAllForwardings=yes", "-o", "PermitLocalCommand=no", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "--", host, `docker ${args.map(quote).join(" ")}${args[0] === "exec" && mergeStderr ? " 2>&1" : ""}`];
|
|
589
|
+
}
|
|
590
|
+
/** Darwin's system tar is bsdtar even when a GNU tar shim comes first on PATH. */
|
|
591
|
+
export function remoteArchiveArgs(projectDir: string, source: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
592
|
+
const executable = platform === "darwin" ? "/usr/bin/tar" : hostExecutable("tar", projectDir);
|
|
593
|
+
const metadata = platform === "darwin"
|
|
594
|
+
? ["--format=gnutar", "--no-xattrs", "--no-acls", "--no-fflags", "--disable-copyfile", "--uid", "65532", "--gid", "65532", "--uname", "check", "--gname", "check"]
|
|
595
|
+
: ["--format=gnu", "--no-xattrs", "--no-acls", "--owner=65532", "--group=65532", "--numeric-owner"];
|
|
596
|
+
return [executable, ...metadata, "-cf", "-", "-C", source, "."];
|
|
597
|
+
}
|
|
598
|
+
function runRemoteDocker(executable: string, remote: RemoteRunnerConfig, args: string[], options: Parameters<ContainerCommand>[1] = {}): Promise<CommandResult> {
|
|
599
|
+
return new Promise((resolveResult, reject) => {
|
|
600
|
+
if (options.signal?.aborted) { reject(new Error("remote runner unavailable")); return; }
|
|
601
|
+
// The hub's agent socket authenticates this client; -a prevents forwarding it into the remote job.
|
|
602
|
+
const env = { PATH: dirname(executable), HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK };
|
|
603
|
+
const child = spawn(executable, remoteDockerArgs(remote.host, args, options.output !== undefined), { env, detached: true, stdio: [options.input ? "pipe" : "ignore", "pipe", "pipe"] });
|
|
604
|
+
let stdout = "", inputFailed = false, bytes = 0, first = Buffer.alloc(0), last = Buffer.alloc(0);
|
|
605
|
+
let stopping: Promise<void> | undefined;
|
|
606
|
+
const stop = () => stopping ??= stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 });
|
|
607
|
+
const abort = () => { void stop().catch(() => {}); };
|
|
608
|
+
child.once("spawn", () => { trackGroup(child); if (options.signal?.aborted) abort(); });
|
|
609
|
+
const timer = setTimeout(abort, Math.max(1, Math.min(600_000, options.timeoutMs ?? 15_000)));
|
|
610
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
611
|
+
if (options.signal?.aborted) abort();
|
|
612
|
+
const input = options.input ? Readable.fromWeb(options.input as import("node:stream/web").ReadableStream<Uint8Array>) : undefined;
|
|
613
|
+
input?.on("error", () => { inputFailed = true; abort(); }); child.stdin?.on("error", () => { inputFailed = true; input?.destroy(); }); if (input && child.stdin) input.pipe(child.stdin);
|
|
614
|
+
child.stdout!.on("data", (data: Buffer) => {
|
|
615
|
+
bytes += data.length;
|
|
616
|
+
if (options.output) {
|
|
617
|
+
if (first.length < MAX_OUTPUT) first = Buffer.concat([first, data.subarray(0, MAX_OUTPUT - first.length)]);
|
|
618
|
+
last = Buffer.concat([last, data]).subarray(-16_384);
|
|
619
|
+
} else stdout = (stdout + data.toString()).slice(-MAX_OUTPUT);
|
|
620
|
+
});
|
|
621
|
+
// SSH diagnostics mix account/host paths with remote stderr. Never forward that channel as task output.
|
|
622
|
+
child.stderr!.resume();
|
|
623
|
+
const finish = () => { clearTimeout(timer); options.signal?.removeEventListener("abort", abort); input?.destroy(); };
|
|
624
|
+
child.once("error", () => { finish(); reject(new Error("remote runner unavailable")); });
|
|
625
|
+
child.once("close", async code => {
|
|
626
|
+
finish();
|
|
627
|
+
try { await stop(); } catch { reject(new Error("remote runner unavailable")); return; }
|
|
628
|
+
if (code === 255 || options.signal?.aborted) return resolveResult({ code, stdout: "", outputBytes: 0 });
|
|
629
|
+
if (options.output) { options.output(first); if (bytes > first.length) options.output(last); }
|
|
630
|
+
resolveResult({ code: inputFailed ? 1 : code, stdout, ...(options.output ? { outputBytes: bytes } : {}) });
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/** Distinguish transport failures from deterministic request/snapshot refusals without leaking remote stderr. */
|
|
635
|
+
class RemoteTransportError extends Error { constructor() { super("remote runner unavailable"); } }
|
|
636
|
+
|
|
637
|
+
/** Same production strategy for transport-only component/native checks; production uses RemoteRunner's dependency gate. */
|
|
638
|
+
export function remoteRunnerTransport(options: RunnerOptions): RunnerTransport {
|
|
639
|
+
if (!/^[A-Za-z0-9_-]+$/.test(options.projectKey)) throw new Error("invalid project key");
|
|
640
|
+
const remote = remoteRunnerConfig(options.remote), digest = remote.image.slice(remote.image.indexOf("@") + 1);
|
|
641
|
+
const command: ContainerCommand = async (args, opts) => {
|
|
642
|
+
try { return await (options.command ? options.command(args, opts) : runRemoteDocker(hostExecutable("ssh", options.projectDir), remote, args, opts)); }
|
|
643
|
+
catch { throw new RemoteTransportError(); }
|
|
644
|
+
};
|
|
645
|
+
const privateValues = [...new Set([remote.host, ...remote.host.split("@"), remote.root])].sort((a, b) => b.length - a.length);
|
|
646
|
+
const redact = (text: string) => {
|
|
647
|
+
for (const value of privateValues) {
|
|
648
|
+
text = text.replaceAll(value, "***");
|
|
649
|
+
for (let length = Math.min(value.length - 1, text.length); length > 0; length--) if (text.endsWith(value.slice(0, length))) { text = text.slice(0, -length) + "***"; break; }
|
|
650
|
+
}
|
|
651
|
+
return text;
|
|
652
|
+
};
|
|
653
|
+
const format = '{"id":{{json .Names}},"project":{{json (.Label "agenthub.project")}},"hub":{{json (.Label "agenthub.hub")}},"run":{{json (.Label "agenthub.run")}},"probe":{{json (.Label "agenthub.probe")}}}';
|
|
654
|
+
const inventoryArgs = (name?: string, project = true) => ["ps", "--all", "--no-trunc", ...(project ? ["--filter", `label=agenthub.project=${options.projectKey}`] : []), ...(name ? ["--filter", `name=^/${name}$`] : []), "--format", format];
|
|
655
|
+
const listArgs = inventoryArgs();
|
|
656
|
+
const list = (stdout: string): { id: string; labels: Record<string, string> }[] => {
|
|
657
|
+
const lines = stdout.trim() ? stdout.trim().split("\n") : [];
|
|
658
|
+
if (lines.length > 10_000) throw new RemoteTransportError();
|
|
659
|
+
return lines.map(line => {
|
|
660
|
+
let value: Record<string, unknown>;
|
|
661
|
+
try { value = JSON.parse(line) as Record<string, unknown>; } catch { throw new RemoteTransportError(); }
|
|
662
|
+
if (!["id", "project", "hub", "run", "probe"].every(key => typeof value[key] === "string")) throw new RemoteTransportError();
|
|
663
|
+
return { id: value.id as string, labels: { "agenthub.project": value.project as string, "agenthub.hub": value.hub as string, "agenthub.run": value.run as string, "agenthub.probe": value.probe as string } };
|
|
664
|
+
});
|
|
665
|
+
};
|
|
666
|
+
const cleanup = async (command: ContainerCommand, name: string): Promise<boolean> => {
|
|
667
|
+
if (!/^agenthub-check-(?:probe-)?[a-f0-9-]{36}$/.test(name)) return false;
|
|
668
|
+
try {
|
|
669
|
+
const before = await command(inventoryArgs(name));
|
|
670
|
+
if (before.code !== 0) return false;
|
|
671
|
+
const owned = list(before.stdout).filter(row => row.id === name && row.labels["agenthub.project"] === options.projectKey);
|
|
672
|
+
if (owned.length > 1) return false;
|
|
673
|
+
if (owned.length === 1) { await command(["kill", name]).catch(() => {}); await command(["rm", name]).catch(() => {}); }
|
|
674
|
+
// Exact-name absence, even if a same-name container has different labels; never kill that other owner.
|
|
675
|
+
const after = await command(inventoryArgs(name, false));
|
|
676
|
+
return after.code === 0 && !list(after.stdout).some(row => row.id === name);
|
|
677
|
+
} catch { return false; }
|
|
678
|
+
};
|
|
679
|
+
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"];
|
|
680
|
+
const execArgs = (container: string, words: readonly string[]) => ["exec", "--user", "65532:65532", "--workdir", "/work", container, ...words];
|
|
681
|
+
const copy = async (command: ContainerCommand, source: string, container: string, signal: AbortSignal) => {
|
|
682
|
+
try {
|
|
683
|
+
const argv = remoteArchiveArgs(options.projectDir, source);
|
|
684
|
+
const archive = Bun.spawn(argv, { env: { PATH: dirname(argv[0]!), COPYFILE_DISABLE: "1" }, stdout: "pipe", stderr: "ignore" });
|
|
685
|
+
const abort = () => { if (archive.exitCode === null) try { archive.kill("SIGKILL"); } catch { /* Exit promise confirms this owned producer. */ } };
|
|
686
|
+
const timer = setTimeout(abort, 180_000);
|
|
687
|
+
signal.addEventListener("abort", abort, { once: true }); if (signal.aborted) abort();
|
|
688
|
+
try {
|
|
689
|
+
// docker cp cannot write a read-only container filesystem, even where a writable tmpfs is mounted.
|
|
690
|
+
const result = await command(["exec", "-i", "--user", "65532:65532", container, "tar", "--no-same-owner", "-xf", "-", "-C", "/work"], { timeoutMs: 180_000, input: archive.stdout });
|
|
691
|
+
if (result.code !== 0 || await archive.exited !== 0) throw new RemoteTransportError();
|
|
692
|
+
} finally { clearTimeout(timer); signal.removeEventListener("abort", abort); abort(); await archive.exited; }
|
|
693
|
+
} catch { throw new RemoteTransportError(); }
|
|
694
|
+
};
|
|
695
|
+
return {
|
|
696
|
+
command, kind: "remote", image: digest, platform: "linux/unknown (remote docker unknown)", listArgs, list, redact, execArgs, cleanup, copy,
|
|
697
|
+
async probe(command, teardown) {
|
|
698
|
+
let probeRoot: string | undefined;
|
|
699
|
+
try {
|
|
700
|
+
const version = await command(["version", "--format", "{{json .Server}}"]);
|
|
701
|
+
if (version.code !== 0) throw new RemoteTransportError();
|
|
702
|
+
const server = JSON.parse(version.stdout) as { Version?: string; Os?: string; Arch?: string };
|
|
703
|
+
if (server.Os !== "linux" || !["amd64", "arm64"].includes(server.Arch ?? "") || !/^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$/.test(server.Version ?? "")) throw new RemoteTransportError();
|
|
704
|
+
const image = await command(["image", "inspect", remote.image, "--format", '{"digests":{{json .RepoDigests}},"arch":{{json .Architecture}},"os":{{json .Os}}}']);
|
|
705
|
+
const metadata = image.code === 0 ? JSON.parse(image.stdout) as { digests?: unknown; arch?: string; os?: string } : undefined;
|
|
706
|
+
if (metadata?.os !== "linux" || !["amd64", "arm64"].includes(metadata.arch ?? "") || !Array.isArray(metadata.digests) || !metadata.digests.some(value => typeof value === "string" && value.endsWith(`@${digest}`))) throw new RemoteTransportError();
|
|
707
|
+
let architecture: string | undefined;
|
|
708
|
+
const probeId = randomUUID(), name = `agenthub-check-probe-${probeId}`;
|
|
709
|
+
probeRoot = await mkdtemp(join(tmpdir(), "ahub-remote-probe-"));
|
|
710
|
+
const source = join(probeRoot, "src"); await mkdir(source, { mode: 0o755 }); await chmod(source, 0o755);
|
|
711
|
+
await writeFile(join(source, "source-proof"), "AHUB_REMOTE_SOURCE", { mode: 0o644 }); await chmod(join(source, "source-proof"), 0o644);
|
|
712
|
+
let clean = false;
|
|
713
|
+
try {
|
|
714
|
+
const launched = await command(["run", "--detach", "--rm", "--name", name, "--label", `agenthub.project=${options.projectKey}`, "--label", `agenthub.probe=${probeId}`, ...hardening, remote.image, "sleep", "600"]);
|
|
715
|
+
if (launched.code !== 0) throw new RemoteTransportError();
|
|
716
|
+
await copy(command, source, name, new AbortController().signal);
|
|
717
|
+
const result = await command(execArgs(name, ["bun", "-e", 'const {networkInterfaces}=await import("node:os"); if(process.getuid?.()!==65532||Object.keys(networkInterfaces()).some(name=>name!=="lo")||await Bun.file("/work/source-proof").text()!=="AHUB_REMOTE_SOURCE")process.exit(2); await Bun.write("/tmp/ready","ok"); try{await Bun.write("/root-write-proof","no");process.exit(3)}catch{} console.log(JSON.stringify({marker:"AHUB_REMOTE_READY",arch:process.arch,platform:process.platform}));']));
|
|
718
|
+
const observed = result.code === 0 ? JSON.parse(result.stdout) as { marker?: string; arch?: string; platform?: string } : undefined;
|
|
719
|
+
architecture = observed?.arch === "x64" ? "amd64" : observed?.arch;
|
|
720
|
+
if (observed?.marker !== "AHUB_REMOTE_READY" || observed.platform !== "linux" || architecture !== metadata.arch) throw new RemoteTransportError();
|
|
721
|
+
} finally { clean = await cleanup(teardown, name); }
|
|
722
|
+
if (!clean) throw new RemoteTransportError();
|
|
723
|
+
return { available: true, platform: `linux/${architecture} (remote docker ${server.Version!.match(/^\d+\.\d+\.\d+/)![0]})` };
|
|
724
|
+
} catch { return { available: false, reason: "remote runner unavailable" }; }
|
|
725
|
+
finally { if (probeRoot) await rm(probeRoot, { recursive: true, force: true }).catch(() => {}); }
|
|
726
|
+
},
|
|
727
|
+
async isolate() { /* Fixed --network none needs no mutable host network. */ },
|
|
728
|
+
runArgs(receipt, labels) {
|
|
729
|
+
// ponytail: root and prepared dependency readiness remain reserved until 793b; no host path is used.
|
|
730
|
+
return ["run", "--detach", "--rm", "--name", receipt.container, ...labels, ...hardening,
|
|
731
|
+
"--mount", `type=volume,src=agenthub-check-deps-unprepared-${options.projectKey},dst=/work/node_modules,readonly`, "--workdir", "/work", remote.image, "sleep", "600"];
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
/** Same lifecycle, but no production checks capability until 793b proves prepared dependencies. */
|
|
736
|
+
export class RemoteRunner extends ContainerRunner {
|
|
737
|
+
constructor(options: RunnerOptions) {
|
|
738
|
+
super(options, remoteRunnerTransport(options));
|
|
739
|
+
}
|
|
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
|
+
}
|
package/src/hub/conductor.ts
CHANGED
|
@@ -166,6 +166,8 @@ export interface ConductorHooks {
|
|
|
166
166
|
status(): ConductorStatusInput;
|
|
167
167
|
task(id: number): Task | undefined;
|
|
168
168
|
publicView(task: Task): Record<string, unknown>;
|
|
169
|
+
/** #812 AC5: true for a peer holding the configured planner role; it may read any task's public view. */
|
|
170
|
+
isPlanner?(actor: string): boolean;
|
|
169
171
|
/** These callbacks must call Tasks, never Board.update. */
|
|
170
172
|
assign(actor: string, id: number, peer: string, role?: TaskAssignmentRole): Promise<unknown>;
|
|
171
173
|
escalate(actor: string, id: number): Promise<unknown>;
|
|
@@ -202,6 +204,9 @@ export class Conductor {
|
|
|
202
204
|
const task = id === undefined ? undefined : this.hooks.task(id);
|
|
203
205
|
// A task's current owner and reviewer read its public view without the role (#208); nobody else does.
|
|
204
206
|
if (task && task.state !== "backlog" && tool === "hub_task_show" && (task.owner === actor || task.reviewer === actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
207
|
+
// #812 AC5: the configured planner reads any task the conductor can, through the same public view (a PII task stays a stub). A staged backlog row stays hidden from agent task
|
|
208
|
+
// tools (#263: backlog is read through planner tools), and a peer that is also the conductor takes the audited conductor path above this branch. Read only: no hold or assignment.
|
|
209
|
+
if (task && task.state !== "backlog" && tool === "hub_task_show" && this.hooks.isPlanner?.(actor)) return publicConductorTask(task, this.hooks.publicView);
|
|
205
210
|
// Its proposer (the first history entry) redirects it while nobody ever accepted it (#207: a decline or a release puts
|
|
206
211
|
// worked tasks back in proposed); work that waits gets a reserved owner. Never over the person: a console assign or
|
|
207
212
|
// reservation stands until the conductor or the console moves it, also once the hub carried it out (the hub's own
|
package/src/hub/config-apply.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { operationalSettingKey } from "./operational-authority.ts";
|
|
2
|
+
import { remoteRunnerConfig } from "./check-runner.ts";
|
|
1
3
|
import { ACTION_CLASSES } from "./action-class.ts";
|
|
2
4
|
import { actionPolicyConfig } from "./action-policy.ts";
|
|
3
5
|
import { adapterModuleDeclarations } from "../adapters/external-modules.ts";
|
|
@@ -39,7 +41,7 @@ export interface ConfigAuthorizationInput {
|
|
|
39
41
|
/** Captured host filename, not the requested setting or a caller-provided authority. */
|
|
40
42
|
sourceFile?: string;
|
|
41
43
|
tracked: boolean | undefined;
|
|
42
|
-
authority: "ordinary" | "settings" | "terminal";
|
|
44
|
+
authority: "ordinary" | "settings" | "terminal" | "conductor";
|
|
43
45
|
/** The daemon supplies this only for the selected setting or an explicitly confirmed reload key. */
|
|
44
46
|
confirmed?: boolean;
|
|
45
47
|
/** Root proved the destination equals an unchanged, effective startup declaration in this origin. */
|
|
@@ -60,9 +62,14 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
60
62
|
if (input.sourceFile !== "config.local.json") return result(false, true, "action policy requires .agenthub/config.local.json");
|
|
61
63
|
if (input.tracked !== false) return result(false, true, "action policy requires an untracked machine-local source");
|
|
62
64
|
}
|
|
65
|
+
const remote = key === "checks.remote" || key.startsWith("checks.remote.") || key === "checks.runner" && after === "remote"
|
|
66
|
+
|| key === "checks" && !!after && typeof after === "object" && ((after as Record<string, unknown>).runner === "remote" || (after as Record<string, unknown>).remote !== undefined);
|
|
67
|
+
if (remote && after !== undefined && (input.sourceFile !== "config.local.json" || input.tracked !== false)) return result(false, true, "remote checks require untracked config.local.json");
|
|
63
68
|
if (same(before, after)) return result(true, false, "unchanged");
|
|
64
69
|
const supplied = input.guard;
|
|
65
70
|
const guard = supplied && (key === supplied.key || key === `${supplied.store === "routing" ? "routing." : ""}${supplied.path.join(".")}`) ? supplied : undefined;
|
|
71
|
+
if (input.authority === "conductor" && (!guard || input.source !== "config" || guard.store !== "config" || input.structural
|
|
72
|
+
|| !(operationalSettingKey(key) || /^roster\.[a-z][a-z0-9-]{0,31}$/.test(key)))) return result(false, true, "conductor changes require one selected operational registry setting");
|
|
66
73
|
const machine = input.source === "config" && ALL_MACHINE_LOCAL.some(path => key === path || key.startsWith(`${path}.`));
|
|
67
74
|
const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(item => typeof item === "string");
|
|
68
75
|
const subset = (smaller: unknown, larger: unknown) => strings(larger) && (smaller === undefined || strings(smaller) && smaller.every(item => larger.includes(item)));
|
|
@@ -88,7 +95,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
88
95
|
if (input.source === "config" && (key === "pet" || key.startsWith("pet."))) return result(true, false, "console-local presentation");
|
|
89
96
|
if (guard?.risk === "safe" && !machine) return result(true, false, "safe registered setting");
|
|
90
97
|
if (input.acceptedFallback && !machine) {
|
|
91
|
-
const person = input.authority === "terminal" || input.authority === "settings" && !!guard;
|
|
98
|
+
const person = input.authority === "terminal" || (input.authority === "settings" || input.authority === "conductor") && !!guard;
|
|
92
99
|
return result(person && input.confirmed === true, true, "restoring an unchanged effective startup value needs this key to be explicitly confirmed");
|
|
93
100
|
}
|
|
94
101
|
// Every other policy change is conservative: person intent never turns tracked or unknown code into host authority.
|
|
@@ -101,7 +108,7 @@ export function authorizeConfigLeaf(input: ConfigAuthorizationInput): ConfigAuth
|
|
|
101
108
|
return result(true, true, guard ? "confirmed registered setting" : "confirmed terminal reload");
|
|
102
109
|
}
|
|
103
110
|
type TrustLayer = ConfigLayer | { name: "routing.toml" | "routing.local.toml"; raw: Record<string, unknown> };
|
|
104
|
-
type Authority = "ordinary" | "settings" | "terminal";
|
|
111
|
+
type Authority = "ordinary" | "settings" | "terminal" | "conductor";
|
|
105
112
|
type Check = (value: unknown, key: string) => void;
|
|
106
113
|
const fail = (key: string, reason = "invalid value"): never => { throw new Error(`${key}: ${reason}`); };
|
|
107
114
|
const object = (value: unknown, key: string): Record<string, unknown> => {
|
|
@@ -173,14 +180,15 @@ const schema: Record<string, Check> = {
|
|
|
173
180
|
checks: (v, k) => { for (const [key, value] of Object.entries(object(v, k))) {
|
|
174
181
|
if (value === undefined) continue;
|
|
175
182
|
if (key === "timeout_s") timerSeconds(value, `${k}.${key}`);
|
|
176
|
-
else if (key === "runner") words(["container"])(value, `${k}.${key}`);
|
|
183
|
+
else if (key === "runner") words(["container", "remote"])(value, `${k}.${key}`);
|
|
184
|
+
else if (key === "remote") remoteRunnerConfig(value);
|
|
177
185
|
else if (/^[a-z][a-z0-9_]{0,47}$/.test(key)) text(value, `${k}.${key}`); else fail(`${k}.${key}`, "unknown key");
|
|
178
186
|
} },
|
|
179
187
|
snapshots: block({ enabled: bool, keep: number(1, 1_000_000, true) }), research: block({ enabled: bool }), bench: block({ enabled: bool }),
|
|
180
188
|
peers: (v, k) => { map(block({ start_mode: words(["tui", "headless"]) }), START_MODE_PEERS)(v, k); peerStartConfig(v); },
|
|
181
189
|
terminal: (v, k) => { block({ open: strings })(v, k); terminalTemplate(object(v, k).open); },
|
|
182
190
|
limits: block({ sender_per_min: number(0), pair_per_min: number(0), important_per_hour: number(0), repeat_window_s: number(0) }),
|
|
183
|
-
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool }), recovery: block({ auto_resume_after_crash: bool }),
|
|
191
|
+
review: block({ adaptive: bool, min_reviews: number(1, Number.MAX_SAFE_INTEGER, true), pool: v => { reviewerPoolConfig({ pool: v }, DEFAULT_REVIEW_POOL, REVIEW_POOL_KINDS); }, rerotate: bool, same_family: words(["fallback", "never"]) }), recovery: block({ auto_resume_after_crash: bool }),
|
|
184
192
|
capabilities: map(strings), coordination: words(["advisory", "turn-free"]), experiments: block({ stale_notices: words(["drop", "deliver"]) }),
|
|
185
193
|
};
|
|
186
194
|
export const SCHEMA_KEYS: readonly string[] = Object.freeze(Object.keys(schema));
|
|
@@ -206,7 +214,7 @@ export function validateEffectiveConfig(value: unknown, catalog?: ProjectAdapter
|
|
|
206
214
|
delete raw.ignored; delete raw.retired; delete raw.permission_default_sources;
|
|
207
215
|
validateRawConfig(raw, undefined, catalog);
|
|
208
216
|
const profile = resolveProfile(raw.profile, raw.profile_overrides);
|
|
209
|
-
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
217
|
+
for (const key of Object.keys(object(raw.checks, "checks"))) if (!["timeout_s", "runner", "remote", ...CLASSES, ...Object.keys(profile.classes)].includes(key)) fail(`checks.${key}`, "unknown task class");
|
|
210
218
|
for (const adapter of ADAPTERS) adapter.configuration?.validateEffective?.(raw, fail);
|
|
211
219
|
}
|
|
212
220
|
|
|
@@ -231,7 +239,7 @@ export function configApply(key: string, scopes: readonly ConfigIdentityScope[]
|
|
|
231
239
|
if (top !== "routing" && top !== "permission" && key.includes(".")) {
|
|
232
240
|
const special: Record<string, readonly string[]> = {
|
|
233
241
|
task_sweep: ["enabled", "auto_reassign", "no_writes_turns", "interval_s", "unaccepted_min", "idle_min", "review_min", "ready_min", "ladder_min"],
|
|
234
|
-
checks: ["timeout_s", "runner", ...CLASSES], terminal: ["open"],
|
|
242
|
+
checks: ["timeout_s", "runner", "remote", ...CLASSES], terminal: ["open"],
|
|
235
243
|
};
|
|
236
244
|
let check = schema[top];
|
|
237
245
|
for (const [index, part] of key.split(".").slice(1).entries()) {
|
|
@@ -298,6 +306,7 @@ export function assertReloadTrust(cwd: string, layers: readonly TrustLayer[], pr
|
|
|
298
306
|
if (!guard.machine && value !== undefined && same(value, guard.floor)) continue;
|
|
299
307
|
// Empty machine-local declarations choose no program/path and are stripped at startup.
|
|
300
308
|
if (!inherit && guard.machine && (value === undefined || value === null || value === "" || Array.isArray(value) && !value.length)) continue;
|
|
309
|
+
if (authority === "conductor" && (!guard.registered || !(operationalSettingKey(key) || /^roster\.[a-z][a-z0-9-]{0,31}$/.test(key)))) fail(`${layer.name}:${key}`, "conductor changes require an operational registry setting");
|
|
301
310
|
let tracked = configTracked(cwd, layer.name);
|
|
302
311
|
if (!guard.machine && tracked === undefined && !configRepositoryPresent(cwd)) tracked = false;
|
|
303
312
|
if (tracked !== false) fail(`${layer.name}:${key}`, tracked ? "guarded change is tracked" : "guarded source trust is unknown");
|
|
@@ -47,7 +47,7 @@ export interface ConfigEditView {
|
|
|
47
47
|
fields: { instance: readonly string[]; tier3Instance: readonly string[] };
|
|
48
48
|
}
|
|
49
49
|
const REASON_BYTES = 400;
|
|
50
|
-
const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
|
|
50
|
+
const BLOCKS: readonly (readonly string[])[] = [["review", "adaptive"], ["review", "min_reviews"], ["review", "pool"], ["review", "rerotate"], ["review", "same_family"], ["snapshots", "keep"], ["approvals", "timeout_s"], ["context", "gate"]];
|
|
51
51
|
|
|
52
52
|
export const CONFIG_EDIT_OPS = ["config_edit_get", "config_edit_preview", "config_edit_apply"] as const;
|
|
53
53
|
|
package/src/hub/config-tier.ts
CHANGED
|
@@ -19,7 +19,7 @@ export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provid
|
|
|
19
19
|
/** Every NEVER_EDITABLE key except `instances`, which splits by field. */
|
|
20
20
|
export const TIER3_PREFIXES: readonly string[] = NEVER_EDITABLE.filter(key => key !== "instances");
|
|
21
21
|
const TIER2_BLOCKS: Readonly<Record<string, readonly string[]>> = {
|
|
22
|
-
review: ["adaptive", "min_reviews", "pool", "rerotate"],
|
|
22
|
+
review: ["adaptive", "min_reviews", "pool", "rerotate", "same_family"],
|
|
23
23
|
snapshots: ["keep"],
|
|
24
24
|
approvals: ["timeout_s"],
|
|
25
25
|
context: ["gate"],
|