@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
package/src/memory/brief.ts
CHANGED
|
@@ -19,10 +19,14 @@ export function parseRows(markdown: string | undefined): BriefItem[] {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/** Index first, then the neighbourhood of the top hit: the retrieval both task briefs and `ahub ask` use. Deduplicated, in order. */
|
|
22
|
-
export async function related(client: MemoryClient, project: string, query: string, around = 3): Promise<BriefItem[]> {
|
|
23
|
-
const
|
|
22
|
+
export async function related(client: MemoryClient, project: string, query: string, around = 3, current?: () => boolean): Promise<BriefItem[]> {
|
|
23
|
+
const search = await client.search(query.slice(0, 300), project, 10);
|
|
24
|
+
if (current && !current()) throw new Error("brief context changed");
|
|
25
|
+
const hits = parseRows(search);
|
|
24
26
|
if (!hits.length) return [];
|
|
25
|
-
const
|
|
27
|
+
const timeline = await client.timeline(hits[0]!.id, project, around, around);
|
|
28
|
+
if (current && !current()) throw new Error("brief context changed");
|
|
29
|
+
const near = parseRows(timeline);
|
|
26
30
|
const out: BriefItem[] = [];
|
|
27
31
|
for (const item of [...hits, ...near]) if (!out.some((o) => o.id === item.id)) out.push(item);
|
|
28
32
|
return out;
|
|
@@ -34,7 +38,14 @@ export type NativeBrief = (task: BriefTask) => NativeItem[] | Promise<NativeItem
|
|
|
34
38
|
export interface IntentBriefContext { source: IntentBriefSource; options: IntentBriefOptions }
|
|
35
39
|
/** Host-owned policy and store binding; the library owns relevance, ordering and text. */
|
|
36
40
|
export type IntentBriefHost = (task: BriefTask) => IntentBriefContext | Promise<IntentBriefContext>;
|
|
37
|
-
export interface BriefTask { id?: number; created?: number; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
|
|
41
|
+
export interface BriefTask { id?: number; created?: number; updated?: number; owner?: string | null; history?: readonly unknown[]; title: string; refs: { paths?: string[] }; plan?: { paths?: string[]; symbols?: string[] } }
|
|
42
|
+
|
|
43
|
+
/** Host-authorized corpus reads. The port screens and sanitizes its returned public note lines. */
|
|
44
|
+
export interface CorpusBriefPort {
|
|
45
|
+
allowed(task: BriefTask): boolean;
|
|
46
|
+
policyVersion(): string;
|
|
47
|
+
read(task: BriefTask): NativeItem[] | Promise<NativeItem[]>;
|
|
48
|
+
}
|
|
38
49
|
|
|
39
50
|
/**
|
|
40
51
|
* Production task briefs use the library's intent retrieval and rendering with host policy and bindings.
|
|
@@ -55,23 +66,49 @@ export class Briefs {
|
|
|
55
66
|
private readonly observe?: (peer: PeerId, task: number, counts: { native: number; fails: number; claudeMem: number }) => void,
|
|
56
67
|
private readonly displayNative?: (items: readonly NativeItem[]) => string[],
|
|
57
68
|
private readonly intent?: IntentBriefHost,
|
|
69
|
+
private readonly corpus?: CorpusBriefPort,
|
|
58
70
|
) {}
|
|
59
71
|
|
|
60
72
|
async forTask(peer: PeerId, task: BriefTask, priority: readonly NativeItem[] = []): Promise<string | undefined> {
|
|
61
73
|
// A brief snapshots its configured cap once, before either optional source is selected.
|
|
62
74
|
const maximum = typeof this.maxItems === "function" ? this.maxItems() : this.maxItems;
|
|
75
|
+
let corpusRevision: string | undefined;
|
|
76
|
+
const allowed = (): boolean => {
|
|
77
|
+
if (!this.corpus) return true;
|
|
78
|
+
try { return this.corpus.allowed(task); } catch { return false; }
|
|
79
|
+
};
|
|
80
|
+
const current = (): boolean => {
|
|
81
|
+
if (!this.corpus) return true;
|
|
82
|
+
try { return allowed() && this.corpus.policyVersion() === corpusRevision; } catch { return false; }
|
|
83
|
+
};
|
|
84
|
+
if (this.corpus) {
|
|
85
|
+
if (!allowed()) return undefined;
|
|
86
|
+
try { corpusRevision = this.corpus.policyVersion(); } catch { return undefined; }
|
|
87
|
+
if (!current()) return undefined;
|
|
88
|
+
}
|
|
89
|
+
const corpusItems = async (room: number): Promise<NativeItem[] | undefined> => {
|
|
90
|
+
if (!this.corpus || room < 1) return [];
|
|
91
|
+
if (!current()) return undefined;
|
|
92
|
+
try {
|
|
93
|
+
const items = await this.corpus.read(task);
|
|
94
|
+
if (!current()) return undefined;
|
|
95
|
+
return items;
|
|
96
|
+
} catch { return current() ? [] : undefined; }
|
|
97
|
+
};
|
|
63
98
|
if (this.intent) {
|
|
64
99
|
// Serialize only this peer's library reads, so awaited policy cannot duplicate a delivered note.
|
|
65
100
|
const previous = this.libraryRuns.get(peer);
|
|
66
101
|
const run = (async () => {
|
|
67
102
|
await previous?.catch(() => undefined);
|
|
103
|
+
if (!current()) return undefined;
|
|
68
104
|
if (maximum < 1) return undefined;
|
|
69
105
|
try {
|
|
70
106
|
const context = await this.intent!(task);
|
|
107
|
+
if (!current()) return undefined;
|
|
71
108
|
const seen = this.seen.get(peer) ?? new Set<string>();
|
|
72
|
-
this.seen.set(peer, seen);
|
|
73
109
|
const revision = context.options.policyVersion();
|
|
74
110
|
const result = await buildIntentBrief(context.source, context.options);
|
|
111
|
+
if (!current()) return undefined;
|
|
75
112
|
if (result.state === "unknown" || context.options.allowed?.() === false || context.options.policyVersion() !== revision) return undefined;
|
|
76
113
|
context.options.assertCurrent?.();
|
|
77
114
|
const lines = result.text.split("\n");
|
|
@@ -84,28 +121,49 @@ export class Briefs {
|
|
|
84
121
|
const id = `mem:${item.id}`;
|
|
85
122
|
if (selected.length < maximum && !seen.has(id) && !selected.some(row => row.id === id)) selected.push({ id, line: rendered.get(id)!, kind: item.kind });
|
|
86
123
|
}
|
|
124
|
+
const corpus = await corpusItems(maximum - selected.length);
|
|
125
|
+
if (corpus === undefined || !current()) return undefined;
|
|
126
|
+
for (const item of corpus) if (selected.length < maximum && !seen.has(item.id) && !selected.some(row => row.id === item.id)) selected.push(item);
|
|
87
127
|
if (!selected.length) return undefined;
|
|
88
128
|
// Preserve complete library rows (including graph neighbours after a seen direct bridge).
|
|
89
|
-
|
|
129
|
+
if (!current()) return undefined;
|
|
130
|
+
const header = selected.some(item => item.kind === "note") ? "Knowledge brief (advice, not a block):" : lines[0] || "Live fail notes for this plan (advice, not a block):";
|
|
131
|
+
const text = [header, ...selected.map(item => item.line)].join("\n");
|
|
132
|
+
if (!current()) return undefined;
|
|
133
|
+
this.seen.set(peer, seen);
|
|
90
134
|
for (const item of selected) seen.add(item.id);
|
|
91
|
-
if (
|
|
135
|
+
if (!current()) return undefined;
|
|
136
|
+
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: selected.filter(item => item.kind !== "note").length, fails: selected.filter(item => item.kind === "fail").length, claudeMem: 0 }); } catch { /* telemetry never fails a brief */ }
|
|
92
137
|
return text;
|
|
93
138
|
} catch { return undefined; }
|
|
94
139
|
})();
|
|
95
140
|
this.libraryRuns.set(peer, run);
|
|
96
141
|
try { return await run; } finally { if (this.libraryRuns.get(peer) === run) this.libraryRuns.delete(peer); }
|
|
97
142
|
}
|
|
143
|
+
if (!current()) return undefined;
|
|
98
144
|
const seen = this.seen.get(peer) ?? new Set<string>();
|
|
145
|
+
// Publish an empty peer set before the first await so concurrent briefs share dedupe state.
|
|
146
|
+
// No ids enter it until the final policy guard, so an invalidated read cannot poison delivery.
|
|
99
147
|
this.seen.set(peer, seen);
|
|
100
148
|
let native: NativeItem[] = [];
|
|
101
|
-
try {
|
|
149
|
+
try {
|
|
150
|
+
if (!current()) return undefined;
|
|
151
|
+
native = await (this.native?.(task) ?? []);
|
|
152
|
+
if (!current()) return undefined;
|
|
153
|
+
} catch { if (!current()) return undefined; /* fail-open: the store and derived code context are optional */ }
|
|
102
154
|
const own: NativeItem[] = [];
|
|
103
155
|
// A plan fail is task-specific guidance even when a previous task already carried it.
|
|
104
156
|
const priorityIds = new Set(priority.map(item => item.id));
|
|
105
157
|
for (const item of priority) if (own.length < maximum && !own.some(existing => existing.id === item.id)) own.push(item);
|
|
106
158
|
// Native order is host-supplied (linked fails, then nearer/newer); preserve it for synchronous callers too.
|
|
107
159
|
try { for (const item of native) if (!seen.has(item.id) && !own.some(existing => existing.id === item.id) && own.length < maximum) own.push(item); } catch { /* optional native iteration stays fail-open */ }
|
|
108
|
-
|
|
160
|
+
let found: BriefItem[] = [];
|
|
161
|
+
if (this.client && own.length < maximum) {
|
|
162
|
+
if (!current()) return undefined;
|
|
163
|
+
try { found = await related(this.client, this.project, [task.title, ...(task.refs.paths ?? [])].join(" "), 3, current); }
|
|
164
|
+
catch { if (!current()) return undefined; }
|
|
165
|
+
if (!current()) return undefined;
|
|
166
|
+
}
|
|
109
167
|
// Another brief for this peer may finish while either optional source is awaited.
|
|
110
168
|
const currentOwn = own.filter(item => priorityIds.has(item.id) || !seen.has(item.id)).slice(0, maximum);
|
|
111
169
|
own.splice(0, own.length, ...currentOwn);
|
|
@@ -116,12 +174,26 @@ export class Briefs {
|
|
|
116
174
|
items.push(item);
|
|
117
175
|
if (own.length + items.length === maximum) break;
|
|
118
176
|
}
|
|
177
|
+
const corpus = await corpusItems(maximum - own.length - items.length);
|
|
178
|
+
if (corpus === undefined || !current()) return undefined;
|
|
179
|
+
const corpusRows: NativeItem[] = [];
|
|
180
|
+
for (const item of corpus) {
|
|
181
|
+
if (own.length + items.length + corpusRows.length >= maximum) break;
|
|
182
|
+
if (seen.has(item.id) || own.some(row => row.id === item.id) || corpusRows.some(row => row.id === item.id)) continue;
|
|
183
|
+
corpusRows.push(item);
|
|
184
|
+
}
|
|
185
|
+
if (!current()) return undefined;
|
|
119
186
|
const notice = this.client?.readNotice();
|
|
120
|
-
if (!own.length && !items.length && !notice) return undefined;
|
|
187
|
+
if (!own.length && !items.length && !corpusRows.length && !notice) return undefined;
|
|
188
|
+
if (!current()) return undefined;
|
|
121
189
|
for (const item of own) seen.add(item.id);
|
|
122
190
|
for (const item of items) seen.add(`#${item.id}`);
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
191
|
+
for (const item of corpusRows) seen.add(item.id);
|
|
192
|
+
if (!current()) return undefined;
|
|
193
|
+
if (task.id !== undefined) try { this.observe?.(peer, task.id, { native: own.filter(item => item.kind !== "note").length, fails: own.filter((i) => i.kind === "fail").length, claudeMem: items.length }); } catch { /* telemetry never fails a brief */ }
|
|
194
|
+
if (!current()) return undefined;
|
|
195
|
+
const nativeRows = own.filter(item => item.kind !== "note"), noteRows = [...own.filter(item => item.kind === "note"), ...corpusRows];
|
|
196
|
+
const header = noteRows.length ? "Knowledge brief (advice, not a block):" : own.length ? "Memory brief (hub memory first, fails first: ahub memory show <mem id>; amem by mem:id):" : "Memory brief (amem curated notes; fetch details by mem:id):";
|
|
197
|
+
return [header, ...(notice ? [notice] : []), ...(this.displayNative?.(nativeRows) ?? nativeRows.map((i) => i.line)), ...items.map((i) => `${i.id.toString().startsWith("mem:") ? i.id : `#${i.id}`} ${i.time} ${i.type} ${i.title}`), ...noteRows.map(item => item.line)].join("\n");
|
|
126
198
|
}
|
|
127
199
|
}
|
package/src/memory/capture.ts
CHANGED
|
@@ -38,7 +38,7 @@ export class Capture {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
observe(call: { tool: string; args: string; output: string; id: string; paths: string[]; sourceId?: string }): void {
|
|
41
|
-
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || this.opts.skip?.includes(call.tool)) return;
|
|
41
|
+
if (!this.sessionId || ((MEMORY_READ_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, "")) || BOUNDED_TASK_TOOLS.has(call.tool.replace(/^mcp__[^ ]+__/, ""))) || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_memory_correct") || call.tool.replace(/^mcp__[^ ]+__/, "") === "hub_source_read" || this.opts.skip?.includes(call.tool)) return;
|
|
42
42
|
// Anything that names a denylisted path is kept out of memory entirely, input and output alike.
|
|
43
43
|
if (call.paths.some((p) => isDenied(p, this.opts.deny) || p.split(/\s+/).some((word) => isDenied(word, this.opts.deny)))) return;
|
|
44
44
|
// ponytail: legacy callers without a host sourceId dedupe native ids within a summary interval; all live LocalPeer calls supply invocation identity. Require that port for other hosts.
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Encrypted X4 application payloads. Transport signatures/epochs remain in wire.ts. */
|
|
2
|
+
import { MAX_HOP, type Priority } from "../hub/envelope.ts";
|
|
3
|
+
import { decodeBase64url } from "./crypto.ts";
|
|
4
|
+
import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
|
|
5
|
+
|
|
6
|
+
export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
|
|
7
|
+
export interface RoomChatEnvelope {
|
|
8
|
+
id: string; trace: string; hop: number; from: string; to: string;
|
|
9
|
+
kind: "chat" | "status"; priority: Priority; body: string; ts: number;
|
|
10
|
+
}
|
|
11
|
+
export type RoomReceiptReason = "admitted" | "inbox_admitted" | "inbox_full" | "inbox_oversize" | "inbox_expired" | "inbox_age_unavailable" | "not_exposed" | "boundary_refused" | "invalid_message" | "rate_limited" | "hop" | "person_inbox_unsupported";
|
|
12
|
+
export type RoomMessage =
|
|
13
|
+
| { v: 1; type: "chat"; envelope: RoomChatEnvelope }
|
|
14
|
+
| { v: 1; type: "receipt"; frame: string; application: string; peer: string; outcome: "completed" | "failed_safe"; reason: RoomReceiptReason };
|
|
15
|
+
export class RoomApplicationError extends Error {
|
|
16
|
+
constructor(readonly code: "invalid_message" | "limit") { super(`Room application: ${code}`); this.name = "RoomApplicationError"; }
|
|
17
|
+
}
|
|
18
|
+
function fail(code: "invalid_message" | "limit" = "invalid_message"): never { throw new RoomApplicationError(code); }
|
|
19
|
+
const LOCAL_ID = /^[a-z][a-z0-9-]{0,31}$/;
|
|
20
|
+
const REMOTE_ID = /^([a-z][a-z0-9-]{0,31})@([a-z][a-z0-9-]{0,31})$/;
|
|
21
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
22
|
+
const REASON_TEXT: Readonly<Record<RoomReceiptReason, string>> = Object.freeze({
|
|
23
|
+
admitted: "admitted", inbox_admitted: "admitted to person inbox", inbox_full: "person inbox full",
|
|
24
|
+
inbox_oversize: "person inbox message too large", inbox_expired: "person inbox message expired",
|
|
25
|
+
inbox_age_unavailable: "person inbox admission age unavailable", not_exposed: "peer not exposed", boundary_refused: "room boundary refused",
|
|
26
|
+
invalid_message: "invalid room message", rate_limited: "room rate limit", hop: "hop limit",
|
|
27
|
+
person_inbox_unsupported: "person inbox not supported yet (X5)",
|
|
28
|
+
});
|
|
29
|
+
const REASONS: readonly RoomReceiptReason[] = ["admitted", "inbox_admitted", "inbox_full", "inbox_oversize", "inbox_expired", "inbox_age_unavailable", "not_exposed", "boundary_refused", "invalid_message", "rate_limited", "hop", "person_inbox_unsupported"];
|
|
30
|
+
/** Fixed operator text only; a diagnostic or remote field can never become a rendered reason. */
|
|
31
|
+
export function roomReceiptReasonText(reason: RoomReceiptReason): string {
|
|
32
|
+
return Object.hasOwn(REASON_TEXT, reason) ? REASON_TEXT[reason] : "room receipt unavailable";
|
|
33
|
+
}
|
|
34
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
35
|
+
const encoder = new TextEncoder();
|
|
36
|
+
|
|
37
|
+
export function isRoomLocalPeerId(value: unknown): value is string {
|
|
38
|
+
return typeof value === "string" && LOCAL_ID.exec(value)?.[0] === value && value !== "hub" && value !== "digest";
|
|
39
|
+
}
|
|
40
|
+
/** Remote humans are user@label, never local console authority. Labels come from the signed private roster. */
|
|
41
|
+
export function parseRemotePeerId(value: unknown): { peer: string; label: string } | undefined {
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const match = REMOTE_ID.exec(value);
|
|
44
|
+
if (!match || match[0] !== value || !isRoomLocalPeerId(match[1])) return undefined;
|
|
45
|
+
return { peer: match[1], label: match[2]! };
|
|
46
|
+
}
|
|
47
|
+
function object(value: unknown, fields: readonly string[]): Record<string, unknown> {
|
|
48
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype) fail();
|
|
49
|
+
const row = value as Record<string, unknown>;
|
|
50
|
+
if (Reflect.ownKeys(row).length !== fields.length || fields.some(key => !Object.hasOwn(row, key))) fail();
|
|
51
|
+
return row;
|
|
52
|
+
}
|
|
53
|
+
function uuid(value: unknown): string {
|
|
54
|
+
if (typeof value !== "string" || value.length !== 36 || !UUID.test(value)) fail();
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function localPeer(value: unknown): string { if (!isRoomLocalPeerId(value)) fail(); return value; }
|
|
58
|
+
function integer(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): number {
|
|
59
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || Object.is(value, -0) || value < min || value > max) fail();
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function text(value: unknown): string {
|
|
63
|
+
if (typeof value !== "string" || !value.isWellFormed()) fail();
|
|
64
|
+
if (encoder.encode(value).length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
function parseEnvelope(value: unknown): RoomChatEnvelope {
|
|
68
|
+
const row = object(value, ["id", "trace", "hop", "from", "to", "kind", "priority", "body", "ts"]);
|
|
69
|
+
if (row.kind !== "chat" && row.kind !== "status") fail();
|
|
70
|
+
if (row.priority !== "important" && row.priority !== "status" && row.priority !== "fyi") fail();
|
|
71
|
+
return { id: uuid(row.id), trace: uuid(row.trace), hop: integer(row.hop, 0, MAX_HOP), from: localPeer(row.from), to: localPeer(row.to), kind: row.kind, priority: row.priority, body: text(row.body), ts: integer(row.ts, 0) };
|
|
72
|
+
}
|
|
73
|
+
function frameDigest(value: unknown): string {
|
|
74
|
+
if (typeof value !== "string" || value.length !== 43) fail();
|
|
75
|
+
try { decodeBase64url(value, 32); } catch { fail(); }
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function parseMessage(value: unknown): RoomMessage {
|
|
79
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) fail();
|
|
80
|
+
const type = (value as Record<string, unknown>).type;
|
|
81
|
+
if (type === "chat") {
|
|
82
|
+
const row = object(value, ["v", "type", "envelope"]);
|
|
83
|
+
if (row.v !== 1) fail();
|
|
84
|
+
return { v: 1, type, envelope: parseEnvelope(row.envelope) };
|
|
85
|
+
}
|
|
86
|
+
if (type === "receipt") {
|
|
87
|
+
const row = object(value, ["v", "type", "frame", "application", "peer", "outcome", "reason"]);
|
|
88
|
+
if (row.v !== 1 || row.outcome !== "completed" && row.outcome !== "failed_safe" || !REASONS.includes(row.reason as RoomReceiptReason)
|
|
89
|
+
|| row.outcome === "completed" && row.reason !== "admitted" && row.reason !== "inbox_admitted"
|
|
90
|
+
|| row.outcome === "failed_safe" && (row.reason === "admitted" || row.reason === "inbox_admitted")) fail();
|
|
91
|
+
return { v: 1, type, frame: frameDigest(row.frame), application: uuid(row.application), peer: localPeer(row.peer), outcome: row.outcome, reason: row.reason as RoomReceiptReason };
|
|
92
|
+
}
|
|
93
|
+
return fail();
|
|
94
|
+
}
|
|
95
|
+
function applicationError(error: unknown): never {
|
|
96
|
+
if (error instanceof RoomApplicationError) throw error;
|
|
97
|
+
return fail();
|
|
98
|
+
}
|
|
99
|
+
export function encodeRoomMessage(message: RoomMessage): Uint8Array {
|
|
100
|
+
try {
|
|
101
|
+
const wire = canonicalBytes(parseMessage(message));
|
|
102
|
+
if (wire.length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
103
|
+
return wire;
|
|
104
|
+
} catch (error) { return applicationError(error); }
|
|
105
|
+
}
|
|
106
|
+
export function decodeRoomMessage(wire: Uint8Array): RoomMessage {
|
|
107
|
+
try {
|
|
108
|
+
if (!(wire instanceof Uint8Array) || wire.length === 0) fail();
|
|
109
|
+
if (wire.length > ROOM_APPLICATION_MAX_BYTES) fail("limit");
|
|
110
|
+
const bytes = Uint8Array.from(wire), message = parseMessage(JSON.parse(decoder.decode(bytes))), canonical = canonicalBytes(message);
|
|
111
|
+
if (canonical.length !== bytes.length || canonical.some((byte, index) => byte !== bytes[index])) fail();
|
|
112
|
+
return message;
|
|
113
|
+
} catch (error) { return applicationError(error); }
|
|
114
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** The one X4 outbound view. A proof never carries refs, private task text or untrusted authority. */
|
|
2
|
+
import { looksSecret } from "@staix/agent-mem";
|
|
3
|
+
import { HUB, DIGEST, MAX_HOP, USER, sanitize, type Envelope } from "../hub/envelope.ts";
|
|
4
|
+
import { mentionsPrivatePath } from "../hub/memory-policy.ts";
|
|
5
|
+
import { detectSignals, type Routing } from "../hub/routing.ts";
|
|
6
|
+
import type { PiiVerdict } from "../hub/inference.ts";
|
|
7
|
+
import { encodeRoomMessage, isRoomLocalPeerId, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
|
|
8
|
+
|
|
9
|
+
export interface RoomBoundaryContext {
|
|
10
|
+
cwd: string; target: string; current: () => boolean;
|
|
11
|
+
senderExposed: (id: string) => boolean;
|
|
12
|
+
senderPrivate: (id: string) => boolean | undefined;
|
|
13
|
+
taskPrivate: (id: number) => boolean | undefined;
|
|
14
|
+
routing: () => Routing; piiPaths: () => readonly string[];
|
|
15
|
+
modelScreenOn: () => boolean;
|
|
16
|
+
/** The existing host-bound screenPii port owns device/campus admission, deadlines and fixed models. */
|
|
17
|
+
screen?: (text: string) => Promise<PiiVerdict>;
|
|
18
|
+
}
|
|
19
|
+
export type RoomBoundaryCode = "invalid_message" | "not_exposed" | "boundary_refused" | "hop";
|
|
20
|
+
declare const proofBrand: unique symbol;
|
|
21
|
+
export interface RoomOutboundProof { readonly [proofBrand]: true }
|
|
22
|
+
export type RoomBoundaryResult = { status: "refused"; code: RoomBoundaryCode } | { status: "ready"; proof: RoomOutboundProof; withheld: boolean };
|
|
23
|
+
const proofs = new WeakMap<RoomOutboundProof, { envelope: Readonly<RoomChatEnvelope>; current: () => boolean }>();
|
|
24
|
+
const ENVELOPE_FIELDS = new Set(["id", "trace", "hop", "from", "to", "kind", "priority", "body", "refs", "private", "ts"]);
|
|
25
|
+
const encoder = new TextEncoder();
|
|
26
|
+
const refused = (code: RoomBoundaryCode): RoomBoundaryResult => ({ status: "refused", code });
|
|
27
|
+
|
|
28
|
+
interface BodyPolicy { routing: Routing; paths: string[]; model: boolean; signature: string }
|
|
29
|
+
function bodyPolicy(context: RoomBoundaryContext): BodyPolicy | undefined {
|
|
30
|
+
try {
|
|
31
|
+
const routing = context.routing(), paths = [...context.piiPaths()], model = context.modelScreenOn();
|
|
32
|
+
if (typeof model !== "boolean" || paths.some(path => typeof path !== "string") || !Array.isArray(routing.signals.pii_patterns) || routing.signals.pii_patterns.some(pattern => typeof pattern !== "string")) return undefined;
|
|
33
|
+
const patterns = [...routing.signals.pii_patterns];
|
|
34
|
+
return { routing: { ...routing, signals: { ...routing.signals, pii_patterns: patterns } }, paths, model, signature: JSON.stringify([patterns, paths, model]) };
|
|
35
|
+
} catch { return undefined; }
|
|
36
|
+
}
|
|
37
|
+
function clearBody(body: string, paths: readonly string[], context: RoomBoundaryContext, policy: BodyPolicy | undefined): boolean {
|
|
38
|
+
if (!policy) return false;
|
|
39
|
+
try {
|
|
40
|
+
return !looksSecret(body) && !mentionsPrivatePath(body, paths.map(path => `path:${path}`), policy.paths, context.cwd)
|
|
41
|
+
&& !detectSignals({ title: "", detail: body, refs: {} }, policy.routing, context.cwd).includes("pii");
|
|
42
|
+
} catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
/** Snapshot before any await; host ports remain captured, while their decisions are read fresh. */
|
|
45
|
+
export async function roomBoundary(input: Envelope, ctx: RoomBoundaryContext): Promise<RoomBoundaryResult> {
|
|
46
|
+
try {
|
|
47
|
+
const env = structuredClone(input), context: RoomBoundaryContext = { ...ctx };
|
|
48
|
+
const target = parseRemotePeerId(context.target);
|
|
49
|
+
if (!target || !env || typeof env !== "object" || Array.isArray(env) || Object.keys(env).some(key => !ENVELOPE_FIELDS.has(key))) return refused("invalid_message");
|
|
50
|
+
if (env.from === HUB || env.from === DIGEST || !isRoomLocalPeerId(env.from)) return refused("boundary_refused");
|
|
51
|
+
if (env.kind !== "chat" && env.kind !== "status" || env.private === true) return refused("boundary_refused");
|
|
52
|
+
if (env.private !== undefined && typeof env.private !== "boolean") return refused("invalid_message");
|
|
53
|
+
if (!Array.isArray(env.to) || !env.to.includes(context.target)) return refused("boundary_refused");
|
|
54
|
+
if (env.to.length > 64 || env.to.some(id => !isRoomLocalPeerId(id) && !parseRemotePeerId(id))) return refused("invalid_message");
|
|
55
|
+
if (!Number.isSafeInteger(env.hop) || env.hop < 0 || env.hop > MAX_HOP) return refused("hop");
|
|
56
|
+
if (env.refs !== undefined && (!env.refs || typeof env.refs !== "object" || Array.isArray(env.refs))) return refused("invalid_message");
|
|
57
|
+
const paths = env.refs?.paths ?? [];
|
|
58
|
+
if (!Array.isArray(paths) || paths.length > 64 || paths.some(path => typeof path !== "string" || encoder.encode(path).length > 4096)) return refused("invalid_message");
|
|
59
|
+
const task = env.refs?.task;
|
|
60
|
+
if (task !== undefined && (typeof task !== "string" || !Number.isSafeInteger(Number(task)) || Number(task) < 1 || String(Number(task)) !== task)) return refused("invalid_message");
|
|
61
|
+
const taskId = task === undefined ? undefined : Number(task);
|
|
62
|
+
const authorized = (): RoomBoundaryCode | undefined => {
|
|
63
|
+
try {
|
|
64
|
+
if (context.current() !== true) return "boundary_refused";
|
|
65
|
+
if (env.from !== USER && context.senderExposed(env.from) !== true) return "not_exposed";
|
|
66
|
+
if (context.senderPrivate(env.from) !== false || taskId !== undefined && context.taskPrivate(taskId) !== false) return "boundary_refused";
|
|
67
|
+
return context.current() === true ? undefined : "boundary_refused";
|
|
68
|
+
} catch { return "boundary_refused"; }
|
|
69
|
+
};
|
|
70
|
+
const initial = authorized(); if (initial) return refused(initial);
|
|
71
|
+
const envelope: RoomChatEnvelope = { id: env.id, trace: env.trace, hop: env.hop, from: env.from, to: target.peer, kind: env.kind, priority: env.priority === "important" ? "status" : env.priority, body: env.body, ts: env.ts };
|
|
72
|
+
encodeRoomMessage({ v: 1, type: "chat", envelope }); // Validate metadata/body budget before any model sees it.
|
|
73
|
+
const policy = bodyPolicy(context);
|
|
74
|
+
let withheld = !clearBody(env.body, paths, context, policy);
|
|
75
|
+
if (!withheld && policy?.model) {
|
|
76
|
+
const before = authorized(); if (before) return refused(before);
|
|
77
|
+
let verdict: PiiVerdict | undefined;
|
|
78
|
+
try { verdict = await context.screen?.(env.body); } catch { /* unknown fails closed to the fixed stub */ }
|
|
79
|
+
const after = authorized(); if (after) return refused(after);
|
|
80
|
+
withheld = verdict?.label !== "clear";
|
|
81
|
+
const fresh = bodyPolicy(context);
|
|
82
|
+
if (!fresh || fresh.signature !== policy.signature) return refused("boundary_refused");
|
|
83
|
+
withheld ||= !clearBody(env.body, paths, context, fresh);
|
|
84
|
+
}
|
|
85
|
+
const final = authorized(); if (final) return refused(final);
|
|
86
|
+
const outgoing = Object.freeze({ ...envelope, body: withheld ? `[room message ${envelope.id} withheld]` : sanitize(envelope.body) });
|
|
87
|
+
encodeRoomMessage({ v: 1, type: "chat", envelope: outgoing });
|
|
88
|
+
const current = () => authorized() === undefined && (withheld || (() => {
|
|
89
|
+
const latest = bodyPolicy(context);
|
|
90
|
+
return latest !== undefined && latest.signature === policy?.signature && clearBody(env.body, paths, context, latest);
|
|
91
|
+
})());
|
|
92
|
+
const proof = Object.freeze({}) as RoomOutboundProof;
|
|
93
|
+
proofs.set(proof, { envelope: outgoing, current });
|
|
94
|
+
return { status: "ready", proof, withheld };
|
|
95
|
+
} catch { return refused("invalid_message"); }
|
|
96
|
+
}
|
|
97
|
+
export function readRoomOutbound(proof: RoomOutboundProof): RoomChatEnvelope | undefined {
|
|
98
|
+
if (!proof || typeof proof !== "object") return undefined;
|
|
99
|
+
const state = proofs.get(proof);
|
|
100
|
+
if (!state) return undefined;
|
|
101
|
+
try { return state.current() === true ? { ...state.envelope } : undefined; }
|
|
102
|
+
catch { return undefined; }
|
|
103
|
+
}
|