@staix/agent-hub 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -4
  3. package/docs/agent-notes/README.md +6 -0
  4. package/docs/agent-notes/adapters.md +10 -8
  5. package/docs/agent-notes/daemon.md +5 -2
  6. package/docs/agent-notes/local-worker.md +2 -1
  7. package/docs/agent-notes/tasks.md +4 -1
  8. package/docs/agent-notes/tests.md +6 -3
  9. package/docs/dogfood.md +9 -3
  10. package/docs/memory.md +1 -1
  11. package/docs/operations.md +66 -28
  12. package/docs/quickstart.md +9 -5
  13. package/docs/security.md +22 -13
  14. package/docs/smoke.md +3 -3
  15. package/docs/specs/2026-09-19-agent-hub-design.md +16 -5
  16. package/docs/verification/2026-10-25-remote-docker-transport.md +10 -0
  17. package/docs/verification/2026-10-26-agy-1.4.7.md +69 -0
  18. package/docs/verification/2026-10-26-claude-implementer-2.1.310.md +69 -0
  19. package/docs/verification/2026-10-26-headless-claude-shell-context.md +62 -0
  20. package/docs/verification/2026-10-26-jcode-0.99.1.md +49 -0
  21. package/docs/verification/2026-10-26-kimi-232-reviewer-compat.md +53 -0
  22. package/docs/verification/2026-10-26-pi-140-compat.md +41 -0
  23. package/docs/verification/2026-10-26-qwen-0261-acp.md +60 -0
  24. package/docs/verification/2026-10-27-0.19.0.md +86 -0
  25. package/docs/verification/claude-permission-transport-native.py +187 -0
  26. package/docs/verification/claude-shell-wrapper-host.ts +20 -0
  27. package/docs/verification/claude-shell-wrapper-native.py +145 -0
  28. package/docs/verification/pi-140-model-free.ts +57 -0
  29. package/docs/verification/pi-140-native.mjs +21 -0
  30. package/docs/verified.json +14 -14
  31. package/package.json +2 -2
  32. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  33. package/plugins/agent-hub/server.js +273 -68
  34. package/src/adapters/agy/entry.ts +1 -1
  35. package/src/adapters/claude-channel.ts +7 -3
  36. package/src/adapters/claude-implementer.ts +5 -3
  37. package/src/adapters/compat.ts +32 -18
  38. package/src/adapters/conformance/fakes/pi-rpc.ts +1 -0
  39. package/src/adapters/definitions/builtin-metadata.ts +2 -2
  40. package/src/adapters/definitions/native-factories.ts +7 -2
  41. package/src/adapters/definitions/reviewer-variants.ts +2 -2
  42. package/src/adapters/pi.ts +39 -3
  43. package/src/adapters/qwen-dual.ts +25 -0
  44. package/src/adapters/registry-types.ts +6 -2
  45. package/src/adapters/registry.ts +1 -0
  46. package/src/cli/agents.ts +69 -7
  47. package/src/cli/claude-shell-wrapper.ts +54 -0
  48. package/src/cli/main.ts +30 -24
  49. package/src/cli/output.ts +4 -0
  50. package/src/hub/agent-drift.ts +61 -0
  51. package/src/hub/board.ts +14 -2
  52. package/src/hub/check-runner.ts +148 -18
  53. package/src/hub/claude-shell-wrapper.ts +349 -0
  54. package/src/hub/config-edit.ts +2 -2
  55. package/src/hub/config-tier.ts +2 -2
  56. package/src/hub/daemon.ts +197 -35
  57. package/src/hub/host-executable.ts +20 -1
  58. package/src/hub/instances.ts +18 -2
  59. package/src/hub/lifecycle.ts +1 -1
  60. package/src/hub/operational-authority.ts +7 -0
  61. package/src/hub/operational-view.ts +6 -10
  62. package/src/hub/read-only-shell-proof.ts +7 -6
  63. package/src/hub/review-scope.ts +13 -0
  64. package/src/hub/sdlc-metrics.ts +6 -4
  65. package/src/hub/task-shell-command.ts +85 -0
  66. package/src/hub/task-shell-default.ts +78 -0
  67. package/src/hub/task-shell-proof.ts +171 -0
  68. package/src/hub/task-shell-sandbox.ts +61 -0
  69. package/src/hub/tasks.ts +139 -13
  70. package/src/local/deny.ts +91 -11
  71. package/src/local/sandbox.ts +67 -28
  72. package/src/local/shell-environment.ts +10 -0
  73. package/src/ui/index.html +1 -0
@@ -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();
@@ -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) && ["jcode_home", "provider", "model", "provider_profile"].includes(field));
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);
@@ -14,8 +14,8 @@ 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[]>> = {