@staix/agent-hub 0.19.0 → 0.21.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 +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { constants, closeSync, fstatSync, lstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { constants, closeSync, fstatSync, lstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
3
3
|
import { isBuiltin } from "node:module";
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { moduleDataObject as dataObject, moduleText as text, moduleArray as array } from "./external-metadata.ts";
|
|
@@ -18,6 +18,38 @@ const ADAPTER_IMPORT = "@staix/agent-hub/adapter";
|
|
|
18
18
|
class ExternalAdapterRefusal extends Error {}
|
|
19
19
|
const fail = (reason: string): never => { throw new ExternalAdapterRefusal(`external adapter: ${reason}`); };
|
|
20
20
|
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
21
|
+
export interface ExternalModuleIdentity {
|
|
22
|
+
readonly projectRoot: string;
|
|
23
|
+
readonly modulePath: string;
|
|
24
|
+
readonly sha256: string;
|
|
25
|
+
readonly api: 1;
|
|
26
|
+
readonly capabilityDigest: string;
|
|
27
|
+
}
|
|
28
|
+
const capturedIdentities = new WeakMap<AdapterDefinition, ExternalModuleIdentity>();
|
|
29
|
+
/** Only the verified startup loader can establish this provenance; display metadata and caller copies cannot. */
|
|
30
|
+
export function externalModuleIdentity(definition: AdapterDefinition): ExternalModuleIdentity | undefined {
|
|
31
|
+
return capturedIdentities.get(definition);
|
|
32
|
+
}
|
|
33
|
+
/** Revalidate captured provenance synchronously without evaluating module code or trusting definition getters. */
|
|
34
|
+
export function externalModuleIdentityCurrent(definition: AdapterDefinition): boolean {
|
|
35
|
+
const identity = capturedIdentities.get(definition);
|
|
36
|
+
if (!identity) return false;
|
|
37
|
+
try {
|
|
38
|
+
const root = identity.projectRoot, file = join(root, ".agenthub", "config.local.json");
|
|
39
|
+
if (realPath(root) !== root || configRefusal(root, "config.local.json")) return false;
|
|
40
|
+
const local = regularPath(root, file);
|
|
41
|
+
if ((local.mode & 0o002) !== 0) return false;
|
|
42
|
+
const source = readFileSync(file, "utf8");
|
|
43
|
+
const raw = dataObject(JSON.parse(source));
|
|
44
|
+
const { modules } = readAdapterModuleDeclarations(root, [{ name: "config.local.json", raw }]);
|
|
45
|
+
const matching = modules.filter(module => relative(root, resolve(root, module.path)).split(sep).join("/") === identity.modulePath);
|
|
46
|
+
if (matching.length !== 1 || matching[0]!.sha256 !== identity.sha256) return false;
|
|
47
|
+
captureBundle(root, matching[0]!);
|
|
48
|
+
const current = regularPath(root, file);
|
|
49
|
+
return current.dev === local.dev && current.ino === local.ino && current.mtimeMs === local.mtimeMs && current.ctimeMs === local.ctimeMs
|
|
50
|
+
&& (current.mode & 0o002) === 0 && readFileSync(file, "utf8") === source && !configRefusal(root, "config.local.json");
|
|
51
|
+
} catch { return false; }
|
|
52
|
+
}
|
|
21
53
|
export function adapterModuleDeclarations(raw: unknown): readonly AdapterModuleDeclaration[] {
|
|
22
54
|
if (raw === undefined) return Object.freeze([]);
|
|
23
55
|
return Object.freeze(array(raw, MAX_MODULES).map(value => {
|
|
@@ -113,6 +145,29 @@ function only(value: unknown, keys: readonly string[]): Record<string, unknown>
|
|
|
113
145
|
if (Object.keys(fields).some(key => !keys.includes(key))) return fail("field is outside the supported external definition subset");
|
|
114
146
|
return fields;
|
|
115
147
|
}
|
|
148
|
+
function headlessInstancePolicy(definition: AdapterDefinition): AdapterDefinition["headlessInstances"] {
|
|
149
|
+
if (!Object.hasOwn(definition, "headlessInstances")) return undefined;
|
|
150
|
+
const policy = only(definition.headlessInstances, ["form", "roles"]);
|
|
151
|
+
const roles = array(policy.roles, 3);
|
|
152
|
+
if (policy.form !== "headless" || !roles.length || new Set(roles).size !== roles.length
|
|
153
|
+
|| roles.some(role => typeof role !== "string" || !["implementer", "verifier", "reviewer"].includes(role) || !definition.roles.includes(role))) return fail("headless instance roles must fit the module role ceiling");
|
|
154
|
+
return Object.freeze({ form: "headless", roles: Object.freeze(roles as ("implementer" | "verifier" | "reviewer")[]) });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Canonical own-data serialization: never stringify callbacks or module-controlled toJSON methods. */
|
|
158
|
+
function capabilityDigest(definition: AdapterDefinition): string {
|
|
159
|
+
const canonical = (value: unknown): unknown => Array.isArray(value) ? value.map(canonical)
|
|
160
|
+
: value && typeof value === "object" ? Object.fromEntries(Object.entries(dataObject(value)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, member]) => [key, canonical(member)])) : value;
|
|
161
|
+
const ceiling = {
|
|
162
|
+
api: definition.api, kind: definition.kind, family: definition.family, transport: definition.transport,
|
|
163
|
+
roles: [...definition.roles].sort(), trust: definition.trust,
|
|
164
|
+
headlessInstances: definition.headlessInstances ? { form: "headless", roles: [...definition.headlessInstances.roles].sort() } : null,
|
|
165
|
+
start: { forms: definition.start.forms, defaultForm: definition.start.defaultForm, daemonPort: definition.start.daemonPort, conductorStartable: definition.start.conductorStartable },
|
|
166
|
+
permission: definition.permission, identity: definition.identity, turnEnd: definition.turnEnd,
|
|
167
|
+
session: { idField: definition.session.idField, resumeById: definition.session.resumeById, launchKind: definition.session.launchKind },
|
|
168
|
+
};
|
|
169
|
+
return digest(new TextEncoder().encode(JSON.stringify(canonical(ceiling))));
|
|
170
|
+
}
|
|
116
171
|
export function validateExternalDefinition(raw: unknown): AdapterDefinition {
|
|
117
172
|
const definition = snapshot(raw) as AdapterDefinition;
|
|
118
173
|
if (definition.api !== ADAPTER_API) return fail("adapter API mismatch");
|
|
@@ -123,9 +178,10 @@ export function validateExternalDefinition(raw: unknown): AdapterDefinition {
|
|
|
123
178
|
if (definition.family !== definition.kind || definition.trust?.onPrem !== false || definition.trust.hubSandboxed !== false
|
|
124
179
|
|| definition.defaultOnRoster !== false || definition.optional !== true) return fail("external kinds must retain their own family and optional off-roster cloud trust");
|
|
125
180
|
if (!Array.isArray(definition.roles) || !definition.roles.length || definition.roles.some(role => !["planner", "implementer", "verifier", "reviewer"].includes(role)) || new Set(definition.roles).size !== definition.roles.length) return fail("unsupported default roles");
|
|
126
|
-
if (definition
|
|
181
|
+
if (Object.hasOwn(definition, "instances")) return fail("external builtin instance policy is unsupported; use headlessInstances");
|
|
182
|
+
const instancePolicy = headlessInstancePolicy(definition);
|
|
127
183
|
try { assertNativeCapabilityScope(definition); } catch { return fail("fixed-builtin capability refused"); }
|
|
128
|
-
only(definition, ["api", "kind", "family", "displayName", "transport", "roles", "optional", "defaultOnRoster", "trust", "start", "identity", "permission", "session", "turnEnd", "cli", "display"]);
|
|
184
|
+
only(definition, ["api", "kind", "family", "displayName", "transport", "roles", "optional", "defaultOnRoster", "trust", "start", "identity", "permission", "session", "turnEnd", "cli", "display", "headlessInstances"]);
|
|
129
185
|
only(definition.trust, ["onPrem", "hubSandboxed"]);
|
|
130
186
|
const start = only(definition.start, ["forms", "defaultForm", "daemonPort", "launch", "create", "preflight", "version", "conductorStartable", "hubSwitches", "ownedFlags", "exclusiveStartRefusal"]);
|
|
131
187
|
if (definition.transport !== "acp" || definition.turnEnd !== "adapter" || definition.start.daemonPort !== "acp"
|
|
@@ -159,6 +215,7 @@ export function validateExternalDefinition(raw: unknown): AdapterDefinition {
|
|
|
159
215
|
try { return callback(); } catch { return fail(`${name} refused (details withheld)`); }
|
|
160
216
|
};
|
|
161
217
|
return snapshot({ ...definition,
|
|
218
|
+
...(instancePolicy ? { headlessInstances: instancePolicy, instances: { mode: "headless", roles: instancePolicy.roles } } : {}),
|
|
162
219
|
start: { ...definition.start,
|
|
163
220
|
create: (context: Parameters<NonNullable<AdapterDefinition["start"]["create"]>>[0]) => invoke("factory", () => definition.start.create!(context)),
|
|
164
221
|
launch: (context: Parameters<NonNullable<AdapterDefinition["start"]["launch"]>>[0]): LaunchPlan => invoke("launch", () => {
|
|
@@ -216,6 +273,7 @@ export async function loadExternalAdapterDefinitions(cwd: string, layers: readon
|
|
|
216
273
|
const definitions: AdapterDefinition[] = [];
|
|
217
274
|
for (const [index, declaration] of modules.entries()) {
|
|
218
275
|
try {
|
|
276
|
+
const projectRoot = realPath(cwd);
|
|
219
277
|
if (configRefusal(cwd, "config.local.json")) return fail("module configuration provenance changed");
|
|
220
278
|
const local = regularPath(realPath(cwd), join(realPath(cwd), ".agenthub", "config.local.json"));
|
|
221
279
|
if ((local.mode & 0o002) !== 0) return fail("world-writable module configuration refused");
|
|
@@ -228,6 +286,8 @@ export async function loadExternalAdapterDefinitions(cwd: string, layers: readon
|
|
|
228
286
|
const definition = validateExternalDefinition((imported as { definition?: unknown }).definition);
|
|
229
287
|
if ([...existingKinds].some(kind => definition.kind === kind || definition.kind.startsWith(`${kind}-`))) return fail("kind collides with a configured adapter namespace");
|
|
230
288
|
if (definitions.some(item => item.kind === definition.kind)) return fail("duplicate external kind");
|
|
289
|
+
capturedIdentities.set(definition, Object.freeze({ projectRoot, modulePath: relative(projectRoot, resolve(projectRoot, declaration.path)).split(sep).join("/"),
|
|
290
|
+
sha256: declaration.sha256, api: ADAPTER_API, capabilityDigest: capabilityDigest(definition) }));
|
|
231
291
|
definitions.push(definition);
|
|
232
292
|
} catch (error) {
|
|
233
293
|
const refusal = error instanceof ExternalAdapterRefusal ? error : new ExternalAdapterRefusal("external adapter: module could not be verified or loaded (details withheld)");
|
package/src/adapters/jcode.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdtempSync, op
|
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { isAbsolute, join } from "node:path";
|
|
7
7
|
import type { Readable } from "node:stream";
|
|
8
|
-
import { renderDigest, type Envelope, type PeerState } from "../hub/envelope.ts";
|
|
8
|
+
import { JCODE_DEFERRED_TOOLS_INSTRUCTION, renderDigest, type Envelope, type PeerState } from "../hub/envelope.ts";
|
|
9
9
|
import { AcpPeer, type AcpOptions } from "./acp.ts";
|
|
10
10
|
import { peerChildEnv, stopOwnedProcess, trackGroup } from "../hub/child-process.ts";
|
|
11
11
|
|
|
@@ -25,6 +25,10 @@ export function jcodeCommand(command: string[], kind: "serve" | "acp", socket: s
|
|
|
25
25
|
return [...command, kind, "--socket", socket, "--cwd", cwd, "--no-update", "--no-selfdev", ...(provider ? ["-p", provider] : []), ...(model ? ["--model", model] : []), ...(profile ? ["--provider-profile", profile] : []),
|
|
26
26
|
...(kind === "serve" ? ["--temporary-server", "--owner-pid", String(process.pid), "--server-name", `agent-hub-${peer}`] : ["--mcp-tools", "eager"])];
|
|
27
27
|
}
|
|
28
|
+
/** The session preamble of a Jcode peer: the deferred-tools guidance (#912), then the hub's role contract when there is one. Sent once, with the session's first prompt. */
|
|
29
|
+
export function jcodePreamble(roleContract?: string): string {
|
|
30
|
+
return roleContract ? `${JCODE_DEFERRED_TOOLS_INSTRUCTION}\n\n${roleContract}` : JCODE_DEFERRED_TOOLS_INSTRUCTION;
|
|
31
|
+
}
|
|
28
32
|
export interface JcodeTuiBinding { launchId: string; sessionId: string }
|
|
29
33
|
export type JcodeTuiState = { state: "waiting"; binding: JcodeTuiBinding } |
|
|
30
34
|
{ state: "ready"; binding: JcodeTuiBinding; launch: { cmd: string; args: string[]; env: NodeJS.ProcessEnv } };
|
|
@@ -201,7 +205,7 @@ export class JcodePeer extends AcpPeer {
|
|
|
201
205
|
const launchId = crypto.randomUUID();
|
|
202
206
|
// The ACP child gets peerChildEnv(this.id): the instance id, so its hub tools act as this peer and never as the base Jcode. The launch id
|
|
203
207
|
// goes with it: the daemon admits a hub-tools hello for this peer only when it carries the id of this peer's current owned launch (#368).
|
|
204
|
-
super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
|
|
208
|
+
super(peer, { ...jcode, cmd, env: { ...jcode.env, JCODE_HOME: jcode.home, JCODE_SOCKET: socket, AGENTHUB_LAUNCH_ID: launchId, ...JCODE_OWNED_ENV }, mcpServers: [], preamble: jcodePreamble(jcode.preamble), requireLoadSessionEcho: true, expectedAgent: { name: "jcode", version: jcodeVersionSupported } });
|
|
205
209
|
this.root = root; this.socket = socket; this.launchId = launchId;
|
|
206
210
|
}
|
|
207
211
|
|
|
@@ -9,7 +9,9 @@ import { CONDUCTOR_TOOL_NAMES, CONDUCTOR_TOOLS, TASK_TOOL_NAMES, TASK_TOOLS, pro
|
|
|
9
9
|
import { BasePeer } from "../hub/peers.ts";
|
|
10
10
|
import type { PublishResult } from "../hub/bus.ts";
|
|
11
11
|
import { profile, proxyEnv, type SandboxNetwork } from "../local/sandbox.ts";
|
|
12
|
-
import
|
|
12
|
+
import type { DocReadPort } from "../local/doc-read.ts";
|
|
13
|
+
import type { WebFetchPort } from "../local/web-fetch.ts";
|
|
14
|
+
import { DOC_READ_SCHEMA, WEB_FETCH_SCHEMA, runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
|
|
13
15
|
import type { Capture } from "../memory/capture.ts";
|
|
14
16
|
import type { ChatMessage, ChatResult, OmniRoute, ToolCall } from "../omniroute/client.ts";
|
|
15
17
|
import { safeModelLabel } from "../omniroute/usage.ts";
|
|
@@ -36,7 +38,7 @@ export interface LocalOptions {
|
|
|
36
38
|
turnId?: () => string | undefined;
|
|
37
39
|
onApprovalStop?: (reason: string) => void;
|
|
38
40
|
onTool?: (observation: ToolObservation, task?: string) => void;
|
|
39
|
-
tools: { deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
|
|
41
|
+
tools: { docRead?: DocReadPort; webFetch?: WebFetchPort; deny: string[]; permit: ToolContext["permit"]; shellExecutionCheck?: ToolContext["shellExecutionCheck"]; bashNetwork?: SandboxNetwork; readAllow?: string[]; readOnly?: string[] };
|
|
40
42
|
capture?: Capture;
|
|
41
43
|
/** Runs a hub task tool (hub_task_*, hub_review, hub_remember) as this peer. Absent = the tools are not offered. */
|
|
42
44
|
taskTool?: (name: string, args: Record<string, unknown>, turn: { pii: boolean }) => Promise<string>;
|
|
@@ -77,6 +79,8 @@ const system = (cwd: string, preamble = "") =>
|
|
|
77
79
|
/** Hub-native agent loop: the hub owns every model call, so it can choose the model per call (L2) and the gateway (L3). */
|
|
78
80
|
export class LocalPeer extends BasePeer {
|
|
79
81
|
readonly hubNative = true;
|
|
82
|
+
readonly deliveryOnly: boolean = true;
|
|
83
|
+
readonly hubOwned: boolean = true;
|
|
80
84
|
private readonly history: ChatMessage[] = [];
|
|
81
85
|
private readonly sessionId = `agent-hub-local-${randomUUID()}`;
|
|
82
86
|
private readonly routes: HubRouteRuntime;
|
|
@@ -109,7 +113,7 @@ export class LocalPeer extends BasePeer {
|
|
|
109
113
|
await this.requireBudget(envs, "model_calls");
|
|
110
114
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
111
115
|
if (signal.aborted) throw new Error("turn cancelled before model request");
|
|
112
|
-
const tools = judge ? undefined : [...
|
|
116
|
+
const tools = judge ? undefined : [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
113
117
|
const result = await this.opts.omni.chat({ model, messages, ...(tools ? { tools } : {}), ...(judge ? { max_tokens: maxTokens ?? 2048 } : {}) }, { signal, ...(pii ? { onCampusOnly: true } : {}) });
|
|
114
118
|
this.recordUsage(result, model, task);
|
|
115
119
|
if (generation !== this.turn) throw new Error("route belongs to an ended turn");
|
|
@@ -165,6 +169,8 @@ export class LocalPeer extends BasePeer {
|
|
|
165
169
|
const progress = { sideEffects: 0, last: "" };
|
|
166
170
|
const requestedPolicy = this.opts.turnPolicy?.(envs);
|
|
167
171
|
const policy = envs.some(env => env.private) ? { ...requestedPolicy, pii: true } : requestedPolicy;
|
|
172
|
+
const remoteTurn = envs.some(env => env.from.includes("@"));
|
|
173
|
+
const preserveHistory = !policy?.pii && !remoteTurn;
|
|
168
174
|
const labelTurn = this.beginRouteTurn(turn, !!policy?.pii || envs.some(env => env.private), policy?.task);
|
|
169
175
|
let completed = false;
|
|
170
176
|
// A PII turn speaks to the console user only, and privately: its answer must not be broadcast to cloud-hosted peers.
|
|
@@ -173,7 +179,7 @@ export class LocalPeer extends BasePeer {
|
|
|
173
179
|
this.run(envs, turn, msgs, progress, policy, reply)
|
|
174
180
|
.then((answer) => {
|
|
175
181
|
if (turn !== this.turn) return;
|
|
176
|
-
if (
|
|
182
|
+
if (preserveHistory) this.commit(msgs); // Private and remote turns never enter later local capture through model history.
|
|
177
183
|
if (answer) this.onMessage?.(answer, reply);
|
|
178
184
|
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed" });
|
|
179
185
|
completed = true;
|
|
@@ -181,31 +187,33 @@ export class LocalPeer extends BasePeer {
|
|
|
181
187
|
.catch((e: Error) => {
|
|
182
188
|
if (turn !== this.turn) return; // aborted by the watchdog or stop(): nothing to report, nothing was committed
|
|
183
189
|
if (e instanceof ApprovalWaitStop) {
|
|
190
|
+
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
184
191
|
this.completeMissingToolResults(msgs, "not run: turn ended after unanswered approvals");
|
|
185
|
-
msgs.push({ role: "assistant", content:
|
|
186
|
-
if (
|
|
187
|
-
this.onMessage?.(
|
|
188
|
-
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason:
|
|
192
|
+
msgs.push({ role: "assistant", content: message });
|
|
193
|
+
if (preserveHistory) this.commit(msgs);
|
|
194
|
+
this.onMessage?.(message, reply);
|
|
195
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: message });
|
|
189
196
|
return; // even without effects, an unanswered approval must not trigger safe replay
|
|
190
197
|
}
|
|
191
198
|
if (this.budgetStopReason && !(e instanceof ExecutionBudgetStop)) e = new ExecutionBudgetStop(this.budgetStopReason);
|
|
192
|
-
|
|
199
|
+
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
200
|
+
this.opts.log?.(`[${this.id}] turn failed: ${message}`);
|
|
193
201
|
if (e instanceof ExecutionBudgetStop) {
|
|
194
|
-
this.reportBudgetStop(e, msgs, progress, reply,
|
|
202
|
+
this.reportBudgetStop(e, msgs, progress, reply, !preserveHistory, deliveryId, remoteTurn);
|
|
195
203
|
return;
|
|
196
204
|
}
|
|
197
205
|
if (!progress.sideEffects) {
|
|
198
|
-
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason:
|
|
206
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "failed_safe", reason: message });
|
|
199
207
|
else this.onFailed?.(envs); // legacy delivery: safe to redeliver
|
|
200
208
|
return;
|
|
201
209
|
}
|
|
202
210
|
// Tools already changed things. Redelivering would redo approved writes and commits, so report instead.
|
|
203
211
|
// A model call is the only thing that throws here, and every tool call before it has its result: msgs is consistent.
|
|
204
|
-
const note = `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${
|
|
212
|
+
const note = remoteTurn ? message : `(turn failed after ${progress.sideEffects} tool call(s) with side effects: ${message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
|
|
205
213
|
msgs.push({ role: "assistant", content: note });
|
|
206
|
-
if (
|
|
214
|
+
if (preserveHistory) this.commit(msgs);
|
|
207
215
|
this.onMessage?.(note, reply);
|
|
208
|
-
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason:
|
|
216
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
|
|
209
217
|
})
|
|
210
218
|
.finally(() => {
|
|
211
219
|
this.endRouteTurn(labelTurn, completed ? "completed" : "failed");
|
|
@@ -235,8 +243,9 @@ export class LocalPeer extends BasePeer {
|
|
|
235
243
|
): Promise<string> {
|
|
236
244
|
const { maxSteps = 30 } = this.opts;
|
|
237
245
|
const turnSignal = this.abort!.signal;
|
|
238
|
-
//
|
|
239
|
-
const
|
|
246
|
+
// Remote text/tool output is never memory evidence, even beside ordinary local work in the same turn.
|
|
247
|
+
const remoteTurn = envs.some(env => env.from.includes("@"));
|
|
248
|
+
const capture = policy?.pii || remoteTurn ? undefined : this.opts.capture;
|
|
240
249
|
// Unknown gateway state is never permission to send PII.
|
|
241
250
|
if (policy?.pii && !(await this.opts.omni.onCampus())) {
|
|
242
251
|
return "Refused: this is a PII task and the only reachable gateway is off campus (Cloudflare Access). Connect the VPN and assign it again.";
|
|
@@ -279,9 +288,11 @@ export class LocalPeer extends BasePeer {
|
|
|
279
288
|
const result: ChatMessage = { role: "tool", tool_call_id: call.id, content: output, is_error: toolResultFailed(name, output) };
|
|
280
289
|
msgs.push(result); results.push(result);
|
|
281
290
|
if (this.approvalStopReason) { this.abort?.abort(); throw new ApprovalWaitStop(this.approvalStopReason); }
|
|
282
|
-
this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
291
|
+
if (!remoteTurn) this.observeTool(call, output, policy, `${this.sessionId}.${turn}.${step}`);
|
|
283
292
|
// Hub tool arguments/results are not capture evidence; Tasks owns its screened memory writes.
|
|
284
|
-
|
|
293
|
+
// doc_read text is converted document content (a converter can surface hidden text), never memory evidence: not captured, whatever the turn.
|
|
294
|
+
// web_fetch text is a third party's page and its URL is the model's own request: neither is memory evidence either (#515b).
|
|
295
|
+
if (name !== "hub_send" && name !== "doc_read" && name !== "web_fetch" && !TASK_TOOL_NAMES.has(name) && !CONDUCTOR_TOOL_NAMES.has(name)) capture?.observe({ tool: call.function.name, args: call.function.arguments, output, id: call.id, sourceId: `${this.sessionId}.${turn}.${step}.${captureInvocation++}`, paths: touchedPaths(call.function.name, safeParse(call.function.arguments)) });
|
|
285
296
|
}
|
|
286
297
|
} finally { if (results.length) this.routes.observeResults(res.routeDecision, res.message, results); }
|
|
287
298
|
}
|
|
@@ -289,9 +300,14 @@ export class LocalPeer extends BasePeer {
|
|
|
289
300
|
return `(stopped after ${maxSteps} steps) ${progress.last}`.trim();
|
|
290
301
|
}
|
|
291
302
|
|
|
303
|
+
/** The base tools, plus `doc_read` and `web_fetch` only when the project opted in (`local.doc_read`, `local.web_fetch.allow`; both off by default). */
|
|
304
|
+
private toolSchemas() { const extra = [...(this.opts.tools.docRead ? [DOC_READ_SCHEMA] : []), ...(this.opts.tools.webFetch ? [WEB_FETCH_SCHEMA] : [])]; return extra.length ? [...TOOL_SCHEMAS, ...extra] : TOOL_SCHEMAS; }
|
|
305
|
+
|
|
292
306
|
private toolContext(envs: Envelope[], turnSignal: AbortSignal, pii: boolean, reply: EnvelopeOpts): ToolContext {
|
|
293
307
|
const executionGeneration=this.turn;
|
|
294
308
|
return {
|
|
309
|
+
...(this.opts.tools.docRead ? { docRead: { ...this.opts.tools.docRead, pii } } : {}),
|
|
310
|
+
...(this.opts.tools.webFetch ? { webFetch: { ...this.opts.tools.webFetch, pii } } : {}),
|
|
295
311
|
cwd: this.opts.cwd,
|
|
296
312
|
deny: this.opts.tools.deny,
|
|
297
313
|
readOnly: this.opts.tools.readOnly,
|
|
@@ -337,15 +353,16 @@ export class LocalPeer extends BasePeer {
|
|
|
337
353
|
};
|
|
338
354
|
}
|
|
339
355
|
|
|
340
|
-
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts,
|
|
341
|
-
|
|
342
|
-
|
|
356
|
+
private reportBudgetStop(e: Error, msgs: ChatMessage[], progress: { sideEffects: number; last: string }, reply: EnvelopeOpts, privateHistory: boolean, deliveryId?: string, remoteTurn = false): void {
|
|
357
|
+
const message = remoteTurn ? "remote turn failed" : e.message;
|
|
358
|
+
this.completeMissingToolResults(msgs, `not run: ${message}`);
|
|
359
|
+
const detail = remoteTurn ? message : progress.sideEffects
|
|
343
360
|
? `(execution budget stopped after ${progress.sideEffects} tool call(s) with side effects; partial work may exist and needs review) ${progress.last}`.trim()
|
|
344
361
|
: `(execution budget stopped before any tool side effects; no work was repeated) ${progress.last}`.trim();
|
|
345
362
|
msgs.push({ role: "assistant", content: detail });
|
|
346
|
-
if (!
|
|
363
|
+
if (!privateHistory) this.commit(msgs);
|
|
347
364
|
this.onMessage?.(detail, reply);
|
|
348
|
-
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason:
|
|
365
|
+
if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "needs_review", reason: message });
|
|
349
366
|
}
|
|
350
367
|
|
|
351
368
|
private async requireBudget(envs: Envelope[], unit: "model_calls" | "tool_calls"): Promise<void> {
|
|
@@ -384,7 +401,7 @@ export class LocalPeer extends BasePeer {
|
|
|
384
401
|
}
|
|
385
402
|
const fixedModel = policy?.fixedModel ?? this.opts.fixedModel;
|
|
386
403
|
const task = policy?.task === undefined ? undefined : Number(policy.task);
|
|
387
|
-
const tools = [...
|
|
404
|
+
const tools = [...this.toolSchemas(), ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
|
|
388
405
|
const signal = this.abort!.signal;
|
|
389
406
|
const messages: ChatMessage[] = [{ role: "system", content: system(this.opts.cwd, this.opts.preamble) }, ...this.history, ...turnMsgs];
|
|
390
407
|
const routed = await this.hubCall(route, messages, policy, signal);
|
|
@@ -444,7 +461,7 @@ export class LocalPeer extends BasePeer {
|
|
|
444
461
|
}
|
|
445
462
|
|
|
446
463
|
private observeTool(call: ToolCall, output: string, policy: { pii: boolean; task?: string } | undefined, nativeTurn: string): void {
|
|
447
|
-
if (policy?.pii) return;
|
|
464
|
+
if (policy?.pii || call.function.name === "doc_read" || call.function.name === "web_fetch") return; // document and web text: no research observation carries it
|
|
448
465
|
try {
|
|
449
466
|
const command = safeParse(call.function.arguments).command;
|
|
450
467
|
this.opts.onTool?.({ name: call.function.name, ...(typeof command === "string" ? { command } : {}), resultText: output, isError: toolResultFailed(call.function.name, output), source: "local", turn: nativeTurn }, policy?.task);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PeerAdapter } from "../hub/peers.ts";
|
|
2
|
+
import { isFixedBuiltinDefinition, type AdapterDefinition } from "./registry.ts";
|
|
3
|
+
|
|
4
|
+
/** Presence is forbidden on a foreign runtime, including a member whose value is undefined. */
|
|
5
|
+
export function assertNativeProofPorts(definition: AdapterDefinition | undefined, product: PeerAdapter): void {
|
|
6
|
+
if (isFixedBuiltinDefinition(definition)) return;
|
|
7
|
+
for (const port of ["frontendProxy", "statusNotice", "revertTurn"] as const) {
|
|
8
|
+
if (port in product) throw new Error(`${definition?.kind ?? product.id}: ${port} is restricted to fixed builtin adapter products`);
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/adapters/pi.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { PiSessionBookmarkMismatch, validatePiSession } from "../pi/session.ts";
|
|
|
12
12
|
import { ApprovalWaitStop, toolResultFailed, type ApprovalProvenance } from "../local/tools.ts";
|
|
13
13
|
import { piToolStepCeiling, type PiToolStepCeiling } from "../pi/ceiling.ts";
|
|
14
14
|
import type { ExecutionBudgetDecision, ExecutionUnit } from "../hub/execution-budget.ts";
|
|
15
|
+
import type { StartForm } from "./registry-types.ts";
|
|
15
16
|
|
|
16
17
|
export interface PiModelDescriptor { id: string; name?: string; contextWindow?: number; maxTokens?: number; reasoning?: boolean; }
|
|
17
18
|
export interface PiToolSchema { name: string; description?: string; parameters: Record<string, unknown>; }
|
|
@@ -99,6 +100,7 @@ function ownerStillAlive(pid: number, signature: string | undefined): boolean {
|
|
|
99
100
|
|
|
100
101
|
export class PiPeer extends BasePeer {
|
|
101
102
|
readonly hubNative = true;
|
|
103
|
+
readonly hubOwned: boolean = true;
|
|
102
104
|
private proc?: ChildProcessWithoutNullStreams;
|
|
103
105
|
private server?: ReturnType<typeof Bun.serve>;
|
|
104
106
|
private buffer = "";
|
|
@@ -202,6 +204,10 @@ export class PiPeer extends BasePeer {
|
|
|
202
204
|
const file = this.sessionFile && (exists || !empty) ? this.sessionFile : undefined;
|
|
203
205
|
return { launch: { kind: "pi", cwd: this.opts.cwd, mode: this.opts.mode, backend: this.opts.backend, ...(this.opts.model ? { model: this.opts.model } : {}), ...(file ? { sessionFile: file } : {}) }, ...(this.sessionId ? { sessionId: this.sessionId } : {}), ...(file ? { sessionFile: file } : {}) };
|
|
204
206
|
}
|
|
207
|
+
get nativeSession(): string | undefined {
|
|
208
|
+
const session = this.recoveryMetadata().sessionId;
|
|
209
|
+
return typeof session === "string" ? session : undefined;
|
|
210
|
+
}
|
|
205
211
|
|
|
206
212
|
/** Verify the live source before choosing ID-only restoration. Never infer emptiness from a missing file. */
|
|
207
213
|
async captureResume(): Promise<Record<string, unknown>> {
|
|
@@ -502,6 +508,7 @@ export class PiPeer extends BasePeer {
|
|
|
502
508
|
return pending.map((call) => call.settled);
|
|
503
509
|
}
|
|
504
510
|
get mode(): "headless" | "tui" { return this.opts.mode; }
|
|
511
|
+
get form(): StartForm { return this.opts.mode; }
|
|
505
512
|
|
|
506
513
|
/** Withdraw hub admission without shutting down a person's Pi TUI or its bridge. */
|
|
507
514
|
async detachForRoster(): Promise<void> {
|
|
@@ -72,6 +72,8 @@ export interface CreateContext<Ports extends DaemonPorts = DaemonPorts> {
|
|
|
72
72
|
instance: string;
|
|
73
73
|
projectRoot: string;
|
|
74
74
|
stateDir: string;
|
|
75
|
+
/** Host-derived private external-instance state namespace; stateDir remains the canonical daemon control directory. */
|
|
76
|
+
instanceStateDir?: string;
|
|
75
77
|
kindConfig: unknown;
|
|
76
78
|
permission: PermissionMode | "read-only";
|
|
77
79
|
mcpServers: NonNullable<AcpOptions["mcpServers"]>;
|
|
@@ -207,6 +209,8 @@ export interface AdapterDefinition {
|
|
|
207
209
|
conductorStartArgs?(args: string[]): { mode?: StartForm } | Error;
|
|
208
210
|
};
|
|
209
211
|
identity: { mcp: "acp-session" | "project-file" | "env" | "channel-plugin"; currentChild?: true };
|
|
212
|
+
/** External module data-only opt-in. The verified loader derives the internal instances policy from it (#754). */
|
|
213
|
+
headlessInstances?: { form: "headless"; roles: readonly ("implementer" | "verifier" | "reviewer")[] };
|
|
210
214
|
/** Declaration admission only; execution-root and runtime owner proof remain host responsibilities. */
|
|
211
215
|
instances?: { mode: "external" | "headless"; roles?: readonly string[];
|
|
212
216
|
/** Each declared instance runs its own native data home (`jcode_home`) and may pass its own provider, model and profile names (#685). Fixed builtins only. */
|
|
@@ -156,6 +156,7 @@ const reviewTask = (env: Envelope): number | undefined => {
|
|
|
156
156
|
|
|
157
157
|
export class ReviewerPrintPeer extends BasePeer {
|
|
158
158
|
readonly hubNative = true;
|
|
159
|
+
readonly hubOwned: boolean = true;
|
|
159
160
|
private generation = 0;
|
|
160
161
|
private child: OwnedPrintChild | undefined;
|
|
161
162
|
private childCleanup: (() => Promise<void>) | undefined;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** Broker v1 control protocol. The broker verifies public signatures and never opens room ciphertext. */
|
|
2
|
+
import {
|
|
3
|
+
decodeBase64url, encodeBase64url, signBytes, verifyBytes,
|
|
4
|
+
type HubIdentity,
|
|
5
|
+
} from "../rooms/crypto.ts";
|
|
6
|
+
import {
|
|
7
|
+
brokerUrl, canonicalBytes, decodeRoster, publicRosterWire, verifyPublicRoster,
|
|
8
|
+
type OwnerPin, type VerifiedPublicRoster,
|
|
9
|
+
} from "../rooms/wire.ts";
|
|
10
|
+
|
|
11
|
+
export const BROKER_CONTROL_LIMITS = Object.freeze({ control: 1_048_576, challenge: 8192, nonceBytes: 32 });
|
|
12
|
+
export type BrokerProtocolCode = "invalid_wire" | "limit" | "untrusted" | "expired" | "authentication_failed";
|
|
13
|
+
export class BrokerProtocolError extends Error {
|
|
14
|
+
constructor(readonly code: BrokerProtocolCode) { super(`Broker protocol: ${code}`); this.name = "BrokerProtocolError"; }
|
|
15
|
+
}
|
|
16
|
+
function fail(code: BrokerProtocolCode = "invalid_wire"): never { throw new BrokerProtocolError(code); }
|
|
17
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
18
|
+
const FP = /^[0-9a-f]{64}$/;
|
|
19
|
+
const DIGEST = /^[A-Za-z0-9_-]{43}$/;
|
|
20
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
21
|
+
|
|
22
|
+
export interface BrokerChallenge {
|
|
23
|
+
v: 1;
|
|
24
|
+
type: "challenge";
|
|
25
|
+
room: string;
|
|
26
|
+
broker: string;
|
|
27
|
+
nonce: string;
|
|
28
|
+
expiresAt: number;
|
|
29
|
+
rosterDigest: string | null;
|
|
30
|
+
}
|
|
31
|
+
export interface BrokerAuth {
|
|
32
|
+
v: 1;
|
|
33
|
+
type: "auth";
|
|
34
|
+
member: string;
|
|
35
|
+
roster: string | null;
|
|
36
|
+
signature: string;
|
|
37
|
+
}
|
|
38
|
+
export interface BrokerRosterReady { v: 1; type: "roster_ready"; digest: string }
|
|
39
|
+
export interface BrokerReceived { v: 1; type: "received"; digest: string }
|
|
40
|
+
export type BrokerControl = BrokerAuth | BrokerRosterReady | BrokerReceived;
|
|
41
|
+
|
|
42
|
+
function record(x: unknown, keys: string[]): Record<string, unknown> {
|
|
43
|
+
if (typeof x !== "object" || x === null || Array.isArray(x) || Object.getPrototypeOf(x) !== Object.prototype) fail();
|
|
44
|
+
const value = x as Record<string, unknown>;
|
|
45
|
+
if (Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key))) fail();
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function canonicalDecode<T>(input: Uint8Array, max: number, parse: (value: unknown) => T): T {
|
|
49
|
+
if (!(input instanceof Uint8Array) || input.length === 0 || input.length > max) fail("limit");
|
|
50
|
+
let raw: unknown;
|
|
51
|
+
try { raw = JSON.parse(decoder.decode(input)); } catch { fail(); }
|
|
52
|
+
const value = parse(raw), bytes = canonicalBytes(value);
|
|
53
|
+
if (bytes.length !== input.length || bytes.some((byte, i) => input[i] !== byte)) fail();
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
function exactText(x: unknown, pattern: RegExp): string {
|
|
57
|
+
if (typeof x !== "string" || !pattern.test(x)) fail();
|
|
58
|
+
return x;
|
|
59
|
+
}
|
|
60
|
+
function roomId(x: unknown): string {
|
|
61
|
+
if (typeof x !== "string" || x.length !== 43 || !B64URL.test(x)) fail();
|
|
62
|
+
try { decodeBase64url(x, 32); } catch { fail(); }
|
|
63
|
+
return x;
|
|
64
|
+
}
|
|
65
|
+
function digestText(x: unknown): string {
|
|
66
|
+
const value = exactText(x, DIGEST);
|
|
67
|
+
try { decodeBase64url(value, 32); } catch { fail(); }
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
function timestamp(x: unknown): number {
|
|
71
|
+
if (typeof x !== "number" || !Number.isSafeInteger(x) || x <= 0) fail();
|
|
72
|
+
return x;
|
|
73
|
+
}
|
|
74
|
+
function nullableDigest(x: unknown): string | null { return x === null ? null : digestText(x); }
|
|
75
|
+
function brokerAudience(value: unknown): string {
|
|
76
|
+
try {
|
|
77
|
+
const normalized = brokerUrl(value), url = new URL(normalized);
|
|
78
|
+
if (url.pathname !== "/" || url.search) fail();
|
|
79
|
+
return normalized;
|
|
80
|
+
} catch { return fail(); }
|
|
81
|
+
}
|
|
82
|
+
function parseChallenge(x: unknown): BrokerChallenge {
|
|
83
|
+
const o = record(x, ["v", "type", "room", "broker", "nonce", "expiresAt", "rosterDigest"]);
|
|
84
|
+
if (o.v !== 1 || o.type !== "challenge") fail();
|
|
85
|
+
const nonce = exactText(o.nonce, B64URL);
|
|
86
|
+
try { decodeBase64url(nonce, BROKER_CONTROL_LIMITS.nonceBytes); } catch { fail(); }
|
|
87
|
+
return { v: 1, type: "challenge", room: roomId(o.room), broker: brokerAudience(o.broker), nonce,
|
|
88
|
+
expiresAt: timestamp(o.expiresAt), rosterDigest: nullableDigest(o.rosterDigest) };
|
|
89
|
+
}
|
|
90
|
+
function parseAuth(x: unknown): BrokerAuth {
|
|
91
|
+
const o = record(x, ["v", "type", "member", "roster", "signature"]);
|
|
92
|
+
if (o.v !== 1 || o.type !== "auth") fail();
|
|
93
|
+
let roster: string | null = null;
|
|
94
|
+
if (o.roster !== null) {
|
|
95
|
+
roster = exactText(o.roster, B64URL);
|
|
96
|
+
if (roster.length > Math.ceil(524288 * 4 / 3)) fail("limit");
|
|
97
|
+
let wire: Uint8Array;
|
|
98
|
+
try { wire = decodeBase64url(roster); } catch { fail(); }
|
|
99
|
+
decodeRoster(wire);
|
|
100
|
+
}
|
|
101
|
+
const signature = exactText(o.signature, B64URL);
|
|
102
|
+
try { decodeBase64url(signature, 64); } catch { fail(); }
|
|
103
|
+
return { v: 1, type: "auth", member: exactText(o.member, FP), roster, signature };
|
|
104
|
+
}
|
|
105
|
+
function parseControl(x: unknown): BrokerControl {
|
|
106
|
+
if (typeof x !== "object" || x === null || Array.isArray(x)) fail();
|
|
107
|
+
const type = (x as Record<string, unknown>).type;
|
|
108
|
+
if (type === "auth") return parseAuth(x);
|
|
109
|
+
if (type === "roster_ready" || type === "received") {
|
|
110
|
+
const o = record(x, ["v", "type", "digest"]);
|
|
111
|
+
if (o.v !== 1 || o.type !== type) fail();
|
|
112
|
+
return { v: 1, type: type as "roster_ready" | "received", digest: digestText(o.digest) };
|
|
113
|
+
}
|
|
114
|
+
fail();
|
|
115
|
+
}
|
|
116
|
+
function challengeBytes(challenge: BrokerChallenge, member: string, initialRosterDigest: string | null): Uint8Array {
|
|
117
|
+
return canonicalBytes(["agent-hub.rooms.broker.v1", "auth", challenge, member, initialRosterDigest]);
|
|
118
|
+
}
|
|
119
|
+
function validateChallenge(challenge: BrokerChallenge): BrokerChallenge {
|
|
120
|
+
return parseChallenge(challenge);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function decodeBrokerChallenge(input: Uint8Array): BrokerChallenge {
|
|
124
|
+
return canonicalDecode(input, BROKER_CONTROL_LIMITS.challenge, parseChallenge);
|
|
125
|
+
}
|
|
126
|
+
export function encodeBrokerChallenge(challenge: BrokerChallenge): Uint8Array {
|
|
127
|
+
return canonicalBytes(validateChallenge(challenge));
|
|
128
|
+
}
|
|
129
|
+
export function decodeBrokerControl(input: Uint8Array): BrokerControl {
|
|
130
|
+
return canonicalDecode(input, BROKER_CONTROL_LIMITS.control, parseControl);
|
|
131
|
+
}
|
|
132
|
+
export function encodeBrokerControl(control: BrokerControl): Uint8Array {
|
|
133
|
+
return canonicalBytes(parseControl(control));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function createBrokerAuth(input: {
|
|
137
|
+
challenge: BrokerChallenge;
|
|
138
|
+
identity: HubIdentity;
|
|
139
|
+
broker: string;
|
|
140
|
+
room: string;
|
|
141
|
+
initialRoster?: Uint8Array;
|
|
142
|
+
}): Promise<Uint8Array> {
|
|
143
|
+
const challenge = validateChallenge(input.challenge);
|
|
144
|
+
if (brokerAudience(input.broker) !== challenge.broker || roomId(input.room) !== challenge.room) fail("untrusted");
|
|
145
|
+
const member = input.identity.public.fingerprint;
|
|
146
|
+
if (!FP.test(member)) fail("untrusted");
|
|
147
|
+
let rosterWire: string | null = null;
|
|
148
|
+
let rosterDigest: string | null = null;
|
|
149
|
+
if (input.initialRoster !== undefined) {
|
|
150
|
+
if (challenge.rosterDigest !== null) fail("untrusted");
|
|
151
|
+
if (!(input.initialRoster instanceof Uint8Array)) fail();
|
|
152
|
+
const initialRoster = Uint8Array.from(input.initialRoster);
|
|
153
|
+
const decoded = decodeRoster(initialRoster);
|
|
154
|
+
const pin: OwnerPin = { room: decoded.room, owner: decoded.owner, minEpoch: "0" };
|
|
155
|
+
const proof = await verifyPublicRoster({ wire: initialRoster, pin });
|
|
156
|
+
if (proof.room !== challenge.room || proof.owner !== member || !proof.members.some(m => m.fingerprint === member)) fail("untrusted");
|
|
157
|
+
rosterWire = encodeBase64url(initialRoster);
|
|
158
|
+
rosterDigest = proof.digest;
|
|
159
|
+
} else {
|
|
160
|
+
if (challenge.rosterDigest === null) fail("untrusted");
|
|
161
|
+
rosterDigest = null;
|
|
162
|
+
}
|
|
163
|
+
const body = { v: 1 as const, type: "auth" as const, member, roster: rosterWire };
|
|
164
|
+
const auth = { ...body, signature: encodeBase64url(await signBytes(input.identity, challengeBytes(challenge, member, rosterDigest))) };
|
|
165
|
+
const wire = canonicalBytes(auth);
|
|
166
|
+
parseAuth(auth);
|
|
167
|
+
return wire;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Verify the challenge signature and resolve the public roster used for membership. */
|
|
171
|
+
export async function verifyBrokerAuth(input: {
|
|
172
|
+
wire: Uint8Array;
|
|
173
|
+
challenge: BrokerChallenge;
|
|
174
|
+
roster?: VerifiedPublicRoster;
|
|
175
|
+
}): Promise<{ member: string; roster: VerifiedPublicRoster; initial: boolean }> {
|
|
176
|
+
const challenge = validateChallenge(input.challenge);
|
|
177
|
+
const auth = canonicalDecode(input.wire, BROKER_CONTROL_LIMITS.control, parseAuth);
|
|
178
|
+
let roster: VerifiedPublicRoster;
|
|
179
|
+
let signedRosterDigest: string | null;
|
|
180
|
+
let initial: boolean;
|
|
181
|
+
if (challenge.rosterDigest === null) {
|
|
182
|
+
if (auth.roster === null || input.roster !== undefined) fail("untrusted");
|
|
183
|
+
let rosterWire: Uint8Array;
|
|
184
|
+
try { rosterWire = decodeBase64url(auth.roster); } catch { fail(); }
|
|
185
|
+
const decoded = decodeRoster(rosterWire);
|
|
186
|
+
roster = await verifyPublicRoster({ wire: rosterWire, pin: { room: decoded.room, owner: decoded.owner, minEpoch: "0" } });
|
|
187
|
+
if (roster.room !== challenge.room || roster.owner !== auth.member) fail("untrusted");
|
|
188
|
+
signedRosterDigest = roster.digest;
|
|
189
|
+
initial = true;
|
|
190
|
+
} else {
|
|
191
|
+
if (auth.roster !== null || !input.roster) fail("untrusted");
|
|
192
|
+
roster = input.roster;
|
|
193
|
+
publicRosterWire(roster);
|
|
194
|
+
if (roster.room !== challenge.room || roster.digest !== challenge.rosterDigest) fail("untrusted");
|
|
195
|
+
signedRosterDigest = null;
|
|
196
|
+
initial = false;
|
|
197
|
+
}
|
|
198
|
+
const member = roster.members.find(candidate => candidate.fingerprint === auth.member);
|
|
199
|
+
if (!member) fail("untrusted");
|
|
200
|
+
const signature = decodeBase64url(auth.signature, 64);
|
|
201
|
+
if (!await verifyBytes(member, challengeBytes(challenge, auth.member, signedRosterDigest), signature)) fail("authentication_failed");
|
|
202
|
+
return { member: auth.member, roster, initial };
|
|
203
|
+
}
|