@staix/agent-hub 0.12.36 → 0.12.38
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 +22 -0
- package/README.md +18 -11
- package/docs/agent-notes/adapters.md +19 -6
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -2
- package/docs/agent-notes/daemon.md +9 -3
- package/docs/agent-notes/tasks.md +8 -4
- package/docs/agent-notes/tests.md +2 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +57 -10
- package/docs/quickstart.md +15 -7
- package/docs/sdlc.md +29 -3
- package/docs/security.md +10 -2
- package/docs/smoke.md +38 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +58 -4
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-agy-p1-t0.md +55 -0
- package/docs/verification/2026-10-19-bus-defer-reset.md +9 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-kimi-agy-reviewer-isolation.md +220 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verification/2026-10-20-0.12.38.md +56 -0
- package/docs/verified.json +27 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5627 -3670
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/agy/definition.ts +61 -0
- package/src/adapters/agy/entry.ts +76 -0
- package/src/adapters/agy/hooks.ts +81 -0
- package/src/adapters/claude-channel.ts +1 -0
- package/src/adapters/claude-print.ts +18 -156
- package/src/adapters/codex-review-isolation.ts +290 -0
- package/src/adapters/definitions/builtin-metadata.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-claude.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-codex.ts +5 -1
- package/src/adapters/definitions/native-cli-entry-jcode.ts +2 -0
- package/src/adapters/definitions/native-cli-entry-kimi.ts +12 -4
- package/src/adapters/definitions/native-cli-entry-local.ts +3 -0
- package/src/adapters/definitions/native-cli-entry-pi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry-qwen.ts +3 -0
- package/src/adapters/definitions/native-cli-entry.ts +2 -0
- package/src/adapters/definitions/native-cli-retained.ts +15 -0
- package/src/adapters/definitions/native-factories.ts +17 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/qwen.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +33 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/kimi-review.ts +161 -0
- package/src/adapters/registry-types.ts +36 -4
- package/src/adapters/registry.ts +28 -7
- package/src/adapters/reviewer-mcp.ts +85 -0
- package/src/adapters/reviewer-print.ts +382 -0
- package/src/cli/agy-mcp.ts +370 -0
- package/src/cli/console-state.ts +6 -0
- package/src/cli/console.ts +2 -0
- package/src/cli/facts-hook.ts +15 -2
- package/src/cli/help.ts +2 -0
- package/src/cli/identity.ts +1 -1
- package/src/cli/init.ts +7 -0
- package/src/cli/launch.ts +3 -1
- package/src/cli/main.ts +20 -0
- package/src/cli/memory.ts +13 -12
- package/src/cli/output.ts +5 -0
- package/src/hub/board.ts +34 -6
- package/src/hub/bus.ts +17 -6
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +7 -1
- package/src/hub/daemon.ts +266 -46
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +5 -0
- package/src/hub/instances.ts +13 -3
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/native-hook-launch.ts +37 -0
- package/src/hub/peer-catalog.ts +2 -3
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +76 -0
- package/src/hub/routing.ts +45 -3
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/tasks.ts +261 -36
- package/src/ui/index.html +6 -1
- package/templates/AGENTS.block.md +1 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
8
|
+
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
9
|
+
import { realPath } from "../hub/project.ts";
|
|
10
|
+
import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
|
|
11
|
+
|
|
12
|
+
const VERSION = "0.146.0", TOOLS = ["hub_review", "hub_task_show"];
|
|
13
|
+
const CAP = 128 * 1024, MAX_SERVERS = 64;
|
|
14
|
+
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
|
15
|
+
/** Conservative translation of the portable core's regex sources; unfamiliar syntax refuses the receipt. */
|
|
16
|
+
export function codexCoreDenyGlobs(names: readonly string[] = DENY_NAMES, segments: readonly string[] = DENY_SEGMENTS): string[] {
|
|
17
|
+
if (names.length > 64 || segments.length > 64) throw new Error("core deny policy bound exceeded");
|
|
18
|
+
const globs: string[] = [];
|
|
19
|
+
for (const source of names) {
|
|
20
|
+
if (typeof source !== "string" || source.length > 256) throw new Error("unverified core deny pattern");
|
|
21
|
+
const anchored = source.startsWith("^");
|
|
22
|
+
let body = anchored ? source.slice(1) : source;
|
|
23
|
+
const branch = "(\\.|$)", branchSuffix = anchored && body.endsWith(branch);
|
|
24
|
+
const end = !branchSuffix && body.endsWith("$");
|
|
25
|
+
if (!anchored && !end) throw new Error("unverified core deny pattern");
|
|
26
|
+
if (branchSuffix) body = body.slice(0, -branch.length);
|
|
27
|
+
else if (end) body = body.slice(0, -1);
|
|
28
|
+
let literal = "";
|
|
29
|
+
for (let i = 0; i < body.length; i++) {
|
|
30
|
+
const char = body[i]!;
|
|
31
|
+
if (char === "\\" && body[i + 1] === ".") { literal += "."; i++; }
|
|
32
|
+
else if (/^[A-Za-z0-9_-]$/.test(char)) literal += char;
|
|
33
|
+
else throw new Error("unverified core deny pattern");
|
|
34
|
+
}
|
|
35
|
+
if (!literal) throw new Error("empty core deny pattern");
|
|
36
|
+
const glob = `/**/${anchored ? "" : "*"}${literal}${end ? "" : "*"}`;
|
|
37
|
+
globs.push(glob, `${glob}/**`);
|
|
38
|
+
}
|
|
39
|
+
for (const segment of segments) {
|
|
40
|
+
if (typeof segment !== "string" || segment.length > 256 || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(segment) || segment.split("/").some(part => part === "." || part === "..")) throw new Error("unverified core deny segment");
|
|
41
|
+
globs.push(`/**/${segment}`, `/**/${segment}/**`);
|
|
42
|
+
}
|
|
43
|
+
return [...new Set(globs)];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const inside = (file: string, root: string) => { const part = relative(root, file); return part === "" || part !== ".." && !part.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && !isAbsolute(part); };
|
|
47
|
+
const object = (value: unknown): Record<string, unknown> => {
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid metadata");
|
|
49
|
+
return value as Record<string, unknown>;
|
|
50
|
+
};
|
|
51
|
+
function toml(value: unknown): string {
|
|
52
|
+
if (typeof value === "string" || typeof value === "boolean" || typeof value === "number") return JSON.stringify(value);
|
|
53
|
+
if (Array.isArray(value)) return `[${value.map(toml).join(",")}]`;
|
|
54
|
+
return `{${Object.entries(object(value)).map(([key, item]) => `${JSON.stringify(key)}=${toml(item)}`).join(",")}}`;
|
|
55
|
+
}
|
|
56
|
+
function metadata(file: string): string {
|
|
57
|
+
try { const link = lstatSync(file), stat = statSync(file); return [link.dev, link.ino, link.mtimeMs, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":"); }
|
|
58
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return "absent"; throw new Error("config metadata unavailable"); }
|
|
59
|
+
}
|
|
60
|
+
function configFiles(cwd: string, env: NodeJS.ProcessEnv): string[] {
|
|
61
|
+
const home = resolve(env.CODEX_HOME ?? join(homedir(), ".codex"));
|
|
62
|
+
const files = [join(home, "config.toml"), join(home, "cloud-config-bundle-cache.json"), "/etc/codex/config.toml", "/etc/codex/requirements.toml", "/etc/codex/managed_config.toml"];
|
|
63
|
+
// Include ancestors even outside the git root: no trust/layout assumption can hide a changed layer.
|
|
64
|
+
for (let dir = cwd; ; dir = dirname(dir)) { files.push(join(dir, ".codex", "config.toml")); if (dirname(dir) === dir) break; }
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
// Bounded host credential roots, not a universal private-home classifier. Never inspect their file contents.
|
|
68
|
+
const HOST_CREDENTIAL_PATHS = [".config", ".ssh", ".aws", ".azure", ".kube", ".docker", ".gnupg", ".codex", ".claude", ".kimi", ".kimi-code", ".gemini", ".git-credentials", ".vault-token", "Library/Keychains", ".local/share/keyrings", ".password-store"] as const;
|
|
69
|
+
const HOST_CREDENTIAL_ENV = ["XDG_CONFIG_HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "KIMI_CODE_HOME", "KIMI_HOME", "GEMINI_CLI_HOME", "GNUPGHOME", "AWS_SHARED_CREDENTIALS_FILE", "AWS_CONFIG_FILE", "AZURE_CONFIG_DIR", "DOCKER_CONFIG", "GOOGLE_APPLICATION_CREDENTIALS"] as const;
|
|
70
|
+
/** A credential root that cannot be resolved. `label` is a path class (a standard name or an env var name), never a path or contents. */
|
|
71
|
+
class CredentialRootError extends Error {
|
|
72
|
+
constructor(readonly label: string, readonly kind: "dangling" | "unreadable" | "unsupported") { super("credential root refused"); }
|
|
73
|
+
}
|
|
74
|
+
interface CredentialRoot { path: string; label: string }
|
|
75
|
+
function hostCredentialRoots(env: NodeJS.ProcessEnv, cwd: string): CredentialRoot[] {
|
|
76
|
+
const home = resolve(cwd, env.HOME || homedir());
|
|
77
|
+
const homes = [...new Set([home, canonicalLabelled(home, "HOME")])];
|
|
78
|
+
const roots: CredentialRoot[] = homes.flatMap(base => HOST_CREDENTIAL_PATHS.map(entry => ({ path: join(base, entry), label: `~/${entry}` })));
|
|
79
|
+
const configured: CredentialRoot[] = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [{ path: env[key]!, label: `$${key}` }] : []);
|
|
80
|
+
if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean).map(path => ({ path, label: "$KUBECONFIG" })));
|
|
81
|
+
if (configured.length > 64) throw new Error("credential root bound exceeded");
|
|
82
|
+
for (const { path: entry, label } of configured) {
|
|
83
|
+
if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new CredentialRootError(label, "unsupported");
|
|
84
|
+
roots.push({ path: entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry), label });
|
|
85
|
+
}
|
|
86
|
+
const seen = new Set<string>();
|
|
87
|
+
return roots.filter(root => !seen.has(root.path) && seen.add(root.path));
|
|
88
|
+
}
|
|
89
|
+
function canonicalLabelled(lexical: string, label: string): string {
|
|
90
|
+
try { return credentialCanonicalPath(lexical); }
|
|
91
|
+
catch (error) { throw new CredentialRootError(label, (error as NodeJS.ErrnoException).code === "ENOENT" ? "dangling" : "unreadable"); }
|
|
92
|
+
}
|
|
93
|
+
/** Resolve absent descendants through the deepest existing ancestor; dangling links refuse admission. */
|
|
94
|
+
function credentialCanonicalPath(lexical: string): string {
|
|
95
|
+
// Same strict ancestor algorithm as policyRealPath; importing path-policy here would cycle through the registry.
|
|
96
|
+
let ancestor = lexical;
|
|
97
|
+
for (;;) {
|
|
98
|
+
try { lstatSync(ancestor); break; }
|
|
99
|
+
catch (error) {
|
|
100
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(ancestor) === ancestor) throw error;
|
|
101
|
+
ancestor = dirname(ancestor);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return join(realPath(ancestor), relative(ancestor, lexical));
|
|
105
|
+
}
|
|
106
|
+
function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly CredentialRoot[], cwd: string): void {
|
|
107
|
+
for (const { path: lexical, label } of roots) {
|
|
108
|
+
// Native profile keys with glob metacharacters cannot represent these literal host locations safely.
|
|
109
|
+
if (/[?*\[\]{}]/.test(lexical)) throw new CredentialRootError(label, "unsupported");
|
|
110
|
+
const targets = new Set([lexical, canonicalLabelled(lexical, label)]);
|
|
111
|
+
for (const target of targets) {
|
|
112
|
+
if (/[?*\[\]{}]/.test(target) || inside(cwd, target)) throw new CredentialRootError(label, "unsupported");
|
|
113
|
+
filesystem[target] = "deny"; filesystem[`${target.replace(/\/$/, "")}/**`] = "deny";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resolveCommand(ctx: ReviewerPrepareContext, cwd: string): { command: string[]; files: string[] } {
|
|
119
|
+
const [name = "codex", ...prefix] = ctx.command ?? [];
|
|
120
|
+
const found = isAbsolute(name) ? name : Bun.which(name);
|
|
121
|
+
if (!found) throw new Error("host executable unavailable");
|
|
122
|
+
let binary = realPath(found);
|
|
123
|
+
// Bind the actual native binary, not the mutable npm launcher/interpreter selection.
|
|
124
|
+
if (basename(binary) === "codex.js") {
|
|
125
|
+
const target = process.platform === "darwin" ? process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
|
|
126
|
+
: process.platform === "linux" ? process.arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl" : undefined;
|
|
127
|
+
if (!target || !["arm64", "x64"].includes(process.arch)) throw new Error("unverified native platform");
|
|
128
|
+
const suffix = `${process.platform}-${process.arch === "x64" ? "x64" : "arm64"}`;
|
|
129
|
+
const manifest = createRequire(binary).resolve(`@openai/codex-${suffix}/package.json`);
|
|
130
|
+
binary = realPath(join(dirname(manifest), "vendor", target, "bin", "codex"));
|
|
131
|
+
}
|
|
132
|
+
const files = [binary];
|
|
133
|
+
if (inside(binary, cwd)) throw new Error("project executable refused");
|
|
134
|
+
for (const [index, arg] of prefix.entries()) {
|
|
135
|
+
// Privileged host argv may include a fixture/launcher script, but never a project-owned one.
|
|
136
|
+
if (isAbsolute(arg) && (index === 0 || /\.(?:js|ts|mjs|cjs|sh)$/.test(arg)) && existsSync(arg) && statSync(arg).isFile()) {
|
|
137
|
+
const file = realPath(arg); if (inside(file, cwd)) throw new Error("project command prefix refused"); files.push(file);
|
|
138
|
+
} else if (/\.(?:js|ts|mjs|cjs|sh)$/.test(arg)) throw new Error("unbound command prefix refused");
|
|
139
|
+
}
|
|
140
|
+
return { command: [binary, ...prefix], files };
|
|
141
|
+
}
|
|
142
|
+
interface Server { name: string; enabled: boolean; type: "stdio" | "http"; transport: Record<string, unknown> }
|
|
143
|
+
function inventory(text: string): Server[] {
|
|
144
|
+
const raw: unknown = JSON.parse(text);
|
|
145
|
+
if (!Array.isArray(raw) || raw.length > MAX_SERVERS) throw new Error("inventory bound exceeded");
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
return raw.map(value => {
|
|
148
|
+
const row = object(value), transport = object(row.transport);
|
|
149
|
+
if (typeof row.name !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(row.name) || seen.has(row.name) || typeof row.enabled !== "boolean") throw new Error("invalid inventory identity");
|
|
150
|
+
seen.add(row.name);
|
|
151
|
+
const type = transport.type;
|
|
152
|
+
if (type !== "stdio" && type !== "http" && type !== "streamable_http") throw new Error("unverified MCP transport");
|
|
153
|
+
return { name: row.name, enabled: row.enabled, type: type === "stdio" ? "stdio" : "http", transport };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/** The configured MCP list omits runtime-synthesized Apps; verify the fixed feature gate separately. */
|
|
157
|
+
function proveDisabledFeatures(text: string): void {
|
|
158
|
+
const rows = text.trim().split(/\r?\n/).map(line => line.trim());
|
|
159
|
+
if (rows.length > 512) throw new Error("feature inventory bound exceeded");
|
|
160
|
+
for (const name of ["apps", "plugins", "hooks"]) {
|
|
161
|
+
const matches = rows.filter(line => line.split(/\s+/)[0] === name);
|
|
162
|
+
if (matches.length !== 1 || !new RegExp(`^${name}\\s+stable\\s+false$`).test(matches[0]!)) throw new Error("effective feature isolation unverified");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function ownServer(text: string): Record<string, unknown> {
|
|
166
|
+
const config = object(JSON.parse(text)), servers = object(config.mcpServers);
|
|
167
|
+
if (Object.keys(servers).length !== 1 || !Object.hasOwn(servers, "agent-hub")) throw new Error("fixed hub MCP required");
|
|
168
|
+
const hub = object(servers["agent-hub"]);
|
|
169
|
+
if (Object.keys(hub).some(key => !["command", "args", "env"].includes(key)) || typeof hub.command !== "string" || !hub.command || hub.args !== undefined && (!Array.isArray(hub.args) || hub.args.some(arg => typeof arg !== "string"))) throw new Error("invalid hub transport");
|
|
170
|
+
const env = hub.env === undefined ? {} : object(hub.env);
|
|
171
|
+
if (Object.values(env).some(value => typeof value !== "string")) throw new Error("invalid hub environment");
|
|
172
|
+
return { command: hub.command, args: hub.args ?? [], env, enabled: true, required: true, enabled_tools: TOOLS };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** All reads are model-free and bounded; stdout/config/env values never become diagnostic text. */
|
|
176
|
+
async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }> {
|
|
177
|
+
let root: string | undefined;
|
|
178
|
+
try {
|
|
179
|
+
const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
|
|
180
|
+
const initialBinaryMetadata = bound.files.map(metadata).join(";");
|
|
181
|
+
// Hash content once per receipt, then fence the identity with dev/ino/size/mtime/ctime metadata.
|
|
182
|
+
const binaryHash = digest(bound.files.map(file => digest(readFileSync(file))).join(";"));
|
|
183
|
+
if (bound.files.map(metadata).join(";") !== initialBinaryMetadata) throw new Error("binary changed while hashing");
|
|
184
|
+
const watched = configFiles(cwd, env), initialConfig = watched.map(metadata).join(";");
|
|
185
|
+
const deadline = performance.now() + 12_000;
|
|
186
|
+
const current = () => {
|
|
187
|
+
if (ctx.signal.aborted || watched.map(metadata).join(";") !== initialConfig) return false;
|
|
188
|
+
return bound.files.map(metadata).join(";") === initialBinaryMetadata;
|
|
189
|
+
};
|
|
190
|
+
const run = (args: readonly string[], capture = true, maxBytes = CAP) => new Promise<string>((resolveOutput, reject) => {
|
|
191
|
+
if (ctx.signal.aborted || performance.now() >= deadline) { reject(new Error("metadata deadline")); return; }
|
|
192
|
+
const [cmd, ...prefix] = bound.command;
|
|
193
|
+
const child = spawn(cmd!, [...prefix, ...args], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
194
|
+
let output = "", bytes = 0, failed = false, settled = false;
|
|
195
|
+
const stop = () => { failed = true; void finish(null); };
|
|
196
|
+
const timer = setTimeout(stop, Math.max(1, Math.min(2000, deadline - performance.now())));
|
|
197
|
+
ctx.signal.addEventListener("abort", stop, { once: true });
|
|
198
|
+
const finish = async (code: number | null) => {
|
|
199
|
+
if (settled) return; settled = true; clearTimeout(timer); ctx.signal.removeEventListener("abort", stop);
|
|
200
|
+
try { await stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 }); }
|
|
201
|
+
catch { reject(new Error("metadata cleanup unconfirmed")); return; }
|
|
202
|
+
if (failed || ctx.signal.aborted || code !== 0) reject(new Error("metadata proof refused")); else resolveOutput(output);
|
|
203
|
+
};
|
|
204
|
+
child.once("spawn", () => { trackGroup(child); if (ctx.signal.aborted) stop(); });
|
|
205
|
+
child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) stop(); else if (capture) output += chunk.toString("utf8"); });
|
|
206
|
+
child.stderr.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) stop(); });
|
|
207
|
+
child.once("error", () => { failed = true; void finish(null); });
|
|
208
|
+
child.once("close", code => { void finish(code); });
|
|
209
|
+
});
|
|
210
|
+
if ((await run(["--version"])).trim() !== `codex-cli ${VERSION}`) throw new Error("native version has no isolation receipt");
|
|
211
|
+
if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
|
|
212
|
+
const hub = ownServer(ctx.mcpConfig);
|
|
213
|
+
// Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
|
|
214
|
+
const policy = ["--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
|
|
215
|
+
proveDisabledFeatures(await run(["features", "list", ...policy]));
|
|
216
|
+
const before = inventory(await run(["mcp", "list", "--json", ...policy]));
|
|
217
|
+
if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
|
|
218
|
+
const servers: Record<string, unknown> = Object.assign(Object.create(null), { "agent-hub": hub });
|
|
219
|
+
for (const server of before) servers[server.name] = server.type === "stdio" ? { command: "/usr/bin/false", enabled: false } : { url: "http://127.0.0.1:1", enabled: false };
|
|
220
|
+
const mcp = ["-c", `mcp_servers=${toml(servers)}`];
|
|
221
|
+
const after = inventory(await run(["mcp", "list", "--json", ...policy, ...mcp]));
|
|
222
|
+
if (after.length !== before.length + 1 || after.some(row => row.name !== "agent-hub" && !before.some(old => old.name === row.name)) || after.filter(row => row.enabled).map(row => row.name).join(",") !== "agent-hub") throw new Error("MCP inventory changed or is not strict");
|
|
223
|
+
const observed = object(JSON.parse(await run(["mcp", "get", "agent-hub", "--json", ...policy, ...mcp])));
|
|
224
|
+
if (observed.name !== "agent-hub" || observed.enabled !== true || !Array.isArray(observed.enabled_tools) || JSON.stringify(observed.enabled_tools) !== JSON.stringify(TOOLS)) throw new Error("hub tool allowlist unverified");
|
|
225
|
+
const observedTransport = object(observed.transport);
|
|
226
|
+
const observedEnv = object(observedTransport.env ?? {}), intendedEnv = object(hub.env);
|
|
227
|
+
if (observedTransport.type !== "stdio" || observedTransport.command !== hub.command || JSON.stringify(observedTransport.args ?? []) !== JSON.stringify(hub.args) || Object.keys(observedEnv).length !== Object.keys(intendedEnv).length || Object.entries(intendedEnv).some(([key, value]) => observedEnv[key] !== value)) throw new Error("hub transport changed");
|
|
228
|
+
// A real native sandbox probes a fresh private fixture: the model never sees these files.
|
|
229
|
+
root = realPath(mkdtempSync(join(tmpdir(), "ahub-codex-review-isolation-")));
|
|
230
|
+
const privateFile = join(root, "private.env"), publicFile = join(root, "public.txt"), writeFile = join(root, "write-proof");
|
|
231
|
+
const coreName = join(root, ".env"), coreStateDir = join(root, ".agenthub", "state"), coreState = join(coreStateDir, "private.txt");
|
|
232
|
+
const fixtureHome = join(root, "host-home"), hostConfig = join(fixtureHome, ".config", "gh", "hosts.yml"), hostKube = join(fixtureHome, ".kube", "config"), hostDocker = join(fixtureHome, ".docker", "config.json"), hostAlias = join(root, "host-alias.yml");
|
|
233
|
+
for (const file of [hostConfig, hostKube, hostDocker]) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, "synthetic host credential fixture"); }
|
|
234
|
+
symlinkSync(hostConfig, hostAlias);
|
|
235
|
+
mkdirSync(coreStateDir, { recursive: true });
|
|
236
|
+
writeFileSync(coreName, "core name fixture"); writeFileSync(coreState, "core state fixture");
|
|
237
|
+
const globRoot = join(root, "glob"), nested = join(globRoot, "nested"), globFile = join(nested, "private.env"), alias = join(root, "alias.env");
|
|
238
|
+
mkdirSync(globRoot); mkdirSync(nested);
|
|
239
|
+
writeFileSync(privateFile, "private fixture"); writeFileSync(publicFile, "public fixture"); writeFileSync(globFile, "glob private fixture");
|
|
240
|
+
symlinkSync(globFile, alias);
|
|
241
|
+
const filesystem: Record<string, string> = { "/": "read", [privateFile]: "deny", [join(globRoot, "**", "*.env")]: "deny" };
|
|
242
|
+
for (const glob of codexCoreDenyGlobs()) filesystem[glob] = "deny";
|
|
243
|
+
denyCredentialRoots(filesystem, [...hostCredentialRoots(env, cwd), ...hostCredentialRoots({ HOME: fixtureHome }, cwd)], cwd);
|
|
244
|
+
for (const entry of ctx.privatePaths) {
|
|
245
|
+
if (typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid private path policy");
|
|
246
|
+
// Native Seatbelt supports *, ?, and closed character classes, not brace alternatives.
|
|
247
|
+
if (/[{}]/.test(entry)) throw new Error("unverified private path pattern");
|
|
248
|
+
const homePath = entry === "~" || entry.startsWith("~/");
|
|
249
|
+
const absolute = homePath ? resolve(homedir(), entry.slice(2)) : isAbsolute(entry) ? entry : resolve(cwd, entry);
|
|
250
|
+
const patterns = new Set([absolute]);
|
|
251
|
+
if (!/[?*\[\]]/.test(absolute) && existsSync(absolute)) patterns.add(realPath(absolute));
|
|
252
|
+
if (!isAbsolute(entry) && !homePath && entry !== "." && entry !== "..") {
|
|
253
|
+
patterns.add(join(cwd, "**", entry));
|
|
254
|
+
// ctx combines PII path patterns and local substring denies: broadening is safer than omission.
|
|
255
|
+
if (!/[?*\[\]]/.test(entry)) patterns.add(join(cwd, "**", `*${entry.replace(/^\.\//, "").replace(/\/$/, "")}*`));
|
|
256
|
+
}
|
|
257
|
+
for (const pattern of patterns) { filesystem[pattern] = "deny"; filesystem[`${pattern.replace(/\/$/, "")}/**`] = "deny"; }
|
|
258
|
+
}
|
|
259
|
+
const profile = { filesystem, network: { enabled: false } }, permissions = ["-c", `permissions=${toml({ "ahub-review": profile })}`, "-c", 'default_permissions="ahub-review"'];
|
|
260
|
+
// ponytail: drain the pinned catalog without retaining it, capped at 4 MiB; replace with quiet config validation when the native CLI provides it.
|
|
261
|
+
await run(["debug", "models", "--bundled", ...policy, ...permissions], false, 4 * 1024 * 1024);
|
|
262
|
+
const script = 'if /bin/cat "$1" >/dev/null 2>&1; then echo AHUB_CODEX_PUBLIC_READ; fi; if /bin/cat "$2" >/dev/null 2>&1; then exit 12; else echo AHUB_CODEX_PRIVATE_DENIED; fi; if (echo no > "$3") 2>/dev/null; then exit 13; else echo AHUB_CODEX_WRITE_DENIED; fi; if /bin/cat "$4" >/dev/null 2>&1; then exit 14; else echo AHUB_CODEX_GLOB_PRIVATE_DENIED; fi; if /bin/cat "$5" >/dev/null 2>&1; then exit 15; else echo AHUB_CODEX_ALIAS_PRIVATE_DENIED; fi; if /bin/cat "$6" >/dev/null 2>&1; then exit 16; else echo AHUB_CODEX_CORE_NAME_DENIED; fi; if /bin/cat "$7" >/dev/null 2>&1; then exit 17; else echo AHUB_CODEX_CORE_STATE_DENIED; fi; if /bin/cat "$8" >/dev/null 2>&1; then exit 18; else echo AHUB_CODEX_HOST_CONFIG_DENIED; fi; if /bin/cat "$9" >/dev/null 2>&1; then exit 19; else echo AHUB_CODEX_HOST_KUBE_DENIED; fi; if /bin/cat "${10}" >/dev/null 2>&1; then exit 20; else echo AHUB_CODEX_HOST_DOCKER_DENIED; fi; if /bin/cat "${11}" >/dev/null 2>&1; then exit 21; else echo AHUB_CODEX_HOST_ALIAS_DENIED; fi';
|
|
263
|
+
const proof = await run(["sandbox", "-P", "ahub-review", "-C", cwd, ...policy, ...permissions, "/bin/sh", "-c", script, "proof", publicFile, privateFile, writeFile, globFile, alias, coreName, coreState, hostConfig, hostKube, hostDocker, hostAlias]);
|
|
264
|
+
if (proof.trim() !== "AHUB_CODEX_PUBLIC_READ\nAHUB_CODEX_PRIVATE_DENIED\nAHUB_CODEX_WRITE_DENIED\nAHUB_CODEX_GLOB_PRIVATE_DENIED\nAHUB_CODEX_ALIAS_PRIVATE_DENIED\nAHUB_CODEX_CORE_NAME_DENIED\nAHUB_CODEX_CORE_STATE_DENIED\nAHUB_CODEX_HOST_CONFIG_DENIED\nAHUB_CODEX_HOST_KUBE_DENIED\nAHUB_CODEX_HOST_DOCKER_DENIED\nAHUB_CODEX_HOST_ALIAS_DENIED" || existsSync(writeFile)) throw new Error("native sandbox isolation unverified");
|
|
265
|
+
if (!current()) throw new Error("binary or config changed during proof");
|
|
266
|
+
const cleanupRoot = root;
|
|
267
|
+
const launchProfile = digest(toml({ profile, servers, policy }));
|
|
268
|
+
return { command: bound.command, args: ["exec", "--ephemeral", "--ignore-user-config", "--ignore-rules", "--json", "--color", "never", "-C", cwd, ...policy, ...permissions, ...mcp, "-c", `developer_instructions=${toml(ctx.systemPrompt)}`, "-"], env,
|
|
269
|
+
current: () => { try { return current(); } catch { return false; } },
|
|
270
|
+
cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
|
|
271
|
+
isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
|
|
272
|
+
evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "apps-disabled-effective-feature-readback", "web-search-disabled", "project-untrusted", "native-public-read-private-deny-write-deny", "native-glob-and-symlink-private-deny", "portable-core-name-and-state-deny", "standard-host-credential-roots-denied", "binary-config-stable", "binary-hash-once-metadata-fence", "session-ephemeral"] } };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
|
|
275
|
+
// Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
|
|
276
|
+
if (error instanceof CredentialRootError) {
|
|
277
|
+
const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
|
|
278
|
+
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
|
|
279
|
+
}
|
|
280
|
+
return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export const codexReviewerBackend: ReviewerBackend = {
|
|
285
|
+
id: "codex", prepare,
|
|
286
|
+
usage(event) {
|
|
287
|
+
const row = event as { type?: string; usage?: { input_tokens?: number; output_tokens?: number; cached_input_tokens?: number } };
|
|
288
|
+
return row?.type === "turn.completed" && row.usage ? { input_tokens: row.usage.input_tokens, output_tokens: row.usage.output_tokens, cache_read_input_tokens: row.usage.cached_input_tokens } : undefined;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { claudeReviewerBackend, CODEX_REVIEWER_VARIANT, KIMI_REVIEWER_VARIANT } from "./reviewer-variants.ts";
|
|
1
2
|
import { kimiAcpCommand } from "./native-daemon-kimi.ts";
|
|
2
3
|
import { claudeRecoveryLaunch, kimiRecoveryLaunch, claudeRetainedTerminalNotice, piRetainedTerminalNotice, codexRetainedTerminalNotice, codexRosterReturnNotice, jcodeStartPreflight, claudeObservedVersion, codexObservedVersion } from "./native-daemon-policy.ts";
|
|
3
4
|
import { piConfigSchema, localConfigSchema, piRoutingSchema, localRoutingSchema, PI_SETTINGS, PI_RESTART_REASONS, LOCAL_RESTART_REASONS, piEffectiveConfig } from "./native-config-policy.ts";
|
|
@@ -65,7 +66,7 @@ const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
|
|
|
65
66
|
identity: { mcp: "env" },
|
|
66
67
|
permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
|
|
67
68
|
session: { idField: "sessionId", currentId: () => undefined, freshOnRestore: true, launchKind: "claude-print", crashPlan: () => ({ resume: {}, how: "claude reviewer: starts again; every review runs in a new session" }) },
|
|
68
|
-
turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true },
|
|
69
|
+
turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true }, reviewer: { backend: claudeReviewerBackend, defaultPool: true, declaration: "automatic" },
|
|
69
70
|
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
70
71
|
};
|
|
71
72
|
|
|
@@ -88,6 +89,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
88
89
|
reviewerVariant: CLAUDE_REVIEWER_VARIANT,
|
|
89
90
|
},
|
|
90
91
|
{
|
|
92
|
+
reviewerVariant: CODEX_REVIEWER_VARIANT,
|
|
91
93
|
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
92
94
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
93
95
|
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
|
|
@@ -98,9 +100,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
98
100
|
grants: { ask: "the session's own approval policy applies again", "ask-when-needed": "approval policy on-request on every turn", "never-ask": "approval policy never on every turn: nothing asks" } },
|
|
99
101
|
session: { nativeRevert: true, idField: "threadId", currentId: sessionId("threadId"), configDirEnv: "CODEX_HOME", zeroTurnFresh: true, launchKind: "codex", launchRecord: true, ...CODEX_RECOVERY_SESSION, crashPlan: codexCrashPlan, unrecordedTerminal: "always" },
|
|
100
102
|
turnEnd: "adapter", budget: { account: "codex", checkpoint: true, reader: { source: "codex rateLimits", read: codexWindows }, context: { source: "codex_token_usage", read: codexContext } },
|
|
101
|
-
cli: { command: "codex", retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
103
|
+
cli: { command: "codex", launchConfigKeys: ["codex_bin"], retainedTerminalNotice: codexRetainedTerminalNotice, rosterReturnNotice: codexRosterReturnNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["codex"] }, display: CODEX_DISPLAY, hooks: { recallPlatform: "codex", recallSource: "codex", recallInjected: true, pluginInventory: codexPluginInventory, classifyTool: codexToolSemantic },
|
|
102
104
|
},
|
|
103
105
|
{
|
|
106
|
+
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
104
107
|
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
105
108
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
106
109
|
start: { create: createKimi, acpCommand: kimiAcpCommand, explicitHeadlessMode: true, planCommand: () => "ahub kimi", daemonPort: "acp", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: ctx => compatibilityLaunch("kimi", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
@@ -113,7 +116,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
113
116
|
unmanagedForms: Object.freeze(["tui"] as const), unmanagedStatusNote: "Kimi approves tools in its own TUI; no hub permission mode", unmanagedNote: "Kimi approves tools and selects its permission mode in its own TUI; no hub permission mode" },
|
|
114
117
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "acp", ...KIMI_RECOVERY_SESSION, crashPlan: kimiCrashPlan },
|
|
115
118
|
turnEnd: "adapter", budget: { account: "kimi", resetSoftTokensOnResume: true, context: { source: "acp_usage_update", read: acpContextValue } }, capabilities: { watch: true }, init: { mcpFile: ".kimi-code/mcp.json" }, hooks: { recallPlatform: "kimi", recallSource: "kimi" },
|
|
116
|
-
cli: { command: "kimi", stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
119
|
+
cli: { command: "kimi", launchConfigKeys: ["kimi_cmd", "peers.kimi.start_mode"], stopNotice: form => form === "tui" ? "Kimi belongs to its own TUI terminal; end it in that terminal" : "", startWords: form => ["kimi", "--mode", form] }, display: KIMI_DISPLAY,
|
|
117
120
|
},
|
|
118
121
|
{
|
|
119
122
|
api: 1, kind: "pi", enabledSwitchStartOnly: true, displayName: "Pi", family: "pi", transport: "hub-native",
|
|
@@ -125,7 +128,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
125
128
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), restoreOnStart: true, launchKind: "pi", launchRecord: true, ...PI_RECOVERY_SESSION, crashPlan: piCrashPlan, unrecordedTerminal: "tui", continuityBookmark: piRecoveryContinuity, continuity: { key: "pi", parse: parsePiContinuity } },
|
|
126
129
|
turnEnd: "adapter", budget: { account: "pi", instrumented: true }, reporting: { tokensNotReported: true }, configuration: { schema: piConfigSchema, routingSchema: piRoutingSchema, settings: PI_SETTINGS, restartReasons: PI_RESTART_REASONS, validateEffective: piEffectiveConfig },
|
|
127
130
|
routing: { localAllowedRefusal: "local_allowed = false also excludes pi for this class", longContext: "backend-limit", preferredTier: true, handoffPriority: 0, trace: "backend", backendLabel: "pi" },
|
|
128
|
-
cli: { command: "pi", previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
131
|
+
cli: { command: "pi", launchConfigKeys: ["pi.cmd", "pi.backend", "pi.dgx_coding", "pi.dgx_fast", "pi.max_steps", "routing.pi", "peers.pi.start_mode"], previewArgs: ["--mode", "tui"], retainedTerminalNotice: piRetainedTerminalNotice, personOnlyFlags: ["--replace-session"], startWords: form => ["pi", "--mode", form] }, display: PI_DISPLAY,
|
|
129
132
|
},
|
|
130
133
|
{
|
|
131
134
|
api: 1, kind: "local", displayName: "Local worker", family: "local", transport: "hub-native",
|
|
@@ -137,7 +140,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
137
140
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), freshOnRestore: true, launchKind: "local", ...LOCAL_RECOVERY_SESSION, crashPlan: localCrashPlan },
|
|
138
141
|
turnEnd: "adapter", budget: { account: "local", quotaExempt: true, instrumented: true }, configuration: { schema: localConfigSchema, routingSchema: localRoutingSchema, restartReasons: LOCAL_RESTART_REASONS },
|
|
139
142
|
routing: { localAllowedRefusal: "local_allowed = false for this class", longContext: "skip-owner", preferredTier: true, handoffPriority: 1, trace: "route" },
|
|
140
|
-
cli: { command: "local", startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
143
|
+
cli: { command: "local", launchConfigKeys: ["local.deny", "local.bash_network", "local.network_allow", "local.max_steps", "local.read_allow", "routing.local"], startWords: () => ["local"] }, display: LOCAL_DISPLAY,
|
|
141
144
|
},
|
|
142
145
|
{
|
|
143
146
|
api: 1, kind: "jcode", displayName: "Jcode", family: "jcode", transport: "acp",
|
|
@@ -148,7 +151,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
148
151
|
ask: "no verified hub mode mapping; native policy only", "ask-when-needed": "no verified hub mode mapping; native policy only", "never-ask": "no verified hub mode mapping; native policy only" } },
|
|
149
152
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), launchKind: "jcode", ...JCODE_RECOVERY_SESSION, crashPlan: jcodeCrashPlan }, turnEnd: "adapter", budget: { account: "jcode" },
|
|
150
153
|
// Jcode has no dedicated CSS swatch today: retain peer-palette.ts's existing 'other' fallback.
|
|
151
|
-
cli: { command: "jcode", authority: "person", startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
154
|
+
cli: { command: "jcode", authority: "person", launchConfigKeys: ["jcode_cmd", "jcode_home", "jcode_provider_profile"], startWords: () => ["jcode"] }, display: JCODE_DISPLAY, hooks: { recallPlatform: "jcode" },
|
|
152
155
|
},
|
|
153
156
|
] satisfies AdapterDefinition[]);
|
|
154
157
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -14,6 +15,8 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
14
15
|
if (args.includes("--print-command") || args.includes("--dry-run")) return console.log(JSON.stringify({ peer, start: "hub", note: "a headless reviewer the hub runs per review" }, null, 2));
|
|
15
16
|
const hub = await ctx.connect();
|
|
16
17
|
try {
|
|
18
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
19
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
17
20
|
const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
18
21
|
if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
|
|
19
22
|
} finally { hub.close(); }
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
3
|
|
|
3
4
|
export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -19,6 +20,8 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
19
20
|
// only when a future recovery receipt supplies independently verified exact-instance execution-root evidence.
|
|
20
21
|
if (recovering && !ctx.instance && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
|
|
21
22
|
const hub = await connect();
|
|
23
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
24
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
22
25
|
const res = await hub.request({ t: "start", peer, args: { unattended: launch0.unattended === true }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
23
26
|
hub.close();
|
|
24
27
|
if (!res.ok) fail(res.error);
|
|
@@ -28,7 +31,8 @@ export async function runCodexEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
28
31
|
}
|
|
29
32
|
const control = readControl(stateDir);
|
|
30
33
|
if ((!recovering || ctx.instance) && control?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: ctx.executionCwd } : undefined);
|
|
31
|
-
const
|
|
34
|
+
const runningCodexBin = res.retainedLaunchConfig?.codex_bin;
|
|
35
|
+
const launch = buildLaunch("codex", args, { unattended: unattendedEnv, instance: peer, proxyUrl: res.proxyUrl, codexBin: typeof runningCodexBin === "string" ? runningCodexBin : projectConfig().codex_bin });
|
|
32
36
|
if (launch0.warning) console.error(launch0.warning);
|
|
33
37
|
exec(launch.cmd, launch.args, "codex");
|
|
34
38
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
4
|
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
@@ -18,6 +19,7 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
18
19
|
const rostered = rosterInStatus(snapshot, "jcode");
|
|
19
20
|
if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
20
21
|
if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
22
|
+
const warning = retainedLaunchWarning(snapshot, "jcode", ctx.definition); if (warning) console.error(warning);
|
|
21
23
|
// The daemon verifies the entry against its own bundle; fall back to this CLI's copy only for a hub that reports none.
|
|
22
24
|
const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
23
25
|
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { resolve } from "node:path";
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
@@ -6,19 +7,22 @@ import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
|
6
7
|
export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
7
8
|
const peer = ctx.instance ?? "kimi";
|
|
8
9
|
const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, connect, fail, takeFlags } = ctx;
|
|
9
|
-
const { launcherPreview, startModeOf, buildKimiTuiLaunch, configureKimiMcp, nativeLaunchEnv, childEnv } = ctx.helpers;
|
|
10
|
+
const { launcherPreview, readControl, startModeOf, buildKimiTuiLaunch, configureKimiMcp, nativeLaunchEnv, childEnv } = ctx.helpers;
|
|
10
11
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
11
12
|
try { return console.log(JSON.stringify(launcherPreview("kimi", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
12
13
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
13
14
|
}
|
|
14
15
|
const { one, rest } = takeFlags(args, ["--mode", "--model"], []);
|
|
15
|
-
const
|
|
16
|
+
const acceptedMode = readControl(stateDir)?.startModes?.[peer];
|
|
17
|
+
const mode = one["--mode"] ?? (ctx.instance ? "headless" : acceptedMode ?? startModeOf(projectConfig().peers, "kimi")), model = one["--model"];
|
|
16
18
|
if (ctx.instance && mode !== "headless") fail("extra Kimi instances require headless mode");
|
|
17
19
|
if (rest.length || !["tui", "headless"].includes(mode)) fail("usage: ahub kimi [--mode tui|headless] [--model <alias>]");
|
|
18
20
|
if (mode === "tui" && one["--mode"] === undefined && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
19
21
|
if (model) fail("Kimi's TUI needs a terminal for these options; run in a terminal or add --mode headless");
|
|
20
22
|
const hub = await connect();
|
|
21
23
|
try {
|
|
24
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
25
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
22
26
|
const reply = await hub.request({ t: "peer_start", peer }, 35_000);
|
|
23
27
|
if (!reply.ok) fail(reply.error);
|
|
24
28
|
return console.log(reply.already ? "kimi is already attached" : `kimi: opened a terminal running ${reply.command}; it attaches pull-only when its TUI is ready`);
|
|
@@ -27,15 +31,17 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
27
31
|
const hub = await connect();
|
|
28
32
|
try {
|
|
29
33
|
if (mode === "tui") {
|
|
30
|
-
const launch = buildKimiTuiLaunch(projectConfig().kimi_cmd, model);
|
|
31
34
|
const snapshot = (await hub.request({ t: "status" })).status;
|
|
32
35
|
const rostered = rosterInStatus(snapshot, peer);
|
|
33
36
|
if (rostered === undefined) fail("cannot read kimi's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
34
37
|
if (!rostered) fail("kimi is off roster; add it with ahub roster add kimi");
|
|
38
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
35
39
|
const owner = snapshot?.peers?.[peer];
|
|
36
40
|
if (owner && !["offline"].includes(owner.state) && !owner.pullOnly) fail("Kimi is running headless; run ahub stop kimi before starting its TUI");
|
|
37
41
|
const admission = await hub.request({ t: "start", peer, args: { mode: "tui" }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
38
42
|
if (!admission.ok) fail(admission.error);
|
|
43
|
+
const runningCommand = admission.retainedLaunchConfig?.kimi_cmd;
|
|
44
|
+
const launch = buildKimiTuiLaunch(Array.isArray(runningCommand) && runningCommand.every((arg: unknown) => typeof arg === "string") ? runningCommand : projectConfig().kimi_cmd, model);
|
|
39
45
|
let result: ReturnType<typeof spawnSync>;
|
|
40
46
|
try {
|
|
41
47
|
// The daemon's own bundle, so the MCP server matches the hub it talks to (#502); this CLI's copy only for an older hub.
|
|
@@ -48,7 +54,9 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
48
54
|
if (result.error) fail(`cannot run ${launch.cmd}: ${result.error.message}`);
|
|
49
55
|
process.exit(result.status ?? 1);
|
|
50
56
|
}
|
|
51
|
-
const
|
|
57
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
58
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
59
|
+
const owner = snapshot?.peers?.[peer];
|
|
52
60
|
if (owner?.pullOnly && owner.state !== "offline") fail("Kimi runs in a terminal; end it there before starting a headless one");
|
|
53
61
|
if (!ctx.instance) configureKimiMcp(cwd, stateDir, resolve(cliDir, "../../plugins/agent-hub/server.js"), "headless");
|
|
54
62
|
const res = await hub.request({ t: "start", peer, args: { mode: "headless", model }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
3
|
|
|
3
4
|
export async function runLocalEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -8,6 +9,8 @@ export async function runLocalEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
8
9
|
if (args.includes("--route") && args.includes("--model")) fail("use --route or --model, not both");
|
|
9
10
|
if (args.some((a, i) => i % 2 === 0 && !["--route", "--model"].includes(a))) fail("usage: ahub local [--route <id> | --model <id>]");
|
|
10
11
|
const hub = await connect();
|
|
12
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
13
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
11
14
|
const res = await hub.request({ t: "start", peer, args: { route: opt("--route"), model: opt("--model") }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
12
15
|
hub.close();
|
|
13
16
|
if (!res.ok) fail(res.error);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import { parsePiFlags } from "./native-cli-pi.ts";
|
|
2
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
4
|
|
|
@@ -12,8 +13,9 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
12
13
|
catch { fail("cannot preview launch: invalid arguments or configuration (details withheld to protect credentials)"); }
|
|
13
14
|
}
|
|
14
15
|
const flags = parsePiFlags(args, fail);
|
|
16
|
+
const acceptedMode = ctx.helpers.readControl(stateDir)?.startModes?.[peer];
|
|
15
17
|
// #269: with no flag, Pi's start mode decides, and that is its TUI unless a person set headless.
|
|
16
|
-
const options = { ...flags, mode: flags.mode ?? (ctx.instance ? "headless" : startModeOf(projectConfig().peers, "pi")) };
|
|
18
|
+
const options = { ...flags, mode: flags.mode ?? (ctx.instance ? "headless" : acceptedMode ?? startModeOf(projectConfig().peers, "pi")) };
|
|
17
19
|
if (ctx.instance && options.mode !== "headless") fail("extra Pi instances require headless mode");
|
|
18
20
|
if (flags.mode === undefined && options.mode === "tui" && !(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
19
21
|
// The default asked for the TUI and there is no terminal here (the console's command line, a script): the hub
|
|
@@ -22,6 +24,8 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
22
24
|
if (Object.keys(flags).length) fail("Pi's TUI needs a terminal, and these options are not passed to one the hub opens: run this in a terminal, or add --mode headless");
|
|
23
25
|
const hub = await connect();
|
|
24
26
|
try {
|
|
27
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
28
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
25
29
|
const reply = await hub.request({ t: "peer_start", peer }, 35_000);
|
|
26
30
|
if (reply.ok !== true) {
|
|
27
31
|
const error = String(reply.error ?? "pi start refused");
|
|
@@ -36,6 +40,8 @@ export async function runPiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
36
40
|
const before = readControl(stateDir);
|
|
37
41
|
if (recovering && before?.instanceId) await recordTerminalLaunch(peer, cwd, stateDir, before.instanceId);
|
|
38
42
|
const hub = await connect();
|
|
43
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
44
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
39
45
|
const res = await hub.request({ t: "start", peer, args: options, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
40
46
|
hub.close();
|
|
41
47
|
if (!res.ok) fail(res.error);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { retainedLaunchWarning } from "./native-cli-retained.ts";
|
|
1
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
2
3
|
|
|
3
4
|
export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -14,6 +15,8 @@ export async function runQwenEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
14
15
|
if (rest.length || (one["--mode"] !== undefined && !(adapter.start.forms as readonly string[]).includes(one["--mode"]))) fail("Qwen TUI is not offered before verification; usage: ahub qwen [--mode headless] [--model <alias>] [--session-id <id>]");
|
|
15
16
|
const hub = await connect();
|
|
16
17
|
try {
|
|
18
|
+
const snapshot = (await hub.request({ t: "status" })).status;
|
|
19
|
+
const warning = retainedLaunchWarning(snapshot, peer, ctx.definition); if (warning) console.error(warning);
|
|
17
20
|
const reply = await hub.request({ t: "start", peer, args: { mode: "headless", ...(one["--model"] ? { model: one["--model"] } : {}), ...(one["--session-id"] ? { sessionId: one["--session-id"] } : {}) }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 35_000);
|
|
18
21
|
if (!reply.ok) fail(reply.error);
|
|
19
22
|
console.log(reply.already ? `${peer} is already attached` : `${peer} attached (headless); use ahub say @${peer} and the console for approvals`);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runAgyEntry } from "../agy/entry.ts";
|
|
1
2
|
import type { BuiltinCliEntry } from "./native-cli-entry-types.ts";
|
|
2
3
|
import { runClaudeEntry } from "./native-cli-entry-claude.ts";
|
|
3
4
|
import { runCodexEntry } from "./native-cli-entry-codex.ts";
|
|
@@ -9,6 +10,7 @@ import { runLocalEntry } from "./native-cli-entry-local.ts";
|
|
|
9
10
|
|
|
10
11
|
// Fixed trusted builtins only. No project configuration, caller payload or external module can register an entry.
|
|
11
12
|
export const builtinCliEntries: readonly BuiltinCliEntry[] = Object.freeze([
|
|
13
|
+
{ command: "agy", lifecycleAdmission: true, execute: runAgyEntry },
|
|
12
14
|
{ command: "claude", lifecycleAdmission: true, execute: runClaudeEntry },
|
|
13
15
|
{ command: "codex", lifecycleAdmission: true, execute: runCodexEntry },
|
|
14
16
|
{ command: "jcode", lifecycleAdmission: true, execute: runJcodeEntry },
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
2
|
+
|
|
3
|
+
/** Explain a retained launch setting using only metadata from the selected adapter definition. */
|
|
4
|
+
export function retainedLaunchWarning(status: unknown, peer: string, definition: AdapterDefinition): string | undefined {
|
|
5
|
+
const keys = definition.cli.launchConfigKeys;
|
|
6
|
+
const retained = (status as { retainedConfig?: unknown } | undefined)?.retainedConfig;
|
|
7
|
+
if (!keys?.length || !Array.isArray(retained)) return;
|
|
8
|
+
const pending = retained.flatMap(row => {
|
|
9
|
+
if (!row || typeof row !== "object") return [];
|
|
10
|
+
const key = (row as { key?: unknown }).key;
|
|
11
|
+
return typeof key === "string" && keys.some(candidate => key === candidate || key.startsWith(`${candidate}.`)) ? [key] : [];
|
|
12
|
+
});
|
|
13
|
+
const unique = [...new Set(pending)];
|
|
14
|
+
return unique.length ? `ahub: config retained: ${unique.join(", ")}; starting ${peer} with the running value; run ahub settings reload to apply the file's value` : undefined;
|
|
15
|
+
}
|