@staix/agent-hub 0.12.35 → 0.12.37
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 +25 -0
- package/README.md +15 -8
- package/docs/agent-notes/adapters.md +9 -2
- package/docs/agent-notes/budget.md +3 -2
- package/docs/agent-notes/bus.md +3 -0
- package/docs/agent-notes/daemon.md +11 -3
- package/docs/agent-notes/tasks.md +14 -1
- package/docs/agent-notes/tests.md +6 -2
- package/docs/memory.md +3 -1
- package/docs/operations.md +74 -18
- package/docs/quickstart.md +7 -9
- package/docs/sdlc.md +72 -5
- package/docs/security.md +11 -3
- package/docs/smoke.md +18 -2
- package/docs/specs/2026-09-19-agent-hub-design.md +108 -3
- package/docs/verification/2026-10-19-0.12.36.md +63 -0
- package/docs/verification/2026-10-19-0.12.37.md +60 -0
- package/docs/verification/2026-10-19-jcode-0.98.1.md +30 -0
- package/docs/verification/2026-10-19-reviewer-isolation.md +119 -0
- package/docs/verified.json +23 -15
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +2843 -1632
- package/src/adapters/acp.ts +3 -2
- package/src/adapters/claude-print.ts +92 -0
- package/src/adapters/codex-review-isolation.ts +276 -0
- package/src/adapters/definitions/builtin-metadata.ts +20 -1
- package/src/adapters/definitions/native-cli-entry-claude.ts +17 -3
- package/src/adapters/definitions/native-cli-entry-jcode.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-kimi.ts +3 -2
- package/src/adapters/definitions/native-cli-entry-types.ts +2 -0
- package/src/adapters/definitions/native-factories.ts +24 -0
- package/src/adapters/definitions/native-policy-aux.ts +1 -1
- package/src/adapters/definitions/reviewer-variants.ts +32 -0
- package/src/adapters/jcode.ts +20 -5
- package/src/adapters/registry-types.ts +13 -3
- package/src/adapters/registry.ts +29 -5
- package/src/adapters/reviewer-print.ts +319 -0
- package/src/cli/help.ts +7 -0
- package/src/cli/identity.ts +2 -1
- package/src/cli/launch.ts +19 -6
- package/src/cli/main.ts +57 -4
- package/src/cli/memory.ts +13 -12
- package/src/cli/reset.ts +4 -2
- package/src/cli/statusline-tee.ts +5 -2
- package/src/cli/terminal-recovery.ts +7 -2
- package/src/cli/upgrade-runtime.ts +18 -15
- package/src/hub/board.ts +61 -11
- package/src/hub/bus.ts +11 -5
- package/src/hub/claude-state.ts +37 -0
- package/src/hub/config-apply.ts +3 -2
- package/src/hub/control-client.ts +1 -1
- package/src/hub/cost-governance.ts +76 -0
- package/src/hub/daemon.ts +269 -87
- package/src/hub/envelope.ts +1 -1
- package/src/hub/host-executable.ts +1 -1
- package/src/hub/hub-tools.ts +25 -1
- package/src/hub/instances.ts +29 -5
- package/src/hub/memory-profile.ts +67 -11
- package/src/hub/planning.ts +2 -0
- package/src/hub/release-admission.ts +64 -0
- package/src/hub/review-brief.ts +175 -0
- package/src/hub/reviewer-pool-config.ts +11 -0
- package/src/hub/reviewer-pool.ts +70 -0
- package/src/hub/routing.ts +58 -4
- package/src/hub/sdlc-release.ts +113 -0
- package/src/hub/sdlc.ts +205 -4
- package/src/hub/status-roster.ts +10 -0
- package/src/hub/task-sweep.ts +143 -0
- package/src/hub/tasks.ts +788 -52
- package/src/hub/usage.ts +57 -20
package/src/adapters/acp.ts
CHANGED
|
@@ -68,7 +68,7 @@ export interface AcpOptions {
|
|
|
68
68
|
/** Production Qwen only; legacy benchmark adapters retain their own native policy. */
|
|
69
69
|
qwen025?: boolean;
|
|
70
70
|
/** A production adapter's source-pinned identity; no table is inferred from it. */
|
|
71
|
-
expectedAgent?: { name: string; version: string };
|
|
71
|
+
expectedAgent?: { name: string; version: string | ((version: unknown) => boolean) };
|
|
72
72
|
/** Vendors with uncertain load semantics must explicitly echo the restored identity. */
|
|
73
73
|
requireLoadSessionEcho?: boolean;
|
|
74
74
|
/** Bound for native mode acknowledgement; defaults to the handshake deadline. */
|
|
@@ -242,7 +242,8 @@ export class AcpPeer extends BasePeer {
|
|
|
242
242
|
// The configured peer id and command are labels, not an agent-specific mode contract.
|
|
243
243
|
// Installed Kimi Code CLI's ACP initialize advertises this exact name.
|
|
244
244
|
this.kimiAgent = init?.agentInfo?.name === "Kimi Code CLI";
|
|
245
|
-
|
|
245
|
+
const expected = this.opts.expectedAgent, version = init?.agentInfo?.version;
|
|
246
|
+
if (expected && (init?.agentInfo?.name !== expected.name || !(typeof expected.version === "function" ? expected.version(version) : version === expected.version))) throw new Error("native ACP identity is not verified for this production adapter");
|
|
246
247
|
this.qwenAgent = qwenNativeIdentity(this.id, this.opts.qwen025, init, this.opts.kind);
|
|
247
248
|
const resume = this.opts.resumeSessionId;
|
|
248
249
|
if (!resume) return this.request("session/new", { cwd: this.opts.cwd, mcpServers: this.opts.mcpServers ?? [] });
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { childEnv } from "../hub/child-process.ts";
|
|
2
|
+
import { hubGitSync } from "../hub/git.ts";
|
|
3
|
+
import { changedPathGroups } from "../hub/review-brief.ts";
|
|
4
|
+
import { type Envelope } from "../hub/envelope.ts";
|
|
5
|
+
import { ReviewerPrintPeer, type ReviewerBackend, type ReviewerPrintOptions } from "./reviewer-print.ts";
|
|
6
|
+
|
|
7
|
+
export interface ClaudePrintUsage { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }
|
|
8
|
+
|
|
9
|
+
export interface ClaudePrintOptions {
|
|
10
|
+
cwd: string;
|
|
11
|
+
/** The native argv prefix; `claude` from PATH by default. */
|
|
12
|
+
command?: readonly string[];
|
|
13
|
+
/** The agent-hub MCP server in tools mode for this instance, as `--mcp-config` JSON. */
|
|
14
|
+
mcpConfig: string;
|
|
15
|
+
systemPrompt: string;
|
|
16
|
+
/** pii.paths and local.deny entries, passed to Claude as Read deny rules. */
|
|
17
|
+
privatePaths?: readonly string[];
|
|
18
|
+
watchdogMs: number;
|
|
19
|
+
/** Facts the hub adds to one review request (a bounded diff of its head); never the builder's narrative. */
|
|
20
|
+
facts?(env: Envelope): string | undefined;
|
|
21
|
+
/** Whether this peer recorded a verdict on the task since `since` (ms). */
|
|
22
|
+
verdictSince(task: number, since: number): boolean;
|
|
23
|
+
onUsage?(usage: ClaudePrintUsage, task: number): void;
|
|
24
|
+
onUnavailable?: ReviewerPrintOptions["onUnavailable"];
|
|
25
|
+
log?(line: string): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The flags that make each review a new, unpersisted session with read-only tools and only the verdict tools. */
|
|
29
|
+
export function claudePrintArgs(sessionId: string, mcpConfig: string, systemPrompt: string, privatePaths: readonly string[] = []): string[] {
|
|
30
|
+
return ["-p", "--output-format", "stream-json", "--verbose", "--no-session-persistence", "--session-id", sessionId,
|
|
31
|
+
"--strict-mcp-config", "--mcp-config", mcpConfig, "--setting-sources", "",
|
|
32
|
+
"--tools", "Read,Grep,Glob", "--allowedTools", "mcp__agent-hub__hub_review", "mcp__agent-hub__hub_task_show", "Read", "Grep", "Glob",
|
|
33
|
+
...(privatePaths.length ? ["--disallowedTools", ...readDenyRules(privatePaths)] : []),
|
|
34
|
+
"--permission-mode", "dontAsk", "--append-system-prompt", systemPrompt];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Claude's own Read deny rules for the project's private paths (pii.paths, local.deny).
|
|
39
|
+
* ponytail: Claude evaluates these, not the hub's path policy (no symlink or case-alias resolution); the hub's own
|
|
40
|
+
* guarantee is that it never puts private content into the prompt. Run reviews of private-path work on-prem.
|
|
41
|
+
*/
|
|
42
|
+
export function readDenyRules(paths: readonly string[]): string[] {
|
|
43
|
+
return paths.flatMap(entry => {
|
|
44
|
+
const rule = entry.startsWith("/") ? `/${entry}` : `./${entry.replace(/^\.\//, "")}`;
|
|
45
|
+
return /[*?]/.test(entry) ? [`Read(${rule})`] : [`Read(${rule})`, `Read(${rule.replace(/\/$/, "")}/**)`];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DIFF_CAP = 100_000;
|
|
50
|
+
/**
|
|
51
|
+
* The review's head against its merge base with the project checkout's HEAD, bounded. Only a hex commit id is used.
|
|
52
|
+
* ponytail: a fixed byte cap; the reviewer reads the rest of the files with Read/Grep, add a per-file summary if reviews
|
|
53
|
+
* of large changes miss things.
|
|
54
|
+
*/
|
|
55
|
+
export function reviewDiff(cwd: string, commit: unknown, policy: { privatePath(path: string): boolean; nameable(text: string): boolean }): string | undefined {
|
|
56
|
+
if (typeof commit !== "string" || !/^[0-9a-f]{7,40}$/i.test(commit)) return undefined;
|
|
57
|
+
const git = (args: string[]) => hubGitSync(["-C", cwd, "diff", "--no-ext-diff", "--no-textconv", "--no-color", "--relative", ...args], { encoding: "utf8", env: childEnv(process.env), maxBuffer: 16 * 1024 * 1024, timeout: 15_000 });
|
|
58
|
+
const unavailable = `Diff of ${commit} unavailable here; read the changed files with Read and Grep.`;
|
|
59
|
+
// Paths first, as groups (both sides of a move or copy): a group is withheld whole when any side is private.
|
|
60
|
+
const groups = changedPathGroups(cwd, commit);
|
|
61
|
+
if (!groups) return unavailable;
|
|
62
|
+
const changed = groups.map(g => g.paths);
|
|
63
|
+
const shownGroups = changed.filter(group => !group.some(path => policy.privatePath(path)));
|
|
64
|
+
const shown = shownGroups.flat();
|
|
65
|
+
const hidden = changed.length - shownGroups.length;
|
|
66
|
+
const note = hidden ? `\n[${hidden} changed file(s) withheld by the project's privacy policy]` : "";
|
|
67
|
+
if (!shown.length) return `Diff of ${commit}: no changed file can be shown.${note}`;
|
|
68
|
+
const r = git(["-M", "--stat", "--patch", `HEAD...${commit}`, "--", ...shown.map(path => `:(literal)${path}`)]);
|
|
69
|
+
if (r.status !== 0 || typeof r.stdout !== "string") return unavailable;
|
|
70
|
+
if (!policy.nameable(r.stdout)) return `Diff of ${commit} withheld: it matches a private-data pattern. Read the changed files with Read and Grep.${note}`;
|
|
71
|
+
const bytes = Buffer.from(r.stdout, "utf8");
|
|
72
|
+
const body = bytes.length > DIFF_CAP ? `${bytes.subarray(0, DIFF_CAP).toString("utf8")}\n[diff cut at ${DIFF_CAP} of ${bytes.length} bytes; read the rest with Read and Grep]` : r.stdout;
|
|
73
|
+
return `Diff of ${commit} against its merge base with the checkout's HEAD:\n${body}${note}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const claudeReviewerBackend: ReviewerBackend = {
|
|
77
|
+
id: "claude",
|
|
78
|
+
prepare: async context => ({ command: context.command ?? ["claude"],
|
|
79
|
+
args: claudePrintArgs(context.sessionId, context.mcpConfig, context.systemPrompt, context.privatePaths),
|
|
80
|
+
isolation: { backend: "claude", version: "existing-223-contract", binary: (context.command ?? ["claude"])[0]!, profile: "strict-mcp-read-grep-glob",
|
|
81
|
+
readOnly: true, strictMcp: true, ephemeral: true, evidence: ["#223 fixed native allowlist, strict MCP config and unpersisted session"] } }),
|
|
82
|
+
usage: event => {
|
|
83
|
+
const row = event as { type?: string; usage?: ClaudePrintUsage } | null;
|
|
84
|
+
return row?.type === "result" ? row.usage : undefined;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Compatibility wrapper for the released Claude transport and its existing tests. */
|
|
89
|
+
export class ClaudePrintPeer extends ReviewerPrintPeer {
|
|
90
|
+
constructor(id: string, options: ClaudePrintOptions) { super(id, { ...options, backend: claudeReviewerBackend }); }
|
|
91
|
+
override recoveryMetadata(): Record<string, unknown> { return { launch: { kind: "claude-print", peer: this.id } }; }
|
|
92
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
function hostCredentialRoots(env: NodeJS.ProcessEnv, cwd: string): string[] {
|
|
71
|
+
const home = resolve(cwd, env.HOME || homedir());
|
|
72
|
+
const homes = [...new Set([home, credentialCanonicalPath(home)])];
|
|
73
|
+
const roots: string[] = homes.flatMap(base => HOST_CREDENTIAL_PATHS.map(entry => join(base, entry)));
|
|
74
|
+
const configured = HOST_CREDENTIAL_ENV.flatMap(key => env[key] ? [env[key]!] : []);
|
|
75
|
+
if (env.KUBECONFIG) configured.push(...env.KUBECONFIG.split(delimiter).filter(Boolean));
|
|
76
|
+
if (configured.length > 64) throw new Error("credential root bound exceeded");
|
|
77
|
+
for (const entry of configured) {
|
|
78
|
+
if (Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid credential root");
|
|
79
|
+
roots.push(entry.startsWith("~/") ? resolve(home, entry.slice(2)) : resolve(cwd, entry));
|
|
80
|
+
}
|
|
81
|
+
return [...new Set(roots)];
|
|
82
|
+
}
|
|
83
|
+
/** Resolve absent descendants through the deepest existing ancestor; dangling links refuse admission. */
|
|
84
|
+
function credentialCanonicalPath(lexical: string): string {
|
|
85
|
+
// Same strict ancestor algorithm as policyRealPath; importing path-policy here would cycle through the registry.
|
|
86
|
+
let ancestor = lexical;
|
|
87
|
+
for (;;) {
|
|
88
|
+
try { lstatSync(ancestor); break; }
|
|
89
|
+
catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT" || dirname(ancestor) === ancestor) throw error;
|
|
91
|
+
ancestor = dirname(ancestor);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return join(realPath(ancestor), relative(ancestor, lexical));
|
|
95
|
+
}
|
|
96
|
+
function denyCredentialRoots(filesystem: Record<string, string>, roots: readonly string[], cwd: string): void {
|
|
97
|
+
for (const lexical of roots) {
|
|
98
|
+
// Native profile keys with glob metacharacters cannot represent these literal host locations safely.
|
|
99
|
+
if (/[?*\[\]{}]/.test(lexical)) throw new Error("unverified credential root syntax");
|
|
100
|
+
const targets = new Set([lexical, credentialCanonicalPath(lexical)]);
|
|
101
|
+
for (const target of targets) {
|
|
102
|
+
if (/[?*\[\]{}]/.test(target)) throw new Error("unverified credential root syntax");
|
|
103
|
+
if (inside(cwd, target)) throw new Error("credential root overlaps checkout");
|
|
104
|
+
filesystem[target] = "deny"; filesystem[`${target.replace(/\/$/, "")}/**`] = "deny";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveCommand(ctx: ReviewerPrepareContext, cwd: string): { command: string[]; files: string[] } {
|
|
110
|
+
const [name = "codex", ...prefix] = ctx.command ?? [];
|
|
111
|
+
const found = isAbsolute(name) ? name : Bun.which(name);
|
|
112
|
+
if (!found) throw new Error("host executable unavailable");
|
|
113
|
+
let binary = realPath(found);
|
|
114
|
+
// Bind the actual native binary, not the mutable npm launcher/interpreter selection.
|
|
115
|
+
if (basename(binary) === "codex.js") {
|
|
116
|
+
const target = process.platform === "darwin" ? process.arch === "arm64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
|
|
117
|
+
: process.platform === "linux" ? process.arch === "arm64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl" : undefined;
|
|
118
|
+
if (!target || !["arm64", "x64"].includes(process.arch)) throw new Error("unverified native platform");
|
|
119
|
+
const suffix = `${process.platform}-${process.arch === "x64" ? "x64" : "arm64"}`;
|
|
120
|
+
const manifest = createRequire(binary).resolve(`@openai/codex-${suffix}/package.json`);
|
|
121
|
+
binary = realPath(join(dirname(manifest), "vendor", target, "bin", "codex"));
|
|
122
|
+
}
|
|
123
|
+
const files = [binary];
|
|
124
|
+
if (inside(binary, cwd)) throw new Error("project executable refused");
|
|
125
|
+
for (const [index, arg] of prefix.entries()) {
|
|
126
|
+
// Privileged host argv may include a fixture/launcher script, but never a project-owned one.
|
|
127
|
+
if (isAbsolute(arg) && (index === 0 || /\.(?:js|ts|mjs|cjs|sh)$/.test(arg)) && existsSync(arg) && statSync(arg).isFile()) {
|
|
128
|
+
const file = realPath(arg); if (inside(file, cwd)) throw new Error("project command prefix refused"); files.push(file);
|
|
129
|
+
} else if (/\.(?:js|ts|mjs|cjs|sh)$/.test(arg)) throw new Error("unbound command prefix refused");
|
|
130
|
+
}
|
|
131
|
+
return { command: [binary, ...prefix], files };
|
|
132
|
+
}
|
|
133
|
+
interface Server { name: string; enabled: boolean; type: "stdio" | "http"; transport: Record<string, unknown> }
|
|
134
|
+
function inventory(text: string): Server[] {
|
|
135
|
+
const raw: unknown = JSON.parse(text);
|
|
136
|
+
if (!Array.isArray(raw) || raw.length > MAX_SERVERS) throw new Error("inventory bound exceeded");
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
return raw.map(value => {
|
|
139
|
+
const row = object(value), transport = object(row.transport);
|
|
140
|
+
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");
|
|
141
|
+
seen.add(row.name);
|
|
142
|
+
const type = transport.type;
|
|
143
|
+
if (type !== "stdio" && type !== "http" && type !== "streamable_http") throw new Error("unverified MCP transport");
|
|
144
|
+
return { name: row.name, enabled: row.enabled, type: type === "stdio" ? "stdio" : "http", transport };
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/** The configured MCP list omits runtime-synthesized Apps; verify the fixed feature gate separately. */
|
|
148
|
+
function proveDisabledFeatures(text: string): void {
|
|
149
|
+
const rows = text.trim().split(/\r?\n/).map(line => line.trim());
|
|
150
|
+
if (rows.length > 512) throw new Error("feature inventory bound exceeded");
|
|
151
|
+
for (const name of ["apps", "plugins", "hooks"]) {
|
|
152
|
+
const matches = rows.filter(line => line.split(/\s+/)[0] === name);
|
|
153
|
+
if (matches.length !== 1 || !new RegExp(`^${name}\\s+stable\\s+false$`).test(matches[0]!)) throw new Error("effective feature isolation unverified");
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function ownServer(text: string): Record<string, unknown> {
|
|
157
|
+
const config = object(JSON.parse(text)), servers = object(config.mcpServers);
|
|
158
|
+
if (Object.keys(servers).length !== 1 || !Object.hasOwn(servers, "agent-hub")) throw new Error("fixed hub MCP required");
|
|
159
|
+
const hub = object(servers["agent-hub"]);
|
|
160
|
+
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");
|
|
161
|
+
const env = hub.env === undefined ? {} : object(hub.env);
|
|
162
|
+
if (Object.values(env).some(value => typeof value !== "string")) throw new Error("invalid hub environment");
|
|
163
|
+
return { command: hub.command, args: hub.args ?? [], env, enabled: true, required: true, enabled_tools: TOOLS };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** All reads are model-free and bounded; stdout/config/env values never become diagnostic text. */
|
|
167
|
+
async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | { unavailable: string }> {
|
|
168
|
+
let root: string | undefined;
|
|
169
|
+
try {
|
|
170
|
+
const cwd = realPath(ctx.cwd), env = peerChildEnv(ctx.peer), bound = resolveCommand(ctx, cwd);
|
|
171
|
+
const initialBinaryMetadata = bound.files.map(metadata).join(";");
|
|
172
|
+
// Hash content once per receipt, then fence the identity with dev/ino/size/mtime/ctime metadata.
|
|
173
|
+
const binaryHash = digest(bound.files.map(file => digest(readFileSync(file))).join(";"));
|
|
174
|
+
if (bound.files.map(metadata).join(";") !== initialBinaryMetadata) throw new Error("binary changed while hashing");
|
|
175
|
+
const watched = configFiles(cwd, env), initialConfig = watched.map(metadata).join(";");
|
|
176
|
+
const deadline = performance.now() + 12_000;
|
|
177
|
+
const current = () => {
|
|
178
|
+
if (ctx.signal.aborted || watched.map(metadata).join(";") !== initialConfig) return false;
|
|
179
|
+
return bound.files.map(metadata).join(";") === initialBinaryMetadata;
|
|
180
|
+
};
|
|
181
|
+
const run = (args: readonly string[], capture = true, maxBytes = CAP) => new Promise<string>((resolveOutput, reject) => {
|
|
182
|
+
if (ctx.signal.aborted || performance.now() >= deadline) { reject(new Error("metadata deadline")); return; }
|
|
183
|
+
const [cmd, ...prefix] = bound.command;
|
|
184
|
+
const child = spawn(cmd!, [...prefix, ...args], { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
185
|
+
let output = "", bytes = 0, failed = false, settled = false;
|
|
186
|
+
const stop = () => { failed = true; void finish(null); };
|
|
187
|
+
const timer = setTimeout(stop, Math.max(1, Math.min(2000, deadline - performance.now())));
|
|
188
|
+
ctx.signal.addEventListener("abort", stop, { once: true });
|
|
189
|
+
const finish = async (code: number | null) => {
|
|
190
|
+
if (settled) return; settled = true; clearTimeout(timer); ctx.signal.removeEventListener("abort", stop);
|
|
191
|
+
try { await stopOwnedProcess(child, { group: true, termMs: 100, killMs: 500 }); }
|
|
192
|
+
catch { reject(new Error("metadata cleanup unconfirmed")); return; }
|
|
193
|
+
if (failed || ctx.signal.aborted || code !== 0) reject(new Error("metadata proof refused")); else resolveOutput(output);
|
|
194
|
+
};
|
|
195
|
+
child.once("spawn", () => { trackGroup(child); if (ctx.signal.aborted) stop(); });
|
|
196
|
+
child.stdout.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) stop(); else if (capture) output += chunk.toString("utf8"); });
|
|
197
|
+
child.stderr.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > maxBytes) stop(); });
|
|
198
|
+
child.once("error", () => { failed = true; void finish(null); });
|
|
199
|
+
child.once("close", code => { void finish(code); });
|
|
200
|
+
});
|
|
201
|
+
if ((await run(["--version"])).trim() !== `codex-cli ${VERSION}`) throw new Error("native version has no isolation receipt");
|
|
202
|
+
if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
|
|
203
|
+
const hub = ownServer(ctx.mcpConfig);
|
|
204
|
+
// Apps creates a runtime MCP server outside the configured inventory; independently pin and read back its gate.
|
|
205
|
+
const policy = ["--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
|
|
206
|
+
proveDisabledFeatures(await run(["features", "list", ...policy]));
|
|
207
|
+
const before = inventory(await run(["mcp", "list", "--json", ...policy]));
|
|
208
|
+
if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
|
|
209
|
+
const servers: Record<string, unknown> = Object.assign(Object.create(null), { "agent-hub": hub });
|
|
210
|
+
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 };
|
|
211
|
+
const mcp = ["-c", `mcp_servers=${toml(servers)}`];
|
|
212
|
+
const after = inventory(await run(["mcp", "list", "--json", ...policy, ...mcp]));
|
|
213
|
+
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");
|
|
214
|
+
const observed = object(JSON.parse(await run(["mcp", "get", "agent-hub", "--json", ...policy, ...mcp])));
|
|
215
|
+
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");
|
|
216
|
+
const observedTransport = object(observed.transport);
|
|
217
|
+
const observedEnv = object(observedTransport.env ?? {}), intendedEnv = object(hub.env);
|
|
218
|
+
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");
|
|
219
|
+
// A real native sandbox probes a fresh private fixture: the model never sees these files.
|
|
220
|
+
root = realPath(mkdtempSync(join(tmpdir(), "ahub-codex-review-isolation-")));
|
|
221
|
+
const privateFile = join(root, "private.env"), publicFile = join(root, "public.txt"), writeFile = join(root, "write-proof");
|
|
222
|
+
const coreName = join(root, ".env"), coreStateDir = join(root, ".agenthub", "state"), coreState = join(coreStateDir, "private.txt");
|
|
223
|
+
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");
|
|
224
|
+
for (const file of [hostConfig, hostKube, hostDocker]) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, "synthetic host credential fixture"); }
|
|
225
|
+
symlinkSync(hostConfig, hostAlias);
|
|
226
|
+
mkdirSync(coreStateDir, { recursive: true });
|
|
227
|
+
writeFileSync(coreName, "core name fixture"); writeFileSync(coreState, "core state fixture");
|
|
228
|
+
const globRoot = join(root, "glob"), nested = join(globRoot, "nested"), globFile = join(nested, "private.env"), alias = join(root, "alias.env");
|
|
229
|
+
mkdirSync(globRoot); mkdirSync(nested);
|
|
230
|
+
writeFileSync(privateFile, "private fixture"); writeFileSync(publicFile, "public fixture"); writeFileSync(globFile, "glob private fixture");
|
|
231
|
+
symlinkSync(globFile, alias);
|
|
232
|
+
const filesystem: Record<string, string> = { "/": "read", [privateFile]: "deny", [join(globRoot, "**", "*.env")]: "deny" };
|
|
233
|
+
for (const glob of codexCoreDenyGlobs()) filesystem[glob] = "deny";
|
|
234
|
+
denyCredentialRoots(filesystem, [...hostCredentialRoots(env, cwd), ...hostCredentialRoots({ HOME: fixtureHome }, cwd)], cwd);
|
|
235
|
+
for (const entry of ctx.privatePaths) {
|
|
236
|
+
if (typeof entry !== "string" || !entry || Buffer.byteLength(entry) > 4096 || /\p{Cc}/u.test(entry)) throw new Error("invalid private path policy");
|
|
237
|
+
// Native Seatbelt supports *, ?, and closed character classes, not brace alternatives.
|
|
238
|
+
if (/[{}]/.test(entry)) throw new Error("unverified private path pattern");
|
|
239
|
+
const homePath = entry === "~" || entry.startsWith("~/");
|
|
240
|
+
const absolute = homePath ? resolve(homedir(), entry.slice(2)) : isAbsolute(entry) ? entry : resolve(cwd, entry);
|
|
241
|
+
const patterns = new Set([absolute]);
|
|
242
|
+
if (!/[?*\[\]]/.test(absolute) && existsSync(absolute)) patterns.add(realPath(absolute));
|
|
243
|
+
if (!isAbsolute(entry) && !homePath && entry !== "." && entry !== "..") {
|
|
244
|
+
patterns.add(join(cwd, "**", entry));
|
|
245
|
+
// ctx combines PII path patterns and local substring denies: broadening is safer than omission.
|
|
246
|
+
if (!/[?*\[\]]/.test(entry)) patterns.add(join(cwd, "**", `*${entry.replace(/^\.\//, "").replace(/\/$/, "")}*`));
|
|
247
|
+
}
|
|
248
|
+
for (const pattern of patterns) { filesystem[pattern] = "deny"; filesystem[`${pattern.replace(/\/$/, "")}/**`] = "deny"; }
|
|
249
|
+
}
|
|
250
|
+
const profile = { filesystem, network: { enabled: false } }, permissions = ["-c", `permissions=${toml({ "ahub-review": profile })}`, "-c", 'default_permissions="ahub-review"'];
|
|
251
|
+
// ponytail: drain the pinned catalog without retaining it, capped at 4 MiB; replace with quiet config validation when the native CLI provides it.
|
|
252
|
+
await run(["debug", "models", "--bundled", ...policy, ...permissions], false, 4 * 1024 * 1024);
|
|
253
|
+
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';
|
|
254
|
+
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]);
|
|
255
|
+
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");
|
|
256
|
+
if (!current()) throw new Error("binary or config changed during proof");
|
|
257
|
+
const cleanupRoot = root;
|
|
258
|
+
const launchProfile = digest(toml({ profile, servers, policy }));
|
|
259
|
+
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,
|
|
260
|
+
current: () => { try { return current(); } catch { return false; } },
|
|
261
|
+
cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
|
|
262
|
+
isolation: { backend: "codex", version: VERSION, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
|
|
263
|
+
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"] } };
|
|
264
|
+
} catch {
|
|
265
|
+
if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
|
|
266
|
+
return { unavailable: "Codex reviewer isolation receipt unavailable (version, MCP inventory, sandbox, configuration or process proof)" };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const codexReviewerBackend: ReviewerBackend = {
|
|
271
|
+
id: "codex", prepare,
|
|
272
|
+
usage(event) {
|
|
273
|
+
const row = event as { type?: string; usage?: { input_tokens?: number; output_tokens?: number; cached_input_tokens?: number } };
|
|
274
|
+
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;
|
|
275
|
+
},
|
|
276
|
+
};
|
|
@@ -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";
|
|
@@ -6,7 +7,7 @@ import { piRecoveryContinuity } from "./native-recovery-continuity.ts";
|
|
|
6
7
|
import { piConductorStartArgs } from "./native-cli-pi.ts";
|
|
7
8
|
import { claudeNativeLaunch, CLAUDE_LAUNCH_POLICY } from "./native-cli-claude-launch.ts";
|
|
8
9
|
import { codexNativeLaunch, CODEX_LAUNCH_POLICY } from "./native-cli-codex-launch.ts";
|
|
9
|
-
import { createCodex, createKimi, createJcode, createPi, createLocal } from "./native-factories.ts";
|
|
10
|
+
import { createCodex, createKimi, createJcode, createPi, createLocal, createClaudeReviewer } from "./native-factories.ts";
|
|
10
11
|
import { CLAUDE_DISPLAY, CODEX_DISPLAY, KIMI_DISPLAY, PI_DISPLAY, LOCAL_DISPLAY, JCODE_DISPLAY } from "./native-display.ts";
|
|
11
12
|
import { CLAUDE_RECOVERY_SESSION, CODEX_RECOVERY_SESSION, KIMI_RECOVERY_SESSION, PI_RECOVERY_SESSION, LOCAL_RECOVERY_SESSION, JCODE_RECOVERY_SESSION } from "./native-recovery.ts";
|
|
12
13
|
import { claudeWindows, codexWindows } from "./native-quota.ts";
|
|
@@ -54,6 +55,21 @@ const hubGrants = {
|
|
|
54
55
|
"never-ask": "every tool runs without asking, inside the hub sandbox and path guard",
|
|
55
56
|
};
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* #223: a declared `claude-*` instance with `start_mode: "headless"` and roles exactly ["reviewer"]. The hub runs one
|
|
60
|
+
* fresh `claude -p` session per review request (src/adapters/claude-print.ts); it never owns a terminal or a session.
|
|
61
|
+
*/
|
|
62
|
+
const CLAUDE_REVIEWER_VARIANT: AdapterDefinition = {
|
|
63
|
+
api: 1, kind: "claude", displayName: "Claude Code reviewer", family: "claude", transport: "hub-native",
|
|
64
|
+
roles: Object.freeze(["reviewer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
65
|
+
start: { create: createClaudeReviewer, daemonPort: "claude-print", forms: headlessForms, defaultForm: "headless", ownsTerminal: headless, launch: ctx => compatibilityLaunch("claude", ctx), conductorStartable: true, hubSwitches: [], ownedFlags: [] },
|
|
66
|
+
identity: { mcp: "env" },
|
|
67
|
+
permission: { modes: {}, unmanaged: "each review runs read-only (Read, Grep, Glob) and may call only hub_review and hub_task_show" },
|
|
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" }) },
|
|
69
|
+
turnEnd: "adapter", budget: { account: "claude" }, routing: { reviewOnly: true }, reviewer: { backend: claudeReviewerBackend, defaultPool: true, declaration: "automatic" },
|
|
70
|
+
cli: { command: "claude", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY,
|
|
71
|
+
};
|
|
72
|
+
|
|
57
73
|
// Sources: peer-catalog.ts roles, roster.ts defaults, start-mode.ts forms, permission-mode.ts grants,
|
|
58
74
|
// daemon.ts runtime mappings/stop policy, budget.ts exemptions, and ui/peer-palette.ts CSS foregrounds.
|
|
59
75
|
export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition[] = Object.freeze([
|
|
@@ -70,8 +86,10 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
70
86
|
session: { idField: "sessionId", currentId: sessionId("sessionId"), configDirEnv: "CLAUDE_CONFIG_DIR", zeroTurnFresh: true, launchKind: "claude-channel", launchRecord: true, ...CLAUDE_RECOVERY_SESSION, crashPlan: claudeCrashPlan, unrecordedTerminal: "always" },
|
|
71
87
|
turnEnd: "native-hook", budget: { account: "claude", checkpoint: true, reader: { source: "claude status line", read: claudeWindows }, context: { source: "claude_statusline", read: claudeContext } },
|
|
72
88
|
cli: { command: "claude", retainedTerminalNotice: claudeRetainedTerminalNotice, stopNotice: () => "this peer belongs to a native terminal; end it in its terminal", startWords: () => ["claude"] }, display: CLAUDE_DISPLAY, hooks: { recallPlatform: "claude", recallSource: "claude", recallInjected: true, pluginInventory: claudePluginInventory },
|
|
89
|
+
reviewerVariant: CLAUDE_REVIEWER_VARIANT,
|
|
73
90
|
},
|
|
74
91
|
{
|
|
92
|
+
reviewerVariant: CODEX_REVIEWER_VARIANT,
|
|
75
93
|
api: 1, kind: "codex", displayName: "Codex", family: "codex", transport: "codex-appserver",
|
|
76
94
|
roles: Object.freeze(["implementer"]), defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
77
95
|
start: { create: createCodex, daemonPort: "codex-appserver", forms: dualForms, defaultForm: "tui", ownsTerminal: terminal, launch: codexNativeLaunch, version: codexObservedVersion, ...CODEX_LAUNCH_POLICY, conductorStartable: true,
|
|
@@ -85,6 +103,7 @@ export const NATIVE_BUILTIN_DEFINITIONS_WITHOUT_QWEN: readonly AdapterDefinition
|
|
|
85
103
|
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 },
|
|
86
104
|
},
|
|
87
105
|
{
|
|
106
|
+
reviewerVariant: KIMI_REVIEWER_VARIANT,
|
|
88
107
|
api: 1, kind: "kimi", displayName: "Kimi Code", family: "kimi", transport: "acp",
|
|
89
108
|
roles: implementerRoles, defaultOnRoster: true, trust: { onPrem: false, hubSandboxed: false },
|
|
90
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: [] },
|
|
@@ -3,9 +3,22 @@ import { join } from "node:path";
|
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
4
|
import type { ClaudeLaunchRecord } from "../../cli/launch.ts";
|
|
5
5
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
6
|
+
import { claudeStateFile } from "../../hub/claude-state.ts";
|
|
6
7
|
|
|
7
8
|
export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
8
9
|
const { args, cwd, stateDir, cliDir, unattendedEnv, projectConfig, fail } = ctx;
|
|
10
|
+
// #450 I3: a claude-<name> instance keeps its own identity, launch and recovery records; it never owns a worktree.
|
|
11
|
+
const peer = ctx.instance ?? "claude";
|
|
12
|
+
if (ctx.hubStarted) {
|
|
13
|
+
// #223: the hub starts a headless reviewer and runs one `claude -p` per review; nothing runs in this terminal.
|
|
14
|
+
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
|
+
const hub = await ctx.connect();
|
|
16
|
+
try {
|
|
17
|
+
const res = await hub.request({ t: "start", peer, operationId: process.env.AGENTHUB_RECOVERY_OPERATION });
|
|
18
|
+
if (res.ok !== true) fail(String(res.error ?? `${peer} was not started`));
|
|
19
|
+
} finally { hub.close(); }
|
|
20
|
+
return console.log(`${peer} started: a headless reviewer the hub runs per review (stop it with ahub stop ${peer})`);
|
|
21
|
+
}
|
|
9
22
|
const { launcherPreview, readControl, recordTerminalLaunch, claudeObservationHooks, buildLaunch, runClaudeLaunch, processSignature, recordClaudeLaunch, cleanupStaleClaudeSettings, nativeLaunchEnv, childEnv, cleanupClaudeSettings } = ctx.helpers;
|
|
10
23
|
if (args.includes("--print-command") || args.includes("--dry-run")) {
|
|
11
24
|
try { return console.log(JSON.stringify(launcherPreview("claude", args, cwd, stateDir, unattendedEnv), null, 2)); }
|
|
@@ -14,7 +27,7 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
14
27
|
const control = readControl(stateDir);
|
|
15
28
|
if (control?.instanceId) {
|
|
16
29
|
process.env.AGENTHUB_INSTANCE_ID = control.instanceId;
|
|
17
|
-
const terminal = await recordTerminalLaunch(
|
|
30
|
+
const terminal = await recordTerminalLaunch(peer, cwd, stateDir, control.instanceId, ctx.instance ? { executionCwd: cwd } : undefined);
|
|
18
31
|
if (!terminal) process.env.AGENTHUB_LAUNCH_ID = randomUUID();
|
|
19
32
|
}
|
|
20
33
|
// `--settings` outranks project and user settings, so the tee has to wrap whichever status line would have won:
|
|
@@ -30,19 +43,20 @@ export async function runClaudeEntry(ctx: NativeCliEntryContext): Promise<void>
|
|
|
30
43
|
// Turn-free facts and opted-in task sweeps share native PreToolUse/PostToolUse/Stop observations.
|
|
31
44
|
const facts = claudeObservationHooks(projectConfig(), { script: join(cliDir, "facts-hook.ts"), stateDir });
|
|
32
45
|
let previousLaunch: unknown;
|
|
33
|
-
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, "claude-launch
|
|
46
|
+
try { previousLaunch = JSON.parse(readFileSync(join(stateDir, claudeStateFile("claude-launch", peer)), "utf8")); } catch { /* no previous managed launch */ }
|
|
34
47
|
const launch = buildLaunch("claude", args, { unattended: unattendedEnv, statusLine: { script: join(cliDir, "statusline-tee.ts"), stateDir, ...(original ? { original } : {}) }, ...(facts ? { facts } : {}) });
|
|
35
48
|
let result: Awaited<ReturnType<typeof runClaudeLaunch>>;
|
|
36
49
|
try {
|
|
37
50
|
let launchRecord: ClaudeLaunchRecord | undefined;
|
|
38
51
|
if (control?.instanceId) {
|
|
39
52
|
// Publish only the final launch's installed hook and effective permission flags.
|
|
40
|
-
launchRecord = { instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
53
|
+
launchRecord = { ...(ctx.instance ? { peer } : {}), instanceId: control.instanceId, launchId: process.env.AGENTHUB_LAUNCH_ID!, permissionHook: launch.permissionHook === true, hookPurpose: launch.hookPurpose, settingsFile: launch.settingsFile, launcherPid: process.pid, launcherSignature: processSignature(process.pid), unattended: launch.unattended === true };
|
|
41
54
|
recordClaudeLaunch(stateDir, launchRecord);
|
|
42
55
|
cleanupStaleClaudeSettings(stateDir, previousLaunch);
|
|
43
56
|
}
|
|
44
57
|
if (launch.warning) console.error(launch.warning);
|
|
45
58
|
const env = nativeLaunchEnv("claude", childEnv());
|
|
59
|
+
if (ctx.instance) env.AGENTHUB_PEER_ID = peer; // the channel plugin and the statusline tee write for this instance
|
|
46
60
|
result = await runClaudeLaunch(launch, { cwd, stateDir, env: { ...env, AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd }, ...(launchRecord ? { record: launchRecord } : {}) });
|
|
47
61
|
} finally {
|
|
48
62
|
cleanupClaudeSettings(stateDir, launch.settingsFile);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
3
|
-
import { rosterInStatus } from "../../hub/status-roster.ts";
|
|
3
|
+
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
4
4
|
|
|
5
5
|
export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
6
6
|
const { args, cwd, stateDir, cliDir, unattendedEnv, connect, fail, takeFlags } = ctx;
|
|
@@ -18,7 +18,8 @@ export async function runJcodeEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
18
18
|
const rostered = rosterInStatus(snapshot, "jcode");
|
|
19
19
|
if (rostered === undefined) fail("cannot read jcode's roster membership from the running hub; upgrade or restart it, then retry (nothing was started)");
|
|
20
20
|
if (!rostered) fail("jcode is off roster; add it with ahub roster add jcode");
|
|
21
|
-
|
|
21
|
+
// The daemon verifies the entry against its own bundle; fall back to this CLI's copy only for a hub that reports none.
|
|
22
|
+
const written = configureJcodeMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"));
|
|
22
23
|
if (written.tracked) console.error(".jcode/mcp.json is tracked; review this local machine-path change before committing it");
|
|
23
24
|
const reply = await hub.request({ t: "start", peer: "jcode", args: { mode: "headless", model: one["--model"], providerProfile: one["--provider-profile"], sessionId: one["--session-id"] }, operationId: process.env.AGENTHUB_RECOVERY_OPERATION }, 65_000);
|
|
24
25
|
if (!reply.ok) fail(reply.error);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import type { NativeCliEntryContext } from "./native-cli-entry-types.ts";
|
|
4
|
-
import { rosterInStatus } from "../../hub/status-roster.ts";
|
|
4
|
+
import { bundleInStatus, rosterInStatus } from "../../hub/status-roster.ts";
|
|
5
5
|
|
|
6
6
|
export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
7
7
|
const peer = ctx.instance ?? "kimi";
|
|
@@ -38,7 +38,8 @@ export async function runKimiEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
|
38
38
|
if (!admission.ok) fail(admission.error);
|
|
39
39
|
let result: ReturnType<typeof spawnSync>;
|
|
40
40
|
try {
|
|
41
|
-
|
|
41
|
+
// 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.
|
|
42
|
+
const written = configureKimiMcp(cwd, stateDir, bundleInStatus(snapshot) ?? resolve(cliDir, "../../plugins/agent-hub/server.js"), "tui");
|
|
42
43
|
if (written.tracked) console.error(".kimi-code/mcp.json is tracked; the agent-hub entry contains machine paths. Review this local change before committing it.");
|
|
43
44
|
result = spawnSync(launch.cmd, launch.args, { cwd, stdio: "inherit", env: { ...nativeLaunchEnv("kimi", childEnv()), AGENTHUB_STATE_DIR: stateDir, AGENTHUB_PROJECT_DIR: cwd } });
|
|
44
45
|
} finally {
|
|
@@ -5,6 +5,8 @@ import type { AdapterDefinition } from "../registry-types.ts";
|
|
|
5
5
|
/** Private static builtin relocation ports. The CLI binds identity, project and authority before invocation. */
|
|
6
6
|
export interface NativeCliEntryContext {
|
|
7
7
|
instance?: string; executionCwd?: string;
|
|
8
|
+
/** #223: the selected instance is started by the hub (a headless reviewer), never as a terminal launch. */
|
|
9
|
+
hubStarted?: true;
|
|
8
10
|
args: string[]; cwd: string; stateDir: string; cliDir: string; unattendedEnv: boolean; definition: AdapterDefinition;
|
|
9
11
|
projectConfig(): HubConfig;
|
|
10
12
|
connect(): Promise<ControlClient>;
|
|
@@ -38,6 +38,28 @@ export function createLocal(ctx: CreateContext): PeerAdapter {
|
|
|
38
38
|
return new LocalPeer(ctx.instance, ctx.daemon.local);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
export function createClaudeReviewer(ctx: CreateContext): PeerAdapter {
|
|
42
|
+
if ("reviewerPrint" in ctx.daemon) {
|
|
43
|
+
const { ClaudePrintPeer } = require("../claude-print.ts") as typeof import("../claude-print.ts");
|
|
44
|
+
return new ClaudePrintPeer(ctx.instance, ctx.daemon.reviewerPrint);
|
|
45
|
+
}
|
|
46
|
+
if (!("claudePrint" in ctx.daemon)) throw new Error("Claude reviewer requires its print transport options");
|
|
47
|
+
const { ClaudePrintPeer } = require("../claude-print.ts") as typeof import("../claude-print.ts");
|
|
48
|
+
return new ClaudePrintPeer(ctx.instance, ctx.daemon.claudePrint);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createCodexReviewer(ctx: CreateContext): PeerAdapter {
|
|
52
|
+
if (!("reviewerPrint" in ctx.daemon)) throw new Error("Codex reviewer requires its fresh print transport options");
|
|
53
|
+
const config = ctx.kindConfig as { codex_bin?: string };
|
|
54
|
+
return createReviewer({ ...ctx, daemon: { reviewerPrint: { ...ctx.daemon.reviewerPrint, command: ctx.daemon.reviewerPrint.command ?? [config.codex_bin ?? "codex"] } } });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createReviewer(ctx: CreateContext): PeerAdapter {
|
|
58
|
+
if (!("reviewerPrint" in ctx.daemon)) throw new Error("Reviewer requires its fresh print transport options");
|
|
59
|
+
const { ReviewerPrintPeer } = require("../reviewer-print.ts") as typeof import("../reviewer-print.ts");
|
|
60
|
+
return new ReviewerPrintPeer(ctx.instance, ctx.daemon.reviewerPrint);
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
/** Preserve existing transport option types and object identity; selection stays host-owned. */
|
|
42
64
|
export const DAEMON_PORTS = {
|
|
43
65
|
"codex-appserver": (options: Extract<DaemonPorts, { codex: unknown }>["codex"]) => ({ codex: options }),
|
|
@@ -45,4 +67,6 @@ export const DAEMON_PORTS = {
|
|
|
45
67
|
"owned-acp": (options: Extract<DaemonPorts, { jcode: unknown }>["jcode"]) => ({ jcode: options }),
|
|
46
68
|
"pi-rpc": (options: Extract<DaemonPorts, { pi: unknown }>["pi"]) => ({ pi: options }),
|
|
47
69
|
"local-worker": (options: Extract<DaemonPorts, { local: unknown }>["local"]) => ({ local: options }),
|
|
70
|
+
"reviewer-print": (options: Extract<DaemonPorts, { reviewerPrint: unknown }>["reviewerPrint"]) => ({ reviewerPrint: options }),
|
|
71
|
+
"claude-print": (options: Extract<DaemonPorts, { claudePrint: unknown }>["claudePrint"]) => ({ claudePrint: options }),
|
|
48
72
|
} satisfies Record<DaemonEngine, unknown>;
|
|
@@ -15,7 +15,7 @@ export function codexCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function jcodeCrashPlan(_meta: Record<string, unknown>): NativeCrashPlan {
|
|
18
|
-
return { how: "jcode: native 0.94.0
|
|
18
|
+
return { how: "jcode: native ACP load (verified on 0.94.0 and 0.98.1) does not prove the restored id; reopen the original conversation with its native client, no automatic fresh replacement" };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export function kimiCrashPlan(meta: Record<string, unknown>): NativeCrashPlan {
|