@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.
Files changed (184) hide show
  1. package/CHANGELOG.md +70 -3
  2. package/README.md +2 -2
  3. package/docs/agent-notes/README.md +11 -0
  4. package/docs/agent-notes/adapters.md +10 -5
  5. package/docs/agent-notes/benchmarks.md +27 -0
  6. package/docs/agent-notes/budget.md +2 -1
  7. package/docs/agent-notes/bus.md +3 -3
  8. package/docs/agent-notes/daemon.md +20 -7
  9. package/docs/agent-notes/local-worker.md +5 -0
  10. package/docs/agent-notes/models.md +2 -0
  11. package/docs/agent-notes/tasks.md +20 -10
  12. package/docs/agent-notes/tests.md +13 -5
  13. package/docs/bench.md +1 -1
  14. package/docs/data-policy.md +2 -0
  15. package/docs/dogfood.md +41 -5
  16. package/docs/events.md +2 -1
  17. package/docs/memory.md +9 -3
  18. package/docs/operations.md +598 -63
  19. package/docs/profiles.md +13 -1
  20. package/docs/quickstart.md +18 -9
  21. package/docs/research.md +7 -4
  22. package/docs/sdlc.md +66 -10
  23. package/docs/security.md +397 -35
  24. package/docs/smoke.md +127 -0
  25. package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
  26. package/docs/verification/2026-10-27-0.19.0.md +5 -5
  27. package/docs/verification/2026-10-27-0.20.0.md +75 -0
  28. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
  29. package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
  30. package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
  31. package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
  32. package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
  33. package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
  34. package/docs/verification/2026-10-29-0.21.0.md +61 -0
  35. package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
  36. package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
  37. package/docs/verification/claude-implementer-permission-triage.ts +160 -0
  38. package/docs/verification/claude-permission-transport-native.py +71 -14
  39. package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
  40. package/docs/verified.json +26 -18
  41. package/package.json +2 -2
  42. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  43. package/plugins/agent-hub/server.js +1116 -284
  44. package/src/adapters/acp.ts +87 -3
  45. package/src/adapters/channel-peer.ts +11 -5
  46. package/src/adapters/claude-channel.ts +13 -6
  47. package/src/adapters/claude-implementer.ts +61 -7
  48. package/src/adapters/codex-appserver.ts +88 -5
  49. package/src/adapters/conformance/enrollment.ts +4 -4
  50. package/src/adapters/conformance/factories.ts +2 -1
  51. package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
  52. package/src/adapters/conformance/kit.ts +10 -6
  53. package/src/adapters/definitions/builtin-metadata.ts +3 -114
  54. package/src/adapters/definitions/native-config-defaults.ts +1 -1
  55. package/src/adapters/definitions/native-config-policy.ts +3 -2
  56. package/src/adapters/definitions/native-context.ts +12 -0
  57. package/src/adapters/external-metadata.ts +48 -0
  58. package/src/adapters/external-modules.ts +63 -3
  59. package/src/adapters/jcode.ts +6 -2
  60. package/src/adapters/local-worker.ts +42 -25
  61. package/src/adapters/native-proof-ports.ts +10 -0
  62. package/src/adapters/pi.ts +7 -0
  63. package/src/adapters/registry-types.ts +4 -0
  64. package/src/adapters/reviewer-print.ts +1 -0
  65. package/src/broker/protocol.ts +203 -0
  66. package/src/broker/server.ts +314 -0
  67. package/src/broker/store.ts +548 -0
  68. package/src/cli/bench.ts +3 -2
  69. package/src/cli/broker.ts +58 -0
  70. package/src/cli/conductor-recovery.ts +142 -0
  71. package/src/cli/configured-acp-entry.ts +26 -6
  72. package/src/cli/console-state.ts +59 -17
  73. package/src/cli/console.ts +17 -2
  74. package/src/cli/facts-hook.ts +3 -1
  75. package/src/cli/help.ts +28 -16
  76. package/src/cli/identity.ts +23 -10
  77. package/src/cli/init.ts +34 -7
  78. package/src/cli/intake-output.ts +33 -0
  79. package/src/cli/main.ts +314 -71
  80. package/src/cli/output.ts +41 -1
  81. package/src/cli/recovery-package.ts +18 -1
  82. package/src/cli/role-holders.ts +35 -0
  83. package/src/cli/room.ts +304 -0
  84. package/src/cli/say-input.ts +16 -0
  85. package/src/cli/schedule-output.ts +49 -0
  86. package/src/cli/sdlc-output.ts +44 -6
  87. package/src/cli/smoke-pty-bridge.ts +33 -0
  88. package/src/cli/smoke-pty.ts +145 -0
  89. package/src/cli/smoke-run.ts +139 -0
  90. package/src/cli/smoke-target.ts +130 -0
  91. package/src/cli/source-output.ts +29 -0
  92. package/src/cli/task-output.ts +3 -2
  93. package/src/cli/upgrade-runtime.ts +98 -16
  94. package/src/cli/upgrade.ts +66 -7
  95. package/src/hub/ask.ts +76 -12
  96. package/src/hub/board.ts +132 -22
  97. package/src/hub/bus.ts +399 -56
  98. package/src/hub/child-process.ts +2 -0
  99. package/src/hub/ci-watch.ts +162 -0
  100. package/src/hub/claude-failure-stage.ts +85 -0
  101. package/src/hub/claude-launch-proof.ts +67 -2
  102. package/src/hub/claude-shell-wrapper.ts +11 -5
  103. package/src/hub/conductor.ts +17 -4
  104. package/src/hub/config-apply.ts +32 -5
  105. package/src/hub/config-reload.ts +7 -5
  106. package/src/hub/config-trust.ts +8 -1
  107. package/src/hub/context-window.ts +33 -3
  108. package/src/hub/control-client.ts +14 -2
  109. package/src/hub/corpus-config.ts +34 -0
  110. package/src/hub/corpus-evidence.ts +184 -0
  111. package/src/hub/corpus.ts +373 -0
  112. package/src/hub/cost-governance.ts +26 -0
  113. package/src/hub/daemon.ts +1228 -242
  114. package/src/hub/deliverable-files.ts +8 -2
  115. package/src/hub/delivery-journal.ts +123 -17
  116. package/src/hub/dogfood-profile.ts +32 -5
  117. package/src/hub/dogfood-read.ts +6 -2
  118. package/src/hub/envelope.ts +11 -0
  119. package/src/hub/eval-gate.ts +131 -0
  120. package/src/hub/events.ts +1 -1
  121. package/src/hub/external-instance.ts +69 -0
  122. package/src/hub/facts.ts +15 -5
  123. package/src/hub/folder-trigger.ts +223 -0
  124. package/src/hub/hub-tools.ts +2 -1
  125. package/src/hub/inference.ts +20 -5
  126. package/src/hub/instance-config.ts +69 -3
  127. package/src/hub/instance-execution.ts +6 -2
  128. package/src/hub/instances.ts +32 -4
  129. package/src/hub/intake.ts +125 -0
  130. package/src/hub/learning.ts +85 -0
  131. package/src/hub/lifecycle.ts +7 -3
  132. package/src/hub/limits.ts +12 -5
  133. package/src/hub/native-permission-request.ts +77 -0
  134. package/src/hub/operational-authority.ts +63 -5
  135. package/src/hub/operational-settings.ts +76 -0
  136. package/src/hub/operational-view.ts +1 -1
  137. package/src/hub/packs.ts +132 -0
  138. package/src/hub/path-policy.ts +7 -0
  139. package/src/hub/peer-doing.ts +130 -0
  140. package/src/hub/peers.ts +46 -0
  141. package/src/hub/queue-backpressure.ts +58 -0
  142. package/src/hub/read-tools.ts +2 -0
  143. package/src/hub/recovery-authority.ts +151 -0
  144. package/src/hub/recovery-policy.ts +220 -0
  145. package/src/hub/research.ts +24 -14
  146. package/src/hub/restart.ts +7 -0
  147. package/src/hub/routing.ts +54 -11
  148. package/src/hub/schedules.ts +266 -0
  149. package/src/hub/sdlc-merge.ts +38 -14
  150. package/src/hub/sdlc-metrics.ts +32 -10
  151. package/src/hub/sdlc-queue.ts +7 -1
  152. package/src/hub/sdlc-release.ts +1 -1
  153. package/src/hub/sdlc.ts +37 -7
  154. package/src/hub/security-baseline.ts +38 -0
  155. package/src/hub/settings.ts +7 -1
  156. package/src/hub/shipped-packs.ts +8 -0
  157. package/src/hub/snapshots.ts +72 -6
  158. package/src/hub/source-read.ts +160 -0
  159. package/src/hub/sources.ts +311 -0
  160. package/src/hub/task-shell-default.ts +3 -2
  161. package/src/hub/tasks.ts +1047 -115
  162. package/src/hub/workflow.ts +1 -1
  163. package/src/local/deny.ts +21 -0
  164. package/src/local/doc-read.ts +69 -0
  165. package/src/local/proxy.ts +4 -13
  166. package/src/local/sandbox.ts +3 -1
  167. package/src/local/tools.ts +37 -3
  168. package/src/local/web-fetch-config.ts +43 -0
  169. package/src/local/web-fetch.ts +183 -0
  170. package/src/memory/brief.ts +85 -13
  171. package/src/memory/capture.ts +1 -1
  172. package/src/rooms/application.ts +114 -0
  173. package/src/rooms/boundary.ts +103 -0
  174. package/src/rooms/commands.ts +294 -0
  175. package/src/rooms/crypto.ts +371 -0
  176. package/src/rooms/link.ts +584 -0
  177. package/src/rooms/policy.ts +82 -0
  178. package/src/rooms/remote-peer.ts +80 -0
  179. package/src/rooms/service.ts +426 -0
  180. package/src/rooms/status.ts +146 -0
  181. package/src/rooms/store.ts +1210 -0
  182. package/src/rooms/wire.ts +465 -0
  183. package/src/ui/index.html +91 -4
  184. 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.instances !== undefined) return fail("external adapter instances are unsupported in A4");
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)");
@@ -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 { runTool, toolResultFailed, TOOL_SCHEMAS, touchedPaths, ApprovalWaitStop, type ToolApproval, type ApprovalProvenance, type ToolContext } from "../local/tools.ts";
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 : [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
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 (!policy?.pii) this.commit(msgs); // a PII turn leaves nothing in the history later turns send along
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: e.message });
186
- if (!policy?.pii) this.commit(msgs);
187
- this.onMessage?.(e.message, reply);
188
- if (deliveryId && this.activeDeliveryId === deliveryId) this.delivery({ id: deliveryId, state: "completed", reason: e.message });
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
- this.opts.log?.(`[${this.id}] turn failed: ${e.message}`);
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, !!policy?.pii, deliveryId);
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: e.message });
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: ${e.message.slice(0, 200)}. The work may be partial; check before repeating it.) ${progress.last}`.trim();
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 (!policy?.pii) this.commit(msgs);
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: e.message });
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
- // Local capture preserves the host privacy fence: nothing of a PII turn is recorded.
239
- const capture = policy?.pii ? undefined : this.opts.capture;
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
- if (name !== "hub_send" && !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)) });
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, pii: boolean, deliveryId?: string): void {
341
- this.completeMissingToolResults(msgs, `not run: ${e.message}`);
342
- const detail = progress.sideEffects
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 (!pii) this.commit(msgs);
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: e.message });
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 = [...TOOL_SCHEMAS, ...(this.opts.taskTool ? [...profileTaskTools(this.opts.profile, this.opts.sdlcStages, this.opts.sdlcCriteria).filter(tool => tool.name !== "hub_delivery_retry"), ...CONDUCTOR_TOOLS].map(asFunction) : [])];
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
+ }
@@ -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
+ }