@staix/agent-hub 0.12.44 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +83 -116
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/docs/adding-an-ai-tool.md +102 -14
- package/docs/agent-notes/README.md +15 -0
- package/docs/agent-notes/adapters.md +63 -46
- package/docs/agent-notes/budget.md +7 -2
- package/docs/agent-notes/bus.md +6 -4
- package/docs/agent-notes/daemon.md +15 -8
- package/docs/agent-notes/local-worker.md +6 -4
- package/docs/agent-notes/tasks.md +18 -5
- package/docs/agent-notes/tests.md +11 -9
- package/docs/dogfood.md +92 -0
- package/docs/events.md +14 -1
- package/docs/operations.md +349 -26
- package/docs/profiles.md +2 -2
- package/docs/quickstart.md +12 -2
- package/docs/sdlc.md +61 -13
- package/docs/security.md +24 -12
- package/docs/smoke.md +9 -4
- package/docs/specs/2026-09-19-agent-hub-design.md +873 -13
- package/docs/verification/2026-10-19-reviewer-isolation.md +2 -0
- package/docs/verification/2026-10-21-469-r1-dashboard-tokens.md +43 -0
- package/docs/verification/2026-10-21-469-r2-dashboard-shell.md +54 -0
- package/docs/verification/2026-10-21-469-r3-dashboard-board.md +51 -0
- package/docs/verification/2026-10-21-469-r4-dashboard-views.md +77 -0
- package/docs/verification/2026-10-21-469-r6-dashboard-motion-states.md +82 -0
- package/docs/verification/2026-10-22-0.12.45.md +60 -0
- package/docs/verification/2026-10-22-code-facts.md +78 -0
- package/docs/verification/2026-10-22-codex-reviewer-isolation.md +47 -0
- package/docs/verification/2026-10-22-kimi-qwen-compat.md +79 -0
- package/docs/verification/2026-10-23-0.13.0.md +89 -0
- package/docs/verification/assets/469-r1-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r1-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r1-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-agents-768-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1024-light.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r2-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r2-board-390-light.png +0 -0
- package/docs/verification/assets/469-r2-board-768-light.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-board-390-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-dark.png +0 -0
- package/docs/verification/assets/469-r3-detail-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-detail-390-dark.png +0 -0
- package/docs/verification/assets/469-r3-plan-1440-light.png +0 -0
- package/docs/verification/assets/469-r3-plan-390-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-agents-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-agents-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-memory-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-memory-390-dark.png +0 -0
- package/docs/verification/assets/469-r4-message-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-office-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-dark.png +0 -0
- package/docs/verification/assets/469-r4-settings-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-1440-light.png +0 -0
- package/docs/verification/assets/469-r4-stream-390-light.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-agents-empty-390-light.png +0 -0
- package/docs/verification/assets/469-r6-board-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-board-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-board-empty-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-disconnected-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-skeleton-390-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-dark.png +0 -0
- package/docs/verification/assets/469-r6-toasts-1440-light.png +0 -0
- package/docs/verification/assets/469-r6-toasts-390-light.png +0 -0
- package/docs/verification/dogfood/2026-10-22.md +3109 -0
- package/docs/verified.json +19 -16
- package/package.json +5 -1
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +10120 -9623
- package/src/adapters/adapter-api.ts +2 -0
- package/src/adapters/agy/definition.ts +3 -1
- package/src/adapters/agy/entry.ts +5 -4
- package/src/adapters/claude-channel.ts +32 -12
- package/src/adapters/codex-appserver.ts +19 -2
- package/src/adapters/codex-review-isolation.ts +12 -5
- package/src/adapters/compat.ts +140 -0
- package/src/adapters/conformance/acp.ts +114 -0
- package/src/adapters/conformance/codex.ts +191 -0
- package/src/adapters/conformance/enrollment.ts +43 -0
- package/src/adapters/conformance/factories.ts +17 -0
- package/src/adapters/conformance/fake-paths.ts +12 -0
- package/src/adapters/conformance/fakes/acp-server.ts +267 -0
- package/src/adapters/conformance/fakes/acp-watchdog-agent.ts +10 -0
- package/src/adapters/conformance/fakes/app-server.ts +119 -0
- package/src/adapters/conformance/fakes/codex-bin.ts +15 -0
- package/src/adapters/conformance/fakes/jcode-agent.ts +91 -0
- package/src/adapters/conformance/fakes/model-server.ts +39 -0
- package/src/adapters/conformance/fakes/pi-rpc.ts +32 -0
- package/src/adapters/conformance/index.ts +11 -0
- package/src/adapters/conformance/kit.ts +50 -0
- package/src/adapters/conformance/local-pi.ts +100 -0
- package/src/adapters/conformance/owned-acp.ts +44 -0
- package/src/adapters/definitions/builtin-metadata.ts +25 -7
- package/src/adapters/definitions/native-cli-doctor.ts +12 -5
- package/src/adapters/definitions/native-cli-entry-jcode.ts +9 -6
- package/src/adapters/definitions/native-cli-entry-kimi.ts +7 -1
- package/src/adapters/definitions/native-cli-entry.ts +7 -19
- package/src/adapters/definitions/native-cli-jcode-mcp.ts +9 -2
- package/src/adapters/definitions/native-cli-preview-common.ts +2 -0
- package/src/adapters/definitions/native-cli-preview-jcode.ts +8 -2
- package/src/adapters/definitions/native-cli-preview-qwen.ts +2 -1
- package/src/adapters/definitions/native-cli-preview.ts +2 -11
- package/src/adapters/definitions/qwen-native.ts +7 -3
- package/src/adapters/definitions/qwen.ts +5 -1
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-module-scan.ts +126 -0
- package/src/adapters/external-modules.ts +240 -0
- package/src/adapters/jcode.ts +72 -31
- package/src/adapters/kimi-review.ts +6 -3
- package/src/adapters/pi.ts +2 -0
- package/src/adapters/public.ts +7 -0
- package/src/adapters/registry-types.ts +13 -1
- package/src/adapters/registry.ts +52 -15
- package/src/cli/agents.ts +40 -0
- package/src/cli/console-review.ts +54 -0
- package/src/cli/console-state.ts +124 -29
- package/src/cli/console-symbols.ts +74 -0
- package/src/cli/console-usage.ts +89 -0
- package/src/cli/console.ts +26 -1
- package/src/cli/help.ts +36 -21
- package/src/cli/identity.ts +30 -7
- package/src/cli/init.ts +109 -11
- package/src/cli/main.ts +145 -16
- package/src/cli/output.ts +44 -1
- package/src/cli/preview.ts +1 -1
- package/src/cli/sdlc-release-guard.ts +82 -0
- package/src/cli/upgrade-labels.ts +1 -1
- package/src/cli/upgrade-presentation.ts +1 -1
- package/src/cli/upgrade-restore-state.ts +3 -2
- package/src/cli/upgrade-runtime.ts +4 -1
- package/src/hub/action-class.ts +362 -0
- package/src/hub/action-policy.ts +106 -0
- package/src/hub/board.ts +72 -11
- package/src/hub/budget.ts +71 -2
- package/src/hub/bus.ts +43 -3
- package/src/hub/checks.ts +80 -31
- package/src/hub/code-facts.ts +223 -0
- package/src/hub/config-apply.ts +28 -11
- package/src/hub/config-reload.ts +8 -5
- package/src/hub/config-trust.ts +7 -4
- package/src/hub/control-client.ts +4 -1
- package/src/hub/daemon.ts +332 -61
- package/src/hub/dogfood-read.ts +199 -0
- package/src/hub/dogfood.ts +163 -0
- package/src/hub/envelope.ts +1 -1
- package/src/hub/events.ts +11 -2
- package/src/hub/facts.ts +76 -13
- package/src/hub/hub-tools.ts +45 -7
- package/src/hub/inbox-polling.ts +25 -0
- package/src/hub/instance-config.ts +17 -5
- package/src/hub/instance-execution.ts +20 -8
- package/src/hub/instances.ts +75 -2
- package/src/hub/peers.ts +9 -0
- package/src/hub/plan-conformance.ts +83 -0
- package/src/hub/profiles.ts +7 -2
- package/src/hub/release-worktree.ts +56 -0
- package/src/hub/review-brief.ts +36 -0
- package/src/hub/review-findings.ts +104 -0
- package/src/hub/roster.ts +31 -5
- package/src/hub/sdlc-queue.ts +48 -11
- package/src/hub/sdlc-release-authorization.ts +173 -0
- package/src/hub/sdlc-release.ts +16 -1
- package/src/hub/sdlc.ts +71 -18
- package/src/hub/settings.ts +10 -3
- package/src/hub/snapshots.ts +101 -14
- package/src/hub/symbol-normalizer.ts +26 -0
- package/src/hub/task-code-symbols.ts +90 -0
- package/src/hub/task-sweep.ts +3 -2
- package/src/hub/tasks.ts +556 -88
- package/src/hub/test-integrity.ts +162 -0
- package/src/hub/ui.ts +2 -2
- package/src/hub/usage-rollup.ts +361 -0
- package/src/local/sandbox.ts +5 -3
- package/src/ui/index.html +560 -78
- package/src/ui/office-model.ts +3 -1
- package/src/ui/peer-palette.ts +44 -0
- package/templates/AGENTS.block.md +1 -1
- package/templates/profiles/dev.json +6 -1
- package/templates/profiles/general.json +6 -1
- package/templates/profiles/research.json +6 -1
- package/templates/profiles/writing.json +6 -1
- package/templates/release-guard.block.md +6 -0
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runAgyEntry } from "./entry.ts";
|
|
1
2
|
import type { AdapterDefinition, LaunchContext, LaunchPlan } from "../registry-types.ts";
|
|
2
3
|
import { AGY_REVIEWER_VARIANT } from "../definitions/reviewer-variants.ts";
|
|
3
4
|
import { AGY_FACTS_HOOK } from "./hooks.ts";
|
|
@@ -15,6 +16,7 @@ function agyLaunch(ctx: LaunchContext): LaunchPlan {
|
|
|
15
16
|
export const AGY_DEFINITION: AdapterDefinition = {
|
|
16
17
|
api: 1,
|
|
17
18
|
kind: "agy",
|
|
19
|
+
projection: { roleRank: 7, helpRank: 4, plannerEligible: false },
|
|
18
20
|
displayName: "Antigravity",
|
|
19
21
|
family: "agy",
|
|
20
22
|
transport: "ws-pull",
|
|
@@ -50,7 +52,7 @@ export const AGY_DEFINITION: AdapterDefinition = {
|
|
|
50
52
|
recoveryRefusal: () => "Antigravity session continuity and resume are unavailable until native behavior is verified",
|
|
51
53
|
},
|
|
52
54
|
turnEnd: "native-hook",
|
|
53
|
-
cli: { command: "agy", authority: "person", startWords: () => ["agy"] },
|
|
55
|
+
cli: { command: "agy", entry: { lifecycleAdmission: true, execute: runAgyEntry }, usage: [["ahub agy [--print-command | --dry-run] [args...]", "optional Antigravity CLI 1.4.x TUI (1.4.2 verified) in this terminal (roster.agy, default off): pull-only via hub_inbox, implementer/verifier only; native permissions unmanaged, no hub sandbox"]], authority: "person", startWords: () => ["agy"] },
|
|
54
56
|
display: DISPLAY,
|
|
55
57
|
routing: { allowedRoles: ["implementer", "verifier"] },
|
|
56
58
|
reviewer: { defaultPool: false, declaration: "automatic", unavailable: "agy is an implementer peer (#485)" },
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { publicArg } from "../definitions/native-cli-preview-common.ts";
|
|
2
|
+
import { assessVersion, COMPAT } from "../compat.ts";
|
|
2
3
|
import { spawn, spawnSync } from "node:child_process";
|
|
3
4
|
import { resolve } from "node:path";
|
|
4
5
|
import type { NativeCliEntryContext } from "../definitions/native-cli-entry-types.ts";
|
|
@@ -38,10 +39,10 @@ export const AGY_LAUNCH_NOTICE = "Antigravity: pull-only TUI; native permissions
|
|
|
38
39
|
|
|
39
40
|
/** agy auto-updates, so the 1.4.x line launches; only 1.4.2 has a T0 receipt and other patches warn (#589). */
|
|
40
41
|
export function agyVersionGate(stdout: string): { ok: true; message?: string } | { ok: false; message: string } {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (
|
|
44
|
-
return { ok:
|
|
42
|
+
// #691: the verified versions, the compatible line and the person's trust live in compat.ts.
|
|
43
|
+
const verdict = assessVersion("agy", COMPAT.agy.parse(stdout));
|
|
44
|
+
if (!verdict.ok) return { ok: false, message: verdict.reason };
|
|
45
|
+
return verdict.note ? { ok: true, message: verdict.note } : { ok: true };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export async function runAgyEntry(ctx: NativeCliEntryContext): Promise<void> {
|
|
@@ -22,9 +22,16 @@ import { renderHeldHint } from "../hub/delivery-hints.ts";
|
|
|
22
22
|
// ControlClient verifies the manifest identity on every connection.
|
|
23
23
|
const stateDir = process.env.AGENTHUB_STATE_DIR ?? stateDirFor(process.cwd());
|
|
24
24
|
const projectRoot = process.env.AGENTHUB_PROJECT_DIR ?? process.cwd();
|
|
25
|
-
|
|
26
|
-
/** tools mode: the same server, run by Kimi (ACP mcpServers) or Codex (mcp_servers override). Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
|
|
25
|
+
/** tools mode: the same server, run by Kimi (ACP mcpServers), Codex (mcp_servers override) or Jcode. Their messages arrive through their own adapters, so no channel here. Not the "tools-only" Claude below. */
|
|
27
26
|
const toolsMode = process.env.AGENTHUB_MODE === "tools";
|
|
27
|
+
/**
|
|
28
|
+
* #368: a tools-mode server acts as exactly the peer whose owned launch started it, and the project's Jcode MCP entry names none, so the
|
|
29
|
+
* id comes from the owned process env alone. With none (a person's own `jcode` run in this project outside the hub) it connects as nobody,
|
|
30
|
+
* refuses every tool with a reason, and never defaults to "jcode" or any other id. Only a plain Claude session defaults to "claude".
|
|
31
|
+
*/
|
|
32
|
+
const unidentified = toolsMode && !process.env.AGENTHUB_PEER_ID;
|
|
33
|
+
const peerId = process.env.AGENTHUB_PEER_ID ?? (toolsMode ? "" : "claude");
|
|
34
|
+
const UNIDENTIFIED = "agent-hub tools refused: this process was not started by the hub (no AGENTHUB_PEER_ID), so it has no peer identity. Start the agent with ahub jcode (or the hub's own start) to use hub tools.";
|
|
28
35
|
// Exposure hint only; the daemon verifies the actual adapter before any retry mutation.
|
|
29
36
|
const deliveryOnly = process.env.AGENTHUB_DELIVERY_ONLY === "true";
|
|
30
37
|
/**
|
|
@@ -84,7 +91,7 @@ const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
|
84
91
|
const TERMINAL_CLOSES: Record<number, string> = {
|
|
85
92
|
4401: "the hub refused the control token",
|
|
86
93
|
4404: "the hub belongs to a different project or instance; restart this session from the intended project",
|
|
87
|
-
4403: `the hub refused
|
|
94
|
+
4403: `the hub refused "${peerId}": the id is reserved or malformed, or this process is not an owned launch of that peer (start it with ahub jcode)`,
|
|
88
95
|
4409: `the peer id "${peerId}" is taken by a hub-managed adapter`,
|
|
89
96
|
4430: "this Claude launch ended; start a new session with ahub claude, or use a plain session with hub_inbox",
|
|
90
97
|
4426: "wire version mismatch with the running hub: update the agent-hub plugin (ahub setup) and restart this session",
|
|
@@ -157,6 +164,8 @@ const offline = () => detached ?? "hub is not running for this project (start it
|
|
|
157
164
|
* Surfaced on the next tool result; the pull itself still happens only through hub_inbox.
|
|
158
165
|
*/
|
|
159
166
|
let pendingQueued: { queued: number; important: number } | undefined;
|
|
167
|
+
/** Marks a tool result whose text already states the queued count (#221), so the fresh-count block does not repeat it. Symbol keys never reach the client. */
|
|
168
|
+
const QUEUED_COUNTED = Symbol("queuedCounted");
|
|
160
169
|
/** Fresh own-peer counts accompany all tool modes; only old plain sessions use advisory queued nudges. */
|
|
161
170
|
async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T> {
|
|
162
171
|
const r = result as { content?: { type: string; text?: string }[] };
|
|
@@ -169,23 +178,28 @@ async function withPendingHint<T>(result: T, recommendInbox = true): Promise<T>
|
|
|
169
178
|
};
|
|
170
179
|
if (hub && r.content) {
|
|
171
180
|
let freshCounts = false;
|
|
181
|
+
let hint = "";
|
|
172
182
|
try {
|
|
173
183
|
const response = await hub.request({ t: "delivery_holds" }, 1000);
|
|
184
|
+
// A hold that refuses pulls (#221): hub_inbox would only be refused again, so the counts stay and the advice goes.
|
|
185
|
+
hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
|
|
174
186
|
if (response?.ok && Number.isSafeInteger(response.queued) && response.queued >= 0
|
|
175
187
|
&& Number.isSafeInteger(response.important) && response.important >= 0 && response.important <= response.queued) {
|
|
176
188
|
freshCounts = true;
|
|
177
189
|
pendingQueued = undefined; // A current zero must never resurrect an earlier queued nudge.
|
|
178
190
|
if (response.important > 0) {
|
|
179
|
-
const next = !recommendInbox ? "" : toolsMode
|
|
191
|
+
const next = !recommendInbox || hint ? "" : toolsMode
|
|
180
192
|
? ": use hub_inbox on a pull-capable peer session; otherwise finish the current turn to receive queued deliveries"
|
|
181
193
|
: ": call hub_inbox before starting long local work";
|
|
182
|
-
|
|
194
|
+
// One queued count per result (#221): a hub_inbox result that already says how many remain unread adds only the important count.
|
|
195
|
+
const counted = (result as { [QUEUED_COUNTED]?: boolean })[QUEUED_COUNTED] === true;
|
|
196
|
+
appendQueued(counted ? `(${response.important} important hub message(s) waiting${next})` : `(${response.queued} queued hub message(s), ${response.important} important hub message(s) waiting${next})`);
|
|
183
197
|
}
|
|
184
198
|
}
|
|
185
|
-
const hint = response?.ok ? renderHeldHint(response.held, Date.now(), !deliveryOnly) : "";
|
|
186
199
|
if (hint) r.content.push({ type: "text", text: hint });
|
|
187
200
|
} catch { /* advisory metadata can never change a saved tool result or its effect classification */ }
|
|
188
|
-
|
|
201
|
+
// A pre-#493 daemon sends held rows without counts; the legacy nudge must not advise a read the hold refuses (#221).
|
|
202
|
+
if (!freshCounts && !hint && recommendInbox && !channel && !toolsMode && pendingQueued && pendingQueued.important > 0) {
|
|
189
203
|
appendQueued(`(${pendingQueued.important} important hub message(s) waiting: call hub_inbox before starting long local work)`);
|
|
190
204
|
}
|
|
191
205
|
}
|
|
@@ -244,7 +258,7 @@ async function connectLoop(): Promise<void> {
|
|
|
244
258
|
let closeReason: string | undefined;
|
|
245
259
|
try {
|
|
246
260
|
const client = await ControlClient.connect(stateDir, { role: toolsMode ? "tools" : "peer", peer: peerId, ...(toolsMode ? {} : { channel }),
|
|
247
|
-
...(
|
|
261
|
+
...(process.env.AGENTHUB_LAUNCH_ID ? { launchId: process.env.AGENTHUB_LAUNCH_ID } : {}),
|
|
248
262
|
...(!toolsMode && standingBy ? { standby: true } : {}),
|
|
249
263
|
...(process.env.AGENTHUB_PROJECT_DIR ? { projectRoot } : {}) });
|
|
250
264
|
client.onPush = (msg) => {
|
|
@@ -368,6 +382,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
368
382
|
|
|
369
383
|
async function callTool(req: { params: { name: string; arguments?: Record<string, unknown> } }) {
|
|
370
384
|
const { name, arguments: args } = req.params;
|
|
385
|
+
if (unidentified) return { ...text(UNIDENTIFIED), isError: true };
|
|
371
386
|
if (name === "hub_delivery_done" && channel) {
|
|
372
387
|
if (!hub) return text(offline());
|
|
373
388
|
const a = args ?? {};
|
|
@@ -375,7 +390,7 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
375
390
|
return text(result.ok ? "delivery completed" : `not completed: ${result.error}`);
|
|
376
391
|
}
|
|
377
392
|
if (name === "hub_inbox" && !toolsMode) {
|
|
378
|
-
let remote: string[] = [], notice = "";
|
|
393
|
+
let remote: string[] = [], notice = "", polling = "", counted = false;
|
|
379
394
|
if (hub) {
|
|
380
395
|
try {
|
|
381
396
|
const res = await hub.request({ t: "inbox" });
|
|
@@ -384,14 +399,19 @@ async function callTool(req: { params: { name: string; arguments?: Record<string
|
|
|
384
399
|
else {
|
|
385
400
|
pendingQueued = { queued: typeof res.waiting === "number" ? res.waiting : 0, important: typeof res.waitingImportant === "number" ? res.waitingImportant : 0 };
|
|
386
401
|
remote = (res.envs as Envelope[]).map(frame);
|
|
387
|
-
if (res.
|
|
402
|
+
if (Number.isSafeInteger(res.pollingAdvice?.count) && res.pollingAdvice.count >= 3 && Number.isSafeInteger(res.pollingAdvice?.minutes) && res.pollingAdvice.minutes >= 0 && res.pollingAdvice.minutes <= 10) polling = `[polling detected] ${res.pollingAdvice.count} empty reads in ${res.pollingAdvice.minutes} min with no assigned work: end your turn`;
|
|
403
|
+
// #713: a pull drains what fits one read; what is left is said plainly, with what to do about it. The count already says how many remain, so the pending hint adds only the important ones (#221).
|
|
404
|
+
if (res.waiting) { notice = `(${res.waiting} more waiting: call hub_inbox again before you start work)`; counted = true; }
|
|
388
405
|
}
|
|
389
406
|
} catch { notice = "not read: daemon inbox unavailable; inspect the connection before another read"; }
|
|
390
407
|
} else notice = offline();
|
|
391
408
|
// Only pushes without a journal delivery id enter this buffer. Pull completion belongs to the daemon.
|
|
392
409
|
const out = [...inbox.splice(0), ...remote];
|
|
393
410
|
if (notice) out.push(notice);
|
|
394
|
-
|
|
411
|
+
const body = out.length ? out.join("\n\n") : "(no queued hub messages)\nNothing is waiting. Do not poll: if you have no assigned task, report and end your turn; new work waits here for your next boundary.";
|
|
412
|
+
const result = text(polling ? `${body}\n${polling}` : body);
|
|
413
|
+
if (counted) Object.defineProperty(result, QUEUED_COUNTED, { value: true });
|
|
414
|
+
return result;
|
|
395
415
|
}
|
|
396
416
|
if (name === "hub_send") {
|
|
397
417
|
const { text: body, to, reply_to } = (args ?? {}) as { text?: string; to?: string[]; reply_to?: string };
|
|
@@ -418,4 +438,4 @@ await server.connect(new StdioServerTransport());
|
|
|
418
438
|
// The host (Claude Code, Codex, Kimi) talks over stdin; once it is gone nothing can use this server, and a leftover would keep reconnecting.
|
|
419
439
|
process.stdin.on("end", () => process.exit(0));
|
|
420
440
|
process.stdin.on("close", () => process.exit(0));
|
|
421
|
-
void connectLoop();
|
|
441
|
+
if (unidentified) log(UNIDENTIFIED); else void connectLoop();
|
|
@@ -9,6 +9,8 @@ import type { ContextReader } from "./registry-types.ts";
|
|
|
9
9
|
|
|
10
10
|
const UNIDENTIFIED_CANCELLATION_NOTICE = "Codex cancelled turn has no verified native id; reattach the native session before further hub delivery";
|
|
11
11
|
|
|
12
|
+
/** A counter Codex reported: a finite non-negative number, otherwise 0 (an absent kind is not a negative delta). */
|
|
13
|
+
const counter = (value: unknown): number => { const n = Number(value); return Number.isFinite(n) && n >= 0 ? n : 0; };
|
|
12
14
|
export interface CodexOptions {
|
|
13
15
|
/** Port the TUI attaches to: `codex --enable tui_app_server --remote ws://127.0.0.1:<proxyPort>`. */
|
|
14
16
|
proxyPort: number;
|
|
@@ -28,6 +30,11 @@ export interface CodexOptions {
|
|
|
28
30
|
onUsage?: (rateLimits: unknown, hard: boolean) => void;
|
|
29
31
|
/** Tokens the thread used since the previous update (see `tokenTotal` for how they are counted). */
|
|
30
32
|
onTokens?: (added: number) => void;
|
|
33
|
+
/**
|
|
34
|
+
* The same update as `onTokens`, split into the kinds Codex reports (#683 U1): the increase of each `total` counter since the previous
|
|
35
|
+
* update. Cached input is a part of `input`, and `reasoning` a part of `output` (Codex's own definition), so `total` is not their sum.
|
|
36
|
+
*/
|
|
37
|
+
onTokenBreakdown?: (delta: { input: number; cachedInput: number; output: number; reasoning: number; total: number }) => void;
|
|
31
38
|
onContext?: (reading: ContextReading) => void;
|
|
32
39
|
/** The definition's native occupancy reader; standalone proxies retain the native default. */
|
|
33
40
|
contextReader?: ContextReader;
|
|
@@ -89,6 +96,8 @@ export class CodexPeer extends BasePeer {
|
|
|
89
96
|
* resumed thread's first update only sets the baseline: its total holds the history from before (issue #40).
|
|
90
97
|
*/
|
|
91
98
|
private tokenTotal: number | undefined;
|
|
99
|
+
/** The per-kind counters of the last `total`, for the breakdown deltas (#683 U1). */
|
|
100
|
+
private tokenKinds: { input: number; cachedInput: number; output: number; reasoning: number } | undefined;
|
|
92
101
|
private freshThread = false;
|
|
93
102
|
private readonly activeTurns = new Set<string>();
|
|
94
103
|
private deliveryEpoch = 0;
|
|
@@ -488,6 +497,7 @@ export class CodexPeer extends BasePeer {
|
|
|
488
497
|
get proxyAttached(): boolean { return !!this.link && this.link.up.readyState === WebSocket.OPEN; }
|
|
489
498
|
|
|
490
499
|
getPermissionMode(): PermissionMode { return this.permissionMode; }
|
|
500
|
+
get permissionModeState(): PermissionMode { return this.getPermissionMode(); }
|
|
491
501
|
|
|
492
502
|
/** Reconcile the daemon's current choice after startup, before the returned proxy URL has a TUI. */
|
|
493
503
|
setStartupPermissionMode(mode: PermissionMode): void {
|
|
@@ -583,7 +593,7 @@ export class CodexPeer extends BasePeer {
|
|
|
583
593
|
for (const ids of this.turnDeliveries.values()) for (const id of ids) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
|
|
584
594
|
for (const id of this.unboundDeliveries) this.delivery({ id, state: "needs_review", reason: "Codex native thread changed" });
|
|
585
595
|
this.turnDeliveries.clear(); this.unboundDeliveries.clear();
|
|
586
|
-
this.tokenTotal = undefined;
|
|
596
|
+
this.tokenTotal = undefined; this.tokenKinds = undefined;
|
|
587
597
|
this.freshThread = fresh;
|
|
588
598
|
this.link = link;
|
|
589
599
|
this.threadId = threadId;
|
|
@@ -647,9 +657,16 @@ export class CodexPeer extends BasePeer {
|
|
|
647
657
|
this.opts.onContext?.(reader ? reader.read(params.tokenUsage, this.threadId, Date.now()) : codexContext(params.tokenUsage, this.threadId, Date.now()));
|
|
648
658
|
const total = Number(params.tokenUsage?.total?.totalTokens);
|
|
649
659
|
if (!Number.isFinite(total)) return;
|
|
660
|
+
const previous = this.tokenKinds;
|
|
661
|
+
const kinds = { input: counter(params.tokenUsage?.total?.inputTokens), cachedInput: counter(params.tokenUsage?.total?.cachedInputTokens),
|
|
662
|
+
output: counter(params.tokenUsage?.total?.outputTokens), reasoning: counter(params.tokenUsage?.total?.reasoningOutputTokens) };
|
|
650
663
|
const added = this.tokenTotal === undefined ? (this.freshThread ? total : 0) : Math.max(0, total - this.tokenTotal);
|
|
651
|
-
|
|
664
|
+
// The same baseline rule as the total: a thread that was resumed counts only what it adds from now on; a new one counts from zero.
|
|
665
|
+
const base = previous ?? (this.freshThread && this.tokenTotal === undefined ? { input: 0, cachedInput: 0, output: 0, reasoning: 0 } : undefined);
|
|
666
|
+
this.tokenTotal = total; this.tokenKinds = kinds;
|
|
652
667
|
if (added > 0) this.opts.onTokens?.(added);
|
|
668
|
+
if (added > 0 && base) this.opts.onTokenBreakdown?.({ input: Math.max(0, kinds.input - base.input), cachedInput: Math.max(0, kinds.cachedInput - base.cachedInput),
|
|
669
|
+
output: Math.max(0, kinds.output - base.output), reasoning: Math.max(0, kinds.reasoning - base.reasoning), total: added });
|
|
653
670
|
return;
|
|
654
671
|
}
|
|
655
672
|
if (method === "turn/started") {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DENY_NAMES, DENY_SEGMENTS } from "@staix/agent-mem/platform";
|
|
2
|
+
import { assessVersion, COMPAT } from "./compat.ts";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { spawn } from "node:child_process";
|
|
4
5
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
@@ -9,7 +10,9 @@ import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process
|
|
|
9
10
|
import { realPath } from "../hub/project.ts";
|
|
10
11
|
import type { ReviewerBackend, ReviewerLaunch, ReviewerPrepareContext } from "./reviewer-print.ts";
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
+
const TOOLS = ["hub_review", "hub_task_show"];
|
|
14
|
+
/** A version refusal names only versions and a command, so the daemon may show it (#691). */
|
|
15
|
+
class VersionRefusal extends Error {}
|
|
13
16
|
const CAP = 128 * 1024, MAX_SERVERS = 64;
|
|
14
17
|
const digest = (value: string | Buffer) => createHash("sha256").update(value).digest("hex");
|
|
15
18
|
/** Conservative translation of the portable core's regex sources; unfamiliar syntax refuses the receipt. */
|
|
@@ -207,11 +210,14 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
207
210
|
child.once("error", () => { failed = true; void finish(null); });
|
|
208
211
|
child.once("close", code => { void finish(code); });
|
|
209
212
|
});
|
|
210
|
-
|
|
213
|
+
// #691: the verified versions and the person's trust live in compat.ts; every proof below still runs after trust.
|
|
214
|
+
const verdict = assessVersion("codex-review", COMPAT["codex-review"].parse(await run(["--version"])));
|
|
215
|
+
if (!verdict.ok) throw new VersionRefusal(verdict.reason);
|
|
216
|
+
const version = verdict.version;
|
|
211
217
|
if (Buffer.byteLength(ctx.mcpConfig) > CAP || ctx.privatePaths.length > 128) throw new Error("policy bounds exceeded");
|
|
212
218
|
const hub = ownServer(ctx.mcpConfig);
|
|
213
219
|
// 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" } })}`];
|
|
220
|
+
const policy = ["-c", "notify=[]", "--disable", "plugins", "--disable", "hooks", "--disable", "apps", "-c", 'web_search="disabled"', "-c", `projects=${toml({ [cwd]: { trust_level: "untrusted" } })}`];
|
|
215
221
|
proveDisabledFeatures(await run(["features", "list", ...policy]));
|
|
216
222
|
const before = inventory(await run(["mcp", "list", "--json", ...policy]));
|
|
217
223
|
if (before.some(row => row.name === "agent-hub")) throw new Error("ambient hub MCP collision");
|
|
@@ -268,11 +274,12 @@ async function prepare(ctx: ReviewerPrepareContext): Promise<ReviewerLaunch | {
|
|
|
268
274
|
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
275
|
current: () => { try { return current(); } catch { return false; } },
|
|
270
276
|
cleanup: () => rmSync(cleanupRoot, { recursive: true, force: true }),
|
|
271
|
-
isolation: { backend: "codex", version
|
|
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"] } };
|
|
277
|
+
isolation: { backend: "codex", version, binary: `sha256:${binaryHash}`, profile: `sha256:${launchProfile}`, readOnly: true, strictMcp: true, ephemeral: true,
|
|
278
|
+
evidence: ["model-free-mcp-inventory-disabled-ambient", "hub-tools-exact-allowlist", "plugins-and-hooks-disabled", "legacy-notify-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
279
|
} catch (error) {
|
|
274
280
|
if (root) { try { rmSync(root, { recursive: true, force: true }); } catch { /* unavailable remains refused */ } }
|
|
275
281
|
// Name only the path class of an unusable credential root so a stale ~/.docker link is fixable; never a path or contents.
|
|
282
|
+
if (error instanceof VersionRefusal) return { unavailable: error.message };
|
|
276
283
|
if (error instanceof CredentialRootError) {
|
|
277
284
|
const why = { dangling: "a dangling link", unreadable: "an unreadable path", unsupported: "unsupported syntax or overlapping the checkout" }[error.kind];
|
|
278
285
|
return { unavailable: `Codex reviewer isolation receipt unavailable (credential root ${error.label} is ${why}; remove or repair it)` };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { hubHome } from "../hub/project.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* #691: the one version policy for native agent CLIs. Every version gate asks `assessVersion`; no other file holds a
|
|
8
|
+
* version constant. `verified` lists versions with a receipt in docs/verification; `line` is a semver window assumed
|
|
9
|
+
* compatible (contract gates only); a person's trust lets an exact other version through on this machine. An isolation
|
|
10
|
+
* gate keeps every runtime proof after trust: trust only lifts the version pin.
|
|
11
|
+
*/
|
|
12
|
+
export type CompatGate = "contract" | "isolation";
|
|
13
|
+
export interface CompatPolicy {
|
|
14
|
+
label: string;
|
|
15
|
+
verified: readonly string[];
|
|
16
|
+
line?: { min: string; below: string };
|
|
17
|
+
gate: CompatGate;
|
|
18
|
+
/** The default executable and the argv `ahub agents` uses to read the version. */
|
|
19
|
+
probe: readonly string[];
|
|
20
|
+
/** Turns the probe's stdout into a plain semver, or undefined. */
|
|
21
|
+
parse: (stdout: string) => string | undefined;
|
|
22
|
+
/** What runtime checks still decide after trust (isolation) or what stays unverified (contract). */
|
|
23
|
+
trustNote: string;
|
|
24
|
+
/** The config key that selects this kind's executable, when one exists. */
|
|
25
|
+
config?: "codex_bin" | "jcode_cmd" | "qwen_cmd";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const plain = (text: string) => text.length <= 64 && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(text) ? text : undefined;
|
|
29
|
+
export const COMPAT = {
|
|
30
|
+
// 0.163.0 receipt: docs/verification/2026-10-22-codex-reviewer-isolation.md
|
|
31
|
+
"codex-review": { label: "Codex reviewer (codex-cli)", verified: ["0.146.0", "0.163.0"], gate: "isolation", probe: ["codex", "--version"], config: "codex_bin",
|
|
32
|
+
parse: out => plain(out.trim().replace(/^codex-cli /, "")),
|
|
33
|
+
trustNote: "Every launch still proves the MCP inventory, Apps/plugins/hooks disabled, sandbox read/write denial and the binary fence; a failed leg still refuses. Trust covers only the source-level claims those probes cannot see (docs/verification/2026-10-19-reviewer-isolation.md)." },
|
|
34
|
+
// Kimi 2.3.0 / Qwen 0.26.0 receipt: docs/verification/2026-10-22-kimi-qwen-compat.md
|
|
35
|
+
"kimi-review": { label: "Kimi reviewer (kimi-code)", verified: ["2.2.0", "2.3.0"], gate: "isolation", probe: ["kimi", "--version"],
|
|
36
|
+
parse: out => plain(out.trim()),
|
|
37
|
+
trustNote: "Every launch still builds the private config, exact two-tool agent file and task-bound MCP; the native tool allowlist behaviour of an untested version is not re-proven." },
|
|
38
|
+
jcode: { label: "Jcode", verified: ["0.98.1", "0.99.0"], line: { min: "0.98.0", below: "1.0.0" }, gate: "contract", probe: ["jcode", "--quiet", "version", "--json"], config: "jcode_cmd",
|
|
39
|
+
parse: out => { try { const v = JSON.parse(out).semver; return typeof v === "string" ? plain(v) : undefined; } catch { return undefined; } },
|
|
40
|
+
trustNote: "The ACP, serve and MCP contract was verified on 0.98.1 and steering on 0.99.0; an untested version may change them." },
|
|
41
|
+
agy: { label: "agy", verified: ["1.4.2"], line: { min: "1.4.0", below: "1.5.0" }, gate: "contract", probe: ["agy", "--version"],
|
|
42
|
+
parse: out => plain(out.trim()),
|
|
43
|
+
trustNote: "Only 1.4.2 is T0-verified; the plugin MCP and hook contract of another version is unverified." },
|
|
44
|
+
qwen: { label: "Qwen Code", verified: ["0.25.0", "0.26.0"], gate: "contract", probe: ["qwen", "--version"], config: "qwen_cmd",
|
|
45
|
+
parse: out => plain(out.trim()),
|
|
46
|
+
trustNote: "Native ACP identity and mode ids have dated receipts; 0.26.0 permission callbacks were checked without inference. Another version may change those contracts." },
|
|
47
|
+
} as const satisfies Record<string, CompatPolicy>;
|
|
48
|
+
export type CompatId = keyof typeof COMPAT;
|
|
49
|
+
type CommandConfig = { codex_bin: string; jcode_cmd: string[]; qwen_cmd: string[] };
|
|
50
|
+
/** The configured executables the native gates use; the rest probe their PATH default. */
|
|
51
|
+
export function configuredCommands(config: CommandConfig): Partial<Record<CompatId, readonly string[]>> {
|
|
52
|
+
const out: Partial<Record<CompatId, readonly string[]>> = {};
|
|
53
|
+
for (const [id, policy] of Object.entries(COMPAT) as [CompatId, CompatPolicy][]) {
|
|
54
|
+
if (!policy.config) continue;
|
|
55
|
+
const value = config[policy.config];
|
|
56
|
+
out[id] = typeof value === "string" ? [value] : value;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
export const isCompatId = (id: unknown): id is CompatId => typeof id === "string" && Object.hasOwn(COMPAT, id);
|
|
61
|
+
|
|
62
|
+
export type CompatVerdict =
|
|
63
|
+
| { ok: true; state: "verified" | "in-line" | "trusted"; version: string; note?: string }
|
|
64
|
+
| { ok: false; state: "refused" | "unknown"; version?: string; reason: string };
|
|
65
|
+
|
|
66
|
+
const parts = (v: string) => /^(\d+)\.(\d+)\.(\d+)$/.exec(v)?.slice(1).map(Number);
|
|
67
|
+
const compare = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]!;
|
|
68
|
+
function inLine(version: string, line?: { min: string; below: string }): boolean {
|
|
69
|
+
const v = parts(version);
|
|
70
|
+
return !!line && !!v && compare(v, parts(line.min)!) >= 0 && compare(v, parts(line.below)!) < 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A refusal the daemon may show as is: it names only versions and a command. */
|
|
74
|
+
export const VERSION_REFUSAL = "Version not verified: ";
|
|
75
|
+
/** A window of one minor (1.4.0 up to 1.5.0) reads as "the 1.4.x line". */
|
|
76
|
+
function lineText(line: { min: string; below: string }): string {
|
|
77
|
+
const [a, b] = [parts(line.min)!, parts(line.below)!];
|
|
78
|
+
return a[2] === 0 && b[2] === 0 && a[0] === b[0] && b[1] === a[1]! + 1 ? `the ${a[0]}.${a[1]}.x line` : `the line ${line.min} up to ${line.below}`;
|
|
79
|
+
}
|
|
80
|
+
export function describe(id: CompatId): string {
|
|
81
|
+
const p: CompatPolicy = COMPAT[id];
|
|
82
|
+
return `verified ${p.verified.join(", ")}${p.line ? `; compatible: ${lineText(p.line)}` : ""}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `trusted` defaults to this machine's record; tests and the CLI pass their own. */
|
|
86
|
+
export function assessVersion(id: CompatId, observed: unknown, trusted: readonly TrustEntry[] = readTrust()): CompatVerdict {
|
|
87
|
+
const policy: CompatPolicy = COMPAT[id];
|
|
88
|
+
const version = typeof observed === "string" ? plain(observed) : undefined;
|
|
89
|
+
if (!version) return { ok: false, state: "unknown", reason: `${VERSION_REFUSAL}${policy.label} version unknown (${describe(id)}); check that the executable runs` };
|
|
90
|
+
if (policy.verified.includes(version)) return { ok: true, state: "verified", version };
|
|
91
|
+
if (trusted.some(entry => entry.id === id && entry.version === version)) return { ok: true, state: "trusted", version, note: `${policy.label} ${version} is untested; trusted on this machine by the person` };
|
|
92
|
+
if (policy.gate === "contract" && inLine(version, policy.line)) return { ok: true, state: "in-line", version, note: `${policy.label} ${version} is untested (${describe(id)}); launching on the compatible line` };
|
|
93
|
+
return { ok: false, state: "refused", version, reason: `${VERSION_REFUSAL}${policy.label} ${version} (${describe(id)}). Install a verified version, or accept this one on this machine with: ahub agents trust ${id} ${version}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface TrustEntry { id: CompatId; version: string; at: number }
|
|
97
|
+
const MAX_TRUST = 64;
|
|
98
|
+
export const trustFile = (home = hubHome()) => join(home, "agent-compat.json");
|
|
99
|
+
|
|
100
|
+
const MAX_BYTES = 64 * 1024;
|
|
101
|
+
/** An unreadable, linked, oversized or invalid record trusts nothing. */
|
|
102
|
+
export function readTrust(file = trustFile()): TrustEntry[] {
|
|
103
|
+
let fd: number | undefined;
|
|
104
|
+
try {
|
|
105
|
+
// O_NONBLOCK: a FIFO planted at the record path must not stall a synchronous version gate; fstat refuses it.
|
|
106
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
107
|
+
const info = fstatSync(fd);
|
|
108
|
+
if (!info.isFile() || info.size > MAX_BYTES) return [];
|
|
109
|
+
const bytes = Buffer.alloc(info.size);
|
|
110
|
+
if (readSync(fd, bytes, 0, info.size, 0) !== info.size) return [];
|
|
111
|
+
const raw = JSON.parse(bytes.toString("utf8"));
|
|
112
|
+
if (raw?.schemaVersion !== 1 || !Array.isArray(raw.trusted) || raw.trusted.length > MAX_TRUST) return [];
|
|
113
|
+
const rows = raw.trusted.filter((row: unknown): row is TrustEntry => {
|
|
114
|
+
const r = row as TrustEntry;
|
|
115
|
+
return !!r && isCompatId(r.id) && typeof r.version === "string" && plain(r.version) === r.version && Number.isSafeInteger(r.at);
|
|
116
|
+
});
|
|
117
|
+
return rows.length === raw.trusted.length ? rows : [];
|
|
118
|
+
} catch { return []; }
|
|
119
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Person-only (the CLI classifier refuses agent shells). Writes atomically; returns the new record. */
|
|
123
|
+
export function writeTrust(id: CompatId, version: string, revoke: boolean, file = trustFile(), now = Date.now()): TrustEntry[] {
|
|
124
|
+
if (plain(version) !== version) throw new Error("trust needs an exact version such as 1.2.3");
|
|
125
|
+
const rows = readTrust(file).filter(row => !(row.id === id && row.version === version));
|
|
126
|
+
if (!revoke) rows.push({ id, version, at: now });
|
|
127
|
+
if (rows.length > MAX_TRUST) throw new Error(`at most ${MAX_TRUST} trusted versions; revoke some first`);
|
|
128
|
+
const parent = dirname(file);
|
|
129
|
+
mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
130
|
+
const dir = lstatSync(parent);
|
|
131
|
+
if (!dir.isDirectory() || dir.isSymbolicLink()) throw new Error("the hub home is not a plain directory; trust not written");
|
|
132
|
+
const target = lstatSync(file, { throwIfNoEntry: false });
|
|
133
|
+
if (target && !target.isFile()) throw new Error("the trust record is not a regular file; trust not written");
|
|
134
|
+
// A unique, exclusively created temporary file: an existing name or a planted link fails instead of being followed.
|
|
135
|
+
const temporary = join(parent, `.agent-compat.${randomUUID()}.tmp`);
|
|
136
|
+
writeFileSync(temporary, `${JSON.stringify({ schemaVersion: 1, trusted: rows }, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
137
|
+
try { renameSync(temporary, file); }
|
|
138
|
+
catch (error) { unlinkSync(temporary); throw error; }
|
|
139
|
+
return rows;
|
|
140
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import type { AcpPeer } from "../acp.ts";
|
|
6
|
+
import { newEnvelope } from "../../hub/envelope.ts";
|
|
7
|
+
import type { DeliveryReceipt } from "../../hub/peers.ts";
|
|
8
|
+
import { processTable } from "../../hub/child-process.ts";
|
|
9
|
+
import { until } from "./kit.ts";
|
|
10
|
+
import type { AdapterDefinition } from "../registry-types.ts";
|
|
11
|
+
import { fakePaths } from "./fake-paths.ts";
|
|
12
|
+
import { createFromDefinition, expire } from "./factories.ts";
|
|
13
|
+
import { ACP_LEGS, assertAcpLegEnrollment, type AcpLeg } from "./enrollment.ts";
|
|
14
|
+
|
|
15
|
+
export interface AcpConformanceOptions {
|
|
16
|
+
definition: AdapterDefinition;
|
|
17
|
+
agentArgs?: readonly string[];
|
|
18
|
+
unsupported?: Partial<Record<AcpLeg, string>>;
|
|
19
|
+
permissionPrompts?: { accepted: string; foreign: string };
|
|
20
|
+
startupModeAcknowledgement?: boolean;
|
|
21
|
+
modeAcknowledgement?: string;
|
|
22
|
+
quotaLabel?: string;
|
|
23
|
+
watchdogLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
/** No protocol is run at import; callers explicitly select supported fake protocol legs. */
|
|
26
|
+
export function registerAcpConformance(options: AcpConformanceOptions): void {
|
|
27
|
+
const { definition } = options, kind = definition.kind, fake = fakePaths.acpServer;
|
|
28
|
+
if (definition.transport !== "acp") throw new Error(kind + ": ACP conformance requires an ACP definition");
|
|
29
|
+
const reasons = Object.fromEntries(Object.entries(options.unsupported ?? {}).map(([leg, reason]) => [kind + ":" + leg, reason!]));
|
|
30
|
+
assertAcpLegEnrollment([definition], reasons);
|
|
31
|
+
if (!options.unsupported?.["native-mode-ack"] && !options.modeAcknowledgement) throw new Error(kind + ": mode acknowledgement fixture must be explicit");
|
|
32
|
+
// The catalog chooses every real registration or explicit unsupported outcome; no kind is filtered away.
|
|
33
|
+
const registrations: Record<AcpLeg, (kind: string) => void> = {
|
|
34
|
+
"native-approval-rpc": kind => {
|
|
35
|
+
test(`P4 ${kind}: real permission RPC approves exact hub identity once and asks on a lookalike`, async () => {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-approval-")), asked: string[] = [], replies: string[] = [];
|
|
37
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? [])],
|
|
38
|
+
mcpServers: [{ name: "agent-hub", command: "unused", args: [], env: [] }],
|
|
39
|
+
autoApprove: title => title === "mcp__agent-hub__hub_task_list" || title === "mcp__agent-hub__hub_send",
|
|
40
|
+
onPermission: async request => { asked.push(request.title); return "no"; } } });
|
|
41
|
+
peer.onMessage = text => { replies.push(text); };
|
|
42
|
+
try {
|
|
43
|
+
await peer.start();
|
|
44
|
+
await peer.deliver([newEnvelope("user", options.permissionPrompts?.accepted ?? "PERMISSION HUBTOOL")]);
|
|
45
|
+
await until(() => replies.length === 1 && peer.state === "idle", "autoapproved permission settled");
|
|
46
|
+
expect(replies[0]).toEndWith("permission=yes"); expect(asked).toEqual([]);
|
|
47
|
+
await peer.deliver([newEnvelope("user", options.permissionPrompts?.foreign ?? "PERMISSION SPOOF")]);
|
|
48
|
+
await until(() => replies.length === 2 && peer.state === "idle", "lookalike permission settled");
|
|
49
|
+
expect(asked).toHaveLength(1); expect(replies[1]).toEndWith("permission=no");
|
|
50
|
+
await peer.setPermissionMode("never-ask"); expect(peer.permissionModeState).toBe("never-ask");
|
|
51
|
+
await peer.setPermissionMode("ask"); expect(peer.permissionModeState).toBe("ask");
|
|
52
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
53
|
+
}, 20_000);
|
|
54
|
+
},
|
|
55
|
+
"native-mode-ack": kind => {
|
|
56
|
+
const modeAcknowledgement = options.modeAcknowledgement;
|
|
57
|
+
if (!modeAcknowledgement) throw new Error(kind + ": mode acknowledgement fixture must be explicit");
|
|
58
|
+
test(`P4 ${kind}: unconfirmed mode change goes unknown/offline despite a late acknowledgement`, async () => {
|
|
59
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-mode-")), pid = join(dir, "pid"), ack = join(dir, "ack");
|
|
60
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? []), "--ignore-term", "--mode-delay-ms", "200", "--mode-ack-record", ack, "--record-pid", pid],
|
|
61
|
+
permissionModeTimeoutMs: 40 } });
|
|
62
|
+
try {
|
|
63
|
+
// Qwen reconciles its default at startup; Kimi's unchanged ask default needs no initial request.
|
|
64
|
+
if (options.startupModeAcknowledgement) await expect(peer.start()).rejects.toThrow("unknown");
|
|
65
|
+
else { await peer.start(); await expect(peer.setPermissionMode("never-ask")).rejects.toThrow("unknown"); }
|
|
66
|
+
expect(peer.permissionModeState).toBe("unknown"); expect(peer.state).toBe("offline");
|
|
67
|
+
expect(readFileSync(ack, "utf8").trim()).toBe(modeAcknowledgement);
|
|
68
|
+
const owned = Number(readFileSync(pid, "utf8")), table = processTable();
|
|
69
|
+
expect(table).toBeDefined(); expect(table!.some(row => row.pid === owned || row.pgid === owned)).toBe(false);
|
|
70
|
+
await expect(peer.setPermissionMode("ask")).rejects.toThrow("unknown");
|
|
71
|
+
await expect(peer.deliver([newEnvelope("user", "must not start")])).rejects.toThrow("offline");
|
|
72
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
73
|
+
}, 20_000);
|
|
74
|
+
},
|
|
75
|
+
"native-quota": kind => {
|
|
76
|
+
test(`P4 ${options.quotaLabel ?? kind}: confirmed pre-activity quota pause settles failed_safe, not an ordinary failure`, async () => {
|
|
77
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-quota-")), receipts: DeliveryReceipt[] = [], order: string[] = [], failures: string[] = [];
|
|
78
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fake, ...(options.agentArgs ?? []), "--quota-refusal"],
|
|
79
|
+
onQuotaRefusal: () => { order.push("paused"); return true; }, onTurnFailure: (_envs, reason) => { failures.push(reason); } } });
|
|
80
|
+
peer.onDelivery = receipt => { receipts.push(receipt); order.push(receipt.state); };
|
|
81
|
+
try {
|
|
82
|
+
await peer.start(); await peer.deliver([newEnvelope("user", "QUOTA_REFUSAL")], "quota");
|
|
83
|
+
await until(() => receipts.length > 0, "quota refusal receipt");
|
|
84
|
+
expect(order).toEqual(["paused", "failed_safe"]); expect(receipts[0]).toMatchObject({ id: "quota", state: "failed_safe", quota: true }); expect(failures).toEqual([]);
|
|
85
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
"watchdog-generation": kind => {
|
|
89
|
+
test(`P4 ${options.watchdogLabel ?? kind}: watchdog cancellation fences the consumed late native result by turn generation`, async () => {
|
|
90
|
+
const dir = mkdtempSync(join(tmpdir(), "conformance-watchdog-")), receipts: DeliveryReceipt[] = [], messages: string[] = [];
|
|
91
|
+
const peer = createFromDefinition<AcpPeer>(definition, { acp: { cwd: dir, cmd: [process.execPath, fakePaths.acpWatchdog, ...(options.agentArgs ?? [])] } });
|
|
92
|
+
peer.onDelivery = receipt => { receipts.push(receipt); }; peer.onMessage = text => { messages.push(text); };
|
|
93
|
+
try {
|
|
94
|
+
await peer.start(); await peer.deliver([newEnvelope("user", "ACK_SLOW")], "old");
|
|
95
|
+
await until(() => receipts.some(receipt => receipt.state === "accepted"), "native acceptance before explicit expiry");
|
|
96
|
+
expire(peer);
|
|
97
|
+
await until(() => receipts.some(receipt => receipt.state === "needs_review"), "watchdog uncertainty");
|
|
98
|
+
// The fake responds to this mode request after sending the old prompt result on the same stdio stream.
|
|
99
|
+
await peer.setPermissionMode("ask");
|
|
100
|
+
expect(receipts.map(receipt => receipt.state)).toEqual(["accepted", "needs_review"]);
|
|
101
|
+
expect(messages).toEqual([]); expect(peer.state).toBe("offline");
|
|
102
|
+
} finally { await peer.stop(); rmSync(dir, { recursive: true, force: true }); }
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
for (const leg of ACP_LEGS) {
|
|
108
|
+
const reason = options.unsupported?.[leg];
|
|
109
|
+
if (reason !== undefined) test(`P4 ${kind}: ${leg} explicitly unsupported by its native transport`, () => {
|
|
110
|
+
expect(reason.trim()).not.toBeEmpty();
|
|
111
|
+
});
|
|
112
|
+
else registrations[leg](kind);
|
|
113
|
+
}
|
|
114
|
+
}
|