@staix/agent-hub 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +4 -4
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -6
- package/docs/agent-notes/benchmarks.md +1 -0
- package/docs/agent-notes/daemon.md +6 -2
- package/docs/agent-notes/local-worker.md +3 -1
- package/docs/agent-notes/tasks.md +11 -2
- package/docs/agent-notes/tests.md +7 -3
- package/docs/dogfood.md +22 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +162 -35
- package/docs/profiles.md +36 -0
- package/docs/quickstart.md +15 -5
- package/docs/sdlc.md +29 -3
- package/docs/security.md +44 -13
- package/docs/smoke.md +3 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +31 -10
- package/docs/verification/2026-10-25-0.18.0.md +76 -0
- package/docs/verification/2026-10-25-codex-review-live-verdict.md +35 -0
- package/docs/verification/2026-10-25-remote-docker-transport.md +56 -0
- package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
- package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
- package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
- package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
- package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
- package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
- package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
- package/docs/verification/2026-10-27-0.19.0.md +86 -0
- package/docs/verification/claude-permission-transport-native.py +187 -0
- package/docs/verification/claude-shell-wrapper-host.ts +20 -0
- package/docs/verification/claude-shell-wrapper-native.py +145 -0
- package/docs/verification/pi-140-model-free.ts +57 -0
- package/docs/verification/pi-140-native.mjs +21 -0
- package/docs/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 +24 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +364 -98
- package/src/adapters/agy/entry.ts +1 -1
- package/src/adapters/claude-channel.ts +7 -3
- package/src/adapters/claude-implementer.ts +5 -3
- package/src/adapters/compat.ts +32 -18
- package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
- package/src/adapters/definitions/builtin-metadata.ts +3 -3
- package/src/adapters/definitions/native-factories.ts +7 -2
- package/src/adapters/definitions/reviewer-variants.ts +2 -2
- package/src/adapters/local-worker.ts +5 -3
- package/src/adapters/pi.ts +39 -3
- package/src/adapters/qwen-dual.ts +25 -0
- package/src/adapters/registry-types.ts +6 -2
- package/src/adapters/registry.ts +1 -0
- package/src/cli/agents.ts +69 -7
- package/src/cli/claude-shell-wrapper.ts +54 -0
- package/src/cli/console-state.ts +9 -2
- package/src/cli/help.ts +29 -28
- package/src/cli/identity.ts +87 -6
- package/src/cli/main.ts +198 -71
- package/src/cli/output.ts +11 -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/hub/agent-drift.ts +61 -0
- package/src/hub/board.ts +31 -2
- package/src/hub/check-runner.ts +385 -25
- package/src/hub/claude-shell-wrapper.ts +349 -0
- 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-edit.ts +2 -2
- package/src/hub/config-tier.ts +3 -3
- package/src/hub/daemon.ts +530 -87
- 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 +21 -1
- package/src/hub/hub-tools.ts +2 -2
- package/src/hub/instances.ts +18 -2
- package/src/hub/lifecycle.ts +1 -1
- package/src/hub/operational-authority.ts +67 -0
- package/src/hub/operational-view.ts +137 -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 +81 -0
- package/src/hub/read-only-shell.ts +162 -0
- package/src/hub/review-scope.ts +13 -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-metrics.ts +6 -4
- 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/task-shell-command.ts +85 -0
- package/src/hub/task-shell-default.ts +78 -0
- package/src/hub/task-shell-proof.ts +171 -0
- package/src/hub/task-shell-sandbox.ts +61 -0
- package/src/hub/tasks.ts +378 -27
- package/src/local/deny.ts +91 -11
- package/src/local/sandbox.ts +67 -28
- package/src/local/shell-environment.ts +10 -0
- package/src/local/tools.ts +17 -4
- package/src/ui/index.html +3 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { accessSync, chmodSync, constants, lstatSync, mkdtempSync, rmSync, rmdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
+
import { type Socket } from "node:net";
|
|
5
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
8
|
+
import type { Readable, Writable } from "node:stream";
|
|
9
|
+
import { HOST_SHELL_PROTOCOL as WIRE } from "../cli/claude-shell-wrapper.ts";
|
|
10
|
+
import { shellQuote } from "../cli/shell-quote.ts";
|
|
11
|
+
import { readProcessTable, stopOwnedProcess, trackGroup } from "./child-process.ts";
|
|
12
|
+
import { realPath } from "./project.ts";
|
|
13
|
+
import { sbplString } from "../local/deny.ts";
|
|
14
|
+
|
|
15
|
+
export interface ShellGrant {
|
|
16
|
+
peer: string; task: number; generation: string; root: string; branch: string;
|
|
17
|
+
executable: string; argv: readonly string[]; env: Readonly<Record<string, string>>;
|
|
18
|
+
signal: AbortSignal; timeoutMs: number; current(): boolean; activity?: () => void;
|
|
19
|
+
sandbox?: { profile: string; readOnlyData: boolean };
|
|
20
|
+
}
|
|
21
|
+
type Stamp = { path: string; identity: string };
|
|
22
|
+
type Ticket = { source: ShellGrant; grant: Readonly<ShellGrant>; expires: number; root: Stamp; executable: Stamp; abort(): void };
|
|
23
|
+
type Execution = { ticket: Ticket; proc?: ChildProcess; temporary?: Stamp; response: ServerResponse; cancelled: boolean; failedCleanup: boolean; stopping?: Promise<void>; done: Promise<void>; cancel(): Promise<void> };
|
|
24
|
+
const MAX_TICKETS = 64, MAX_PEER = 8, TTL_MS = 30_000;
|
|
25
|
+
const MAX_EXECUTION_PAYLOAD = 256 * 1024;
|
|
26
|
+
// Host-only source: no public CLI mode accepts execution descriptors. The descriptor arrives on fd4,
|
|
27
|
+
// never argv/env, and the exact target env is passed to spawn rather than inherited through a shell.
|
|
28
|
+
const SUPERVISOR_SOURCE = String.raw`
|
|
29
|
+
import { createReadStream, writeSync, closeSync } from "node:fs";
|
|
30
|
+
import { spawn } from "node:child_process";
|
|
31
|
+
import { constants } from "node:os";
|
|
32
|
+
let finished = false;
|
|
33
|
+
const finish = code => {
|
|
34
|
+
if (finished) return;
|
|
35
|
+
finished = true;
|
|
36
|
+
try { writeSync(3, String(code) + "\n"); } catch {}
|
|
37
|
+
for (const fd of [1, 2, 3]) { try { closeSync(fd); } catch {} }
|
|
38
|
+
};
|
|
39
|
+
// The host owns stdin. On owner loss, only this original detached group is terminated.
|
|
40
|
+
process.stdin.on("end", () => { try { process.kill(-process.pid, "SIGKILL"); } catch { process.exit(125); } });
|
|
41
|
+
process.stdin.resume();
|
|
42
|
+
try {
|
|
43
|
+
const pieces = []; let bytes = 0;
|
|
44
|
+
for await (const part of createReadStream("/dev/null", { fd: 4, autoClose: true, highWaterMark: 16384 })) {
|
|
45
|
+
const block = Buffer.isBuffer(part) ? part : Buffer.from(part); bytes += block.length;
|
|
46
|
+
if (bytes > 262144) throw new Error(); pieces.push(block);
|
|
47
|
+
}
|
|
48
|
+
const value = JSON.parse(Buffer.concat(pieces, bytes).toString("utf8"));
|
|
49
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "argv,env,executable,root"
|
|
50
|
+
|| typeof value.root !== "string" || !value.root.startsWith("/") || typeof value.executable !== "string" || !value.executable.startsWith("/")
|
|
51
|
+
|| !Array.isArray(value.argv) || value.argv.some(v => typeof v !== "string" || v.includes("\0"))
|
|
52
|
+
|| !value.env || typeof value.env !== "object" || Array.isArray(value.env)
|
|
53
|
+
|| Object.entries(value.env).some(([key, v]) => !key || key.includes("=") || key.includes("\0") || typeof v !== "string" || v.includes("\0"))) throw new Error();
|
|
54
|
+
const child = spawn(value.executable, value.argv, { cwd: value.root, env: value.env, stdio: ["ignore", 1, 2, "ignore", "ignore"] });
|
|
55
|
+
const status = (code, signal) => {
|
|
56
|
+
const found = code ?? (signal && constants.signals[signal] ? 128 + constants.signals[signal] : 125);
|
|
57
|
+
finish(Number.isInteger(found) && found >= 0 && found <= 255 ? found : 125);
|
|
58
|
+
};
|
|
59
|
+
child.once("error", () => finish(256)); child.once("exit", status);
|
|
60
|
+
if (child.exitCode !== null || child.signalCode !== null) status(child.exitCode, child.signalCode);
|
|
61
|
+
} catch { finish(256); }
|
|
62
|
+
`;
|
|
63
|
+
const fail = () => new Error(WIRE.refusal);
|
|
64
|
+
const beneath = (root: string, file: string) => { const r = relative(root, file); return !r || r !== ".." && !r.startsWith("../") && !isAbsolute(r); };
|
|
65
|
+
function stamp(value: string, directory = false): Stamp {
|
|
66
|
+
const path = realPath(value), s = lstatSync(path);
|
|
67
|
+
if (directory ? !s.isDirectory() : !s.isFile()) throw fail();
|
|
68
|
+
if (!directory) accessSync(path, constants.R_OK);
|
|
69
|
+
return { path, identity: JSON.stringify(directory ? [path, s.dev, s.ino, s.mode] : [path, s.dev, s.ino, s.mode, s.size, s.mtimeMs, s.ctimeMs]) };
|
|
70
|
+
}
|
|
71
|
+
const same = (value: string, prior: Stamp, directory = false) => { try { return stamp(value, directory).identity === prior.identity; } catch { return false; } };
|
|
72
|
+
function removeOwned(directory: Stamp): void {
|
|
73
|
+
try { lstatSync(directory.path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw fail(); }
|
|
74
|
+
if (!same(directory.path, directory, true)) throw fail();
|
|
75
|
+
rmSync(directory.path, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
function snapshot(source: ShellGrant): Readonly<ShellGrant> | undefined {
|
|
78
|
+
try {
|
|
79
|
+
if (!source || !/^[a-z][a-z0-9-]{0,63}$/.test(source.peer) || !Number.isSafeInteger(source.task) || source.task < 1
|
|
80
|
+
|| typeof source.generation !== "string" || !source.generation || source.generation.length > 256 || /[\p{Cc}]/u.test(source.generation)
|
|
81
|
+
|| typeof source.root !== "string" || !isAbsolute(source.root) || typeof source.executable !== "string" || !isAbsolute(source.executable)
|
|
82
|
+
|| typeof source.branch !== "string" || !source.branch || source.branch.length > 256 || /[\p{Cc}]/u.test(source.branch)
|
|
83
|
+
|| !Array.isArray(source.argv) || source.argv.length > 256 || source.argv.some(a => typeof a !== "string" || a.includes("\0") || a.length > 64 * 1024)
|
|
84
|
+
|| !source.env || typeof source.env !== "object" || Array.isArray(source.env) || !(source.signal instanceof AbortSignal) || source.signal.aborted
|
|
85
|
+
|| !Number.isSafeInteger(source.timeoutMs) || source.timeoutMs < 1 || source.timeoutMs > WIRE.maximumMs || typeof source.current !== "function"
|
|
86
|
+
|| source.activity !== undefined && typeof source.activity !== "function") return;
|
|
87
|
+
if (source.sandbox !== undefined && (!source.sandbox || typeof source.sandbox !== "object" || Array.isArray(source.sandbox)
|
|
88
|
+
|| Object.keys(source.sandbox).sort().join(",") !== "profile,readOnlyData" || typeof source.sandbox.profile !== "string" || !source.sandbox.profile
|
|
89
|
+
|| source.sandbox.profile.includes("\0") || Buffer.byteLength(source.sandbox.profile) > 128 * 1024 || typeof source.sandbox.readOnlyData !== "boolean"
|
|
90
|
+
|| source.sandbox.readOnlyData && /\(allow file-write\* \(subpath /.test(source.sandbox.profile))) return;
|
|
91
|
+
const entries = Object.entries(source.env);
|
|
92
|
+
if (entries.length > 256 || entries.some(([key, value]) => !key || key.includes("=") || key.includes("\0") || typeof value !== "string" || value.includes("\0"))
|
|
93
|
+
|| Buffer.byteLength(JSON.stringify([source.argv, entries, source.sandbox])) > 256 * 1024) return;
|
|
94
|
+
return Object.freeze({ ...source, argv: Object.freeze([...source.argv]), env: Object.freeze(Object.fromEntries(entries)),
|
|
95
|
+
...(source.sandbox ? { sandbox: Object.freeze({ ...source.sandbox }) } : {}) });
|
|
96
|
+
} catch { return; }
|
|
97
|
+
}
|
|
98
|
+
function unchanged(ticket: Ticket): boolean {
|
|
99
|
+
const a = ticket.source, b = ticket.grant;
|
|
100
|
+
try {
|
|
101
|
+
return a.peer === b.peer && a.task === b.task && a.generation === b.generation && a.root === b.root && a.branch === b.branch
|
|
102
|
+
&& a.executable === b.executable && a.signal === b.signal && a.timeoutMs === b.timeoutMs && a.current === b.current && a.activity === b.activity
|
|
103
|
+
&& a.argv.length === b.argv.length && a.argv.every((v, i) => v === b.argv[i])
|
|
104
|
+
&& (a.sandbox === undefined ? b.sandbox === undefined : b.sandbox !== undefined && Object.keys(a.sandbox).sort().join(",") === "profile,readOnlyData"
|
|
105
|
+
&& a.sandbox.profile === b.sandbox.profile && a.sandbox.readOnlyData === b.sandbox.readOnlyData)
|
|
106
|
+
&& Object.keys(a.env).length === Object.keys(b.env).length && Object.entries(b.env).every(([k, v]) => a.env[k] === v);
|
|
107
|
+
} catch { return false; }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** An in-memory single-use capability service. Only the host supplies grants; request bodies carry a ticket only. */
|
|
111
|
+
export class HostShellWrapper {
|
|
112
|
+
private server!: Server;
|
|
113
|
+
private readonly tickets = new Map<string, Ticket>();
|
|
114
|
+
private readonly active = new Set<Execution>();
|
|
115
|
+
private readonly connections = new Set<Socket>();
|
|
116
|
+
private closing = false;
|
|
117
|
+
private closed = false;
|
|
118
|
+
private closingWork?: Promise<void>;
|
|
119
|
+
private socketIdentity?: string;
|
|
120
|
+
private constructor(private readonly directory: Stamp, private readonly socket: string, private readonly runtime: Stamp, private readonly entry: Stamp, private readonly envProgram: Stamp, private readonly implementation: Stamp, private readonly sandboxExec?: Stamp) {}
|
|
121
|
+
|
|
122
|
+
static async create(): Promise<HostShellWrapper> {
|
|
123
|
+
// A short system temp path avoids Darwin's Unix-socket length ceiling under a nested test TMPDIR.
|
|
124
|
+
// Native/target TMPDIR never chooses the private capability directory.
|
|
125
|
+
const directory = mkdtempSync(join(realPath("/tmp"), "ahub-shell-")); chmodSync(directory, 0o700);
|
|
126
|
+
let wrapper: HostShellWrapper | undefined;
|
|
127
|
+
try {
|
|
128
|
+
const runtime = stamp(process.execPath), entry = stamp(fileURLToPath(new URL("../cli/claude-shell-wrapper.ts", import.meta.url))), envProgram = stamp("/usr/bin/env"), implementation = stamp(fileURLToPath(import.meta.url));
|
|
129
|
+
for (const program of [runtime, envProgram]) accessSync(program.path, constants.X_OK);
|
|
130
|
+
let sandboxExec: Stamp | undefined;
|
|
131
|
+
if (process.platform === "darwin") { try { sandboxExec = stamp("/usr/bin/sandbox-exec"); accessSync(sandboxExec.path, constants.X_OK); } catch { sandboxExec = undefined; } }
|
|
132
|
+
wrapper = new HostShellWrapper(stamp(directory, true), join(directory, "s"), runtime, entry, envProgram, implementation, sandboxExec);
|
|
133
|
+
const owner = wrapper;
|
|
134
|
+
const server = wrapper.server = createServer({ maxHeaderSize: 2048 }, (request, response) => {
|
|
135
|
+
response.on("error", () => {});
|
|
136
|
+
void owner.handle(request, response).catch(() => owner.refuse(response));
|
|
137
|
+
});
|
|
138
|
+
server.maxConnections = 96; server.maxHeadersCount = 8; server.requestTimeout = 5000; server.headersTimeout = 5000; server.keepAliveTimeout = 1000; server.timeout = 0;
|
|
139
|
+
server.on("connection", socket => { owner.connections.add(socket); socket.once("close", () => owner.connections.delete(socket)); });
|
|
140
|
+
server.on("clientError", (_error, socket) => { socket.destroy(); });
|
|
141
|
+
await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(owner.socket, () => { server.off("error", reject); resolve(); }); });
|
|
142
|
+
server.on("error", () => {});
|
|
143
|
+
chmodSync(wrapper.socket, 0o600);
|
|
144
|
+
const socket = lstatSync(wrapper.socket); wrapper.socketIdentity = JSON.stringify([socket.dev, socket.ino, socket.mode]);
|
|
145
|
+
return wrapper;
|
|
146
|
+
} catch {
|
|
147
|
+
if (wrapper?.server) {
|
|
148
|
+
for (const connection of wrapper.connections) connection.destroy();
|
|
149
|
+
if (wrapper.server.listening) await new Promise<void>(resolve => wrapper!.server.close(() => resolve()));
|
|
150
|
+
}
|
|
151
|
+
try { rmSync(directory, { recursive: true, force: true }); } catch { /* unavailable service, no issued grant */ }
|
|
152
|
+
throw fail();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private stable(root: string): boolean {
|
|
157
|
+
return !this.closing && same(this.directory.path, this.directory, true) && same(this.runtime.path, this.runtime)
|
|
158
|
+
&& same(this.entry.path, this.entry) && same(this.envProgram.path, this.envProgram)
|
|
159
|
+
&& same(this.implementation.path, this.implementation)
|
|
160
|
+
&& ![this.runtime.path, this.entry.path, this.envProgram.path, this.implementation.path].some(file => beneath(root, file));
|
|
161
|
+
}
|
|
162
|
+
private sandboxStable(grant: Readonly<ShellGrant>, root: string): boolean {
|
|
163
|
+
return !grant.sandbox || process.platform === "darwin" && !!this.sandboxExec && same(this.sandboxExec.path, this.sandboxExec) && !beneath(root, this.sandboxExec.path);
|
|
164
|
+
}
|
|
165
|
+
private count(peer?: string): number { return [...this.tickets.values(), ...[...this.active].map(a => a.ticket)].filter(t => peer === undefined || t.grant.peer === peer).length; }
|
|
166
|
+
private forget(token: string, ticket: Ticket): void { this.tickets.delete(token); ticket.grant.signal.removeEventListener("abort", ticket.abort); }
|
|
167
|
+
private prune(): void { for (const [token, ticket] of this.tickets) if (ticket.expires <= Date.now() || ticket.grant.signal.aborted) this.forget(token, ticket); }
|
|
168
|
+
|
|
169
|
+
issue(source: ShellGrant): { command: string } | undefined {
|
|
170
|
+
this.prune(); const grant = snapshot(source); if (!grant) return;
|
|
171
|
+
try {
|
|
172
|
+
const root = stamp(grant.root, true), executable = stamp(grant.executable); accessSync(executable.path, constants.X_OK);
|
|
173
|
+
if (!this.stable(root.path) || !this.sandboxStable(grant, root.path) || this.count() >= MAX_TICKETS || this.count(grant.peer) >= MAX_PEER || grant.current() !== true || grant.signal.aborted || this.closing) return;
|
|
174
|
+
const token = randomBytes(32).toString("hex");
|
|
175
|
+
const ticket: Ticket = { source, grant, root, executable, expires: Date.now() + TTL_MS, abort: () => this.forget(token, ticket) };
|
|
176
|
+
if (!unchanged(ticket) || !this.stable(root.path) || !this.sandboxStable(grant, root.path) || this.count() >= MAX_TICKETS || this.count(grant.peer) >= MAX_PEER) return;
|
|
177
|
+
this.tickets.set(token, ticket); grant.signal.addEventListener("abort", ticket.abort, { once: true });
|
|
178
|
+
if (grant.signal.aborted) { this.forget(token, ticket); return; }
|
|
179
|
+
// env -i alone does not prevent Bun from discovering config/preloads in a persisted native cwd.
|
|
180
|
+
return { command: [this.envProgram.path, "-i", `HOME=${this.directory.path}`, this.runtime.path, "--config=/dev/null", "--no-env-file", `--cwd=${this.directory.path}`, this.entry.path, this.socket, token].map(shellQuote).join(" ") };
|
|
181
|
+
} catch { return; }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private refuse(response: ServerResponse): void {
|
|
185
|
+
if (response.destroyed || response.writableEnded) return;
|
|
186
|
+
if (!response.headersSent) { response.statusCode = 403; response.setHeader("connection", "close"); response.end(`${WIRE.refusal}\n`); }
|
|
187
|
+
else response.destroy();
|
|
188
|
+
}
|
|
189
|
+
private async body(request: IncomingMessage): Promise<unknown> {
|
|
190
|
+
const length = request.headers["content-length"];
|
|
191
|
+
if (typeof length !== "string" || !/^[0-9]+$/.test(length) || Number(length) > 256 || Number(length) < 1 || request.headers["transfer-encoding"] !== undefined) throw fail();
|
|
192
|
+
const chunks: Buffer[] = []; let bytes = 0;
|
|
193
|
+
const timer = setTimeout(() => request.destroy(), 3000);
|
|
194
|
+
try {
|
|
195
|
+
for await (const part of request) { const chunk = Buffer.isBuffer(part) ? part : Buffer.from(part); bytes += chunk.length; if (bytes > 256) throw fail(); chunks.push(chunk); }
|
|
196
|
+
if (bytes !== Number(length)) throw fail();
|
|
197
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
198
|
+
} finally { clearTimeout(timer); }
|
|
199
|
+
}
|
|
200
|
+
private async handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
|
201
|
+
if (this.closing || request.method !== "POST" || request.url !== "/execute" || Object.hasOwn(request.headers, "origin") || request.headers["content-type"] !== "application/json") return this.refuse(response);
|
|
202
|
+
const body = await this.body(request) as { ticket?: unknown };
|
|
203
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.ticket !== "string" || !/^[a-f0-9]{64}$/.test(body.ticket)) return this.refuse(response);
|
|
204
|
+
const ticket = this.tickets.get(body.ticket);
|
|
205
|
+
if (!ticket) return this.refuse(response);
|
|
206
|
+
this.forget(body.ticket, ticket); // Consume before schema, expiry, authority or metadata validation.
|
|
207
|
+
if (Object.keys(body).length !== 1 || ticket.expires <= Date.now()) return this.refuse(response);
|
|
208
|
+
const execution: Execution = { ticket, response, cancelled: false, failedCleanup: false, done: Promise.resolve(), cancel: async () => { execution.cancelled = true; await this.stop(execution); } };
|
|
209
|
+
this.active.add(execution);
|
|
210
|
+
execution.done = this.execute(execution);
|
|
211
|
+
await execution.done;
|
|
212
|
+
}
|
|
213
|
+
private async stop(execution: Execution): Promise<void> {
|
|
214
|
+
if (!execution.proc) return;
|
|
215
|
+
if (execution.stopping) return execution.stopping;
|
|
216
|
+
const work = stopOwnedProcess(execution.proc, { group: true }).then(() => { execution.failedCleanup = false; }, () => { execution.failedCleanup = true; throw fail(); });
|
|
217
|
+
execution.stopping = work;
|
|
218
|
+
try { await work; } finally { execution.stopping = undefined; }
|
|
219
|
+
}
|
|
220
|
+
private async execute(execution: Execution): Promise<void> {
|
|
221
|
+
const { ticket, response } = execution, grant = ticket.grant;
|
|
222
|
+
let timer: ReturnType<typeof setTimeout> | undefined, heartbeat: ReturnType<typeof setInterval> | undefined, temporary: Stamp | undefined;
|
|
223
|
+
const activity = () => { try { grant.activity?.(); } catch { /* A heartbeat grants nothing. */ } };
|
|
224
|
+
const abort = () => { void execution.cancel().catch(() => response.destroy()); };
|
|
225
|
+
const disconnected = () => { if (!response.writableEnded) abort(); };
|
|
226
|
+
response.once("close", disconnected); response.on("error", abort); grant.signal.addEventListener("abort", abort, { once: true });
|
|
227
|
+
try {
|
|
228
|
+
if (!unchanged(ticket) || !this.stable(ticket.root.path) || !this.sandboxStable(grant, ticket.root.path) || !same(grant.root, ticket.root, true) || !same(grant.executable, ticket.executable)) throw fail();
|
|
229
|
+
const temp = mkdtempSync(join(this.directory.path, "r-")); chmodSync(temp, 0o700); temporary = execution.temporary = stamp(temp, true);
|
|
230
|
+
const env = { ...grant.env, TMPDIR: `${temporary.path}/` };
|
|
231
|
+
// Identical own-temp allow suffix and data.readonly broad-write guard to sandboxedExec.
|
|
232
|
+
if (grant.sandbox?.readOnlyData && /\(allow file-write\* \(subpath /.test(grant.sandbox.profile)) throw fail();
|
|
233
|
+
const sandbox = grant.sandbox && `${grant.sandbox.profile}\n(allow file-read* ${grant.sandbox.readOnlyData ? "" : "file-write* "}process-exec (subpath ${sbplString(temporary.path)}))`;
|
|
234
|
+
const target = sandbox ? [this.sandboxExec!.path, "-p", sandbox, ticket.executable.path, ...grant.argv] : [ticket.executable.path, ...grant.argv];
|
|
235
|
+
const payload = JSON.stringify({ root: ticket.root.path, executable: target[0], argv: target.slice(1), env });
|
|
236
|
+
if (Buffer.byteLength(payload) > MAX_EXECUTION_PAYLOAD) throw fail();
|
|
237
|
+
// No await or effectful setup remains between this final host proof and spawn.
|
|
238
|
+
if (!this.sandboxStable(grant, ticket.root.path) || grant.current() !== true || grant.signal.aborted || execution.cancelled || this.closing || response.destroyed) throw fail();
|
|
239
|
+
// Keep our original group leader alive through output drain. stopOwnedProcess refuses to signal a
|
|
240
|
+
// reaped leader's surviving group. The fixed Bun supervisor reports target status on fd3,
|
|
241
|
+
// closes its own output fds and stays alive on the host-owned stdin release pipe.
|
|
242
|
+
const proc = execution.proc = spawn(this.runtime.path, ["--config=/dev/null", "--no-env-file", `--cwd=${this.directory.path}`, "-e", SUPERVISOR_SOURCE], {
|
|
243
|
+
cwd: this.directory.path, env: { HOME: this.directory.path }, detached: true, stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"],
|
|
244
|
+
});
|
|
245
|
+
proc.on("error", () => {}); proc.stdin!.on("error", () => {});
|
|
246
|
+
const control = proc.stdio[4] as Writable;
|
|
247
|
+
control.on("error", abort);
|
|
248
|
+
proc.once("spawn", () => {
|
|
249
|
+
try {
|
|
250
|
+
if (!unchanged(ticket) || !this.stable(ticket.root.path) || !this.sandboxStable(grant, ticket.root.path)
|
|
251
|
+
|| !same(grant.root, ticket.root, true) || !same(grant.executable, ticket.executable)
|
|
252
|
+
|| grant.current() !== true || grant.signal.aborted || execution.cancelled || this.closing || response.destroyed) { abort(); return; }
|
|
253
|
+
control.end(payload);
|
|
254
|
+
} catch { abort(); }
|
|
255
|
+
});
|
|
256
|
+
trackGroup(proc);
|
|
257
|
+
response.writeHead(200, { "content-type": "application/x-ndjson", connection: "close", "cache-control": "no-store" }); response.flushHeaders();
|
|
258
|
+
timer = setTimeout(abort, grant.timeoutMs); heartbeat = setInterval(activity, 250); heartbeat.unref(); activity();
|
|
259
|
+
let queued = Promise.resolve();
|
|
260
|
+
const frame = (value: unknown): Promise<void> => {
|
|
261
|
+
const bytes = `${JSON.stringify(value)}\n`; if (Buffer.byteLength(bytes) > WIRE.frameBytes) return Promise.reject(fail());
|
|
262
|
+
const work = queued.then(() => new Promise<void>((resolve, reject) => {
|
|
263
|
+
if (response.destroyed || response.writableEnded) { reject(fail()); return; }
|
|
264
|
+
response.write(bytes, error => error ? reject(error) : resolve());
|
|
265
|
+
}));
|
|
266
|
+
queued = work.catch(() => {}); return work;
|
|
267
|
+
};
|
|
268
|
+
const pump = async (stream: Readable, type: "stdout" | "stderr") => {
|
|
269
|
+
stream.pause();
|
|
270
|
+
for (;;) {
|
|
271
|
+
const raw = stream.read(Math.min(WIRE.chunkBytes, stream.readableLength || WIRE.chunkBytes));
|
|
272
|
+
if (raw !== null) {
|
|
273
|
+
const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
|
|
274
|
+
if (chunk.length > WIRE.chunkBytes) throw fail();
|
|
275
|
+
activity(); await frame({ type, data: chunk.toString("base64") }); continue;
|
|
276
|
+
}
|
|
277
|
+
if (stream.readableEnded) return;
|
|
278
|
+
if (stream.destroyed) throw fail();
|
|
279
|
+
await new Promise<void>((resolve, reject) => {
|
|
280
|
+
const clean = () => { stream.off("readable", ready); stream.off("end", ready); stream.off("error", broken); stream.off("close", closed); };
|
|
281
|
+
const ready = () => { clean(); resolve(); };
|
|
282
|
+
const broken = () => { clean(); reject(fail()); };
|
|
283
|
+
const closed = () => stream.readableEnded ? ready() : broken();
|
|
284
|
+
stream.once("readable", ready); stream.once("end", ready); stream.once("error", broken); stream.once("close", closed);
|
|
285
|
+
if (stream.readableLength || stream.readableEnded) ready(); else if (stream.destroyed) broken();
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
const status = (async () => {
|
|
290
|
+
let value = "";
|
|
291
|
+
for await (const raw of proc.stdio[3] as Readable) { value += Buffer.from(raw).toString("ascii"); if (value.length > 16) throw fail(); }
|
|
292
|
+
if (!/^[0-9]{1,3}\n$/.test(value) || Number(value) > 255) throw fail();
|
|
293
|
+
// The leader is still ours. A surviving same-group child is an unclean target outcome; reap it
|
|
294
|
+
// now rather than circularly waiting for a pipe that that child still owns. Unknown evidence asks.
|
|
295
|
+
const rows = await readProcessTable();
|
|
296
|
+
if (!rows || !rows.some(row => row.pid === proc.pid && row.pgid === proc.pid)) throw fail();
|
|
297
|
+
if (rows.some(row => row.pgid === proc.pid && row.pid !== proc.pid)) { execution.cancelled = true; await this.stop(execution); throw fail(); }
|
|
298
|
+
return Number(value);
|
|
299
|
+
})();
|
|
300
|
+
const [code] = await Promise.all([status, pump(proc.stdout!, "stdout"), pump(proc.stderr!, "stderr")]);
|
|
301
|
+
await this.stop(execution);
|
|
302
|
+
if (execution.cancelled) throw fail();
|
|
303
|
+
removeOwned(temporary); temporary = undefined; execution.temporary = undefined;
|
|
304
|
+
if (execution.cancelled || grant.signal.aborted || this.closing || response.destroyed) throw fail();
|
|
305
|
+
if (!Number.isInteger(code) || code < 0 || code > 255) throw fail();
|
|
306
|
+
await frame({ type: "exit", code }); response.end();
|
|
307
|
+
} catch {
|
|
308
|
+
execution.cancelled = true;
|
|
309
|
+
try { await this.stop(execution); } catch { execution.failedCleanup = true; }
|
|
310
|
+
this.refuse(response);
|
|
311
|
+
} finally {
|
|
312
|
+
clearTimeout(timer); clearInterval(heartbeat); grant.signal.removeEventListener("abort", abort); response.off("close", disconnected); response.off("error", abort);
|
|
313
|
+
if (!execution.failedCleanup && temporary) { try { removeOwned(temporary); execution.temporary = undefined; } catch { execution.failedCleanup = true; } }
|
|
314
|
+
if (!execution.failedCleanup) this.active.delete(execution);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
close(): Promise<void> {
|
|
319
|
+
if (this.closed) return Promise.resolve();
|
|
320
|
+
if (this.closingWork) return this.closingWork;
|
|
321
|
+
this.closing = true;
|
|
322
|
+
for (const [token, ticket] of this.tickets) this.forget(token, ticket);
|
|
323
|
+
const work = (async () => {
|
|
324
|
+
const entries = [...this.active];
|
|
325
|
+
await Promise.allSettled(entries.map(entry => entry.cancel()));
|
|
326
|
+
await Promise.allSettled(entries.map(entry => entry.done));
|
|
327
|
+
for (const entry of entries) if (!entry.failedCleanup) {
|
|
328
|
+
try { if (entry.temporary) removeOwned(entry.temporary); entry.temporary = undefined; this.active.delete(entry); }
|
|
329
|
+
catch { entry.failedCleanup = true; }
|
|
330
|
+
}
|
|
331
|
+
for (const connection of this.connections) connection.destroy();
|
|
332
|
+
await new Promise<void>(resolve => { if (!this.server.listening) resolve(); else this.server.close(() => resolve()); });
|
|
333
|
+
if ([...this.active].some(entry => entry.failedCleanup)) throw fail();
|
|
334
|
+
if (!same(this.directory.path, this.directory, true)) throw fail();
|
|
335
|
+
try {
|
|
336
|
+
const socket = lstatSync(this.socket);
|
|
337
|
+
if (!socket.isSocket() || JSON.stringify([socket.dev, socket.ino, socket.mode]) !== this.socketIdentity) throw fail();
|
|
338
|
+
unlinkSync(this.socket);
|
|
339
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw fail(); }
|
|
340
|
+
try { rmdirSync(this.directory.path); } catch { throw fail(); }
|
|
341
|
+
this.closed = true;
|
|
342
|
+
})();
|
|
343
|
+
this.closingWork = work;
|
|
344
|
+
void work.finally(() => { if (!this.closed && this.closingWork === work) this.closingWork = undefined; }).catch(() => {});
|
|
345
|
+
return work;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const createHostShellWrapper = (): Promise<HostShellWrapper> => HostShellWrapper.create();
|
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-edit.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { configRepositoryPresent, configTracked } from "./config-trust.ts";
|
|
5
|
-
import { configTier, CONFIG_EDIT_FILES, type ConfigEditFile } from "./config-tier.ts";
|
|
5
|
+
import { configTier, CONFIG_EDIT_FILES, TIER3_INSTANCE_FIELDS, type ConfigEditFile } from "./config-tier.ts";
|
|
6
6
|
import { sdlcConfig } from "./sdlc.ts";
|
|
7
7
|
import { VERSION } from "../version.ts";
|
|
8
8
|
|
|
@@ -202,7 +202,7 @@ export function planConfigEdit(cwd: string, rawRequest: unknown, check: Candidat
|
|
|
202
202
|
if (tier.tier === 3) tier3Keys.push(tier.key);
|
|
203
203
|
// A whole instance replaced by a tier 2 object must not drop a tier 3 field it held: that would silently erase a home or account.
|
|
204
204
|
if (path[0] === "instances" && path.length === 2 && isObject(held) && isObject(value)) {
|
|
205
|
-
const lost = Object.keys(held).find(field => !(field in value) &&
|
|
205
|
+
const lost = Object.keys(held).find(field => !(field in value) && (TIER3_INSTANCE_FIELDS as readonly string[]).includes(field));
|
|
206
206
|
if (lost) return { ok: false, error: `instances.${path[1]}.${lost}: this replacement would drop a native home or account setting; set only the fields you change (instances.${path[1]}.<field>); nothing was written`, field: `instances.${path[1]}.${lost}` };
|
|
207
207
|
}
|
|
208
208
|
putPath(doc, path, value);
|
package/src/hub/config-tier.ts
CHANGED
|
@@ -14,12 +14,12 @@ const PEER = /^[a-z][a-z0-9-]{0,31}$/;
|
|
|
14
14
|
const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
|
|
15
15
|
/** Instance fields that choose no executable and no home. */
|
|
16
16
|
export const TIER2_INSTANCE_FIELDS = ["kind", "roles", "start_mode", "review_mode", "readOnly", "worktree", "permission_mode"] as const;
|
|
17
|
-
/** Instance fields that choose a native home, account or model: tier 3 (#685). */
|
|
18
|
-
export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provider_profile"] as const;
|
|
17
|
+
/** Instance fields that choose a native home, account or model, or widen what a reviewer verdict may skip (`review_scope`, #852): tier 3 (#685). */
|
|
18
|
+
export const TIER3_INSTANCE_FIELDS = ["jcode_home", "provider", "model", "provider_profile", "review_scope"] as const;
|
|
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"],
|