@animalabs/connectome-host 0.3.1 → 0.3.5
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/.github/workflows/publish.yml +9 -4
- package/package.json +3 -3
- package/src/commands.ts +57 -3
- package/src/index.ts +9 -0
- package/src/modules/fleet-module.ts +4 -2
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/web-ui-module.ts +390 -16
- package/src/modules/web-ui-observers.ts +309 -0
- package/src/web/protocol.ts +52 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +301 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +15 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +68 -0
- package/web/src/observer-identity.ts +110 -0
- package/web/src/wire.ts +57 -1
package/web/src/App.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { McplPanel, type McplServerRow } from './Mcpl';
|
|
|
12
12
|
import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
|
|
13
13
|
import { ContextPanel } from './Context';
|
|
14
14
|
import { ContextDocument } from './ContextDocument';
|
|
15
|
+
import { ObserverGateScreen } from './ObserverGate';
|
|
15
16
|
import {
|
|
16
17
|
WEB_PROTOCOL_VERSION,
|
|
17
18
|
type WebUiServerMessage,
|
|
@@ -836,6 +837,20 @@ export function App() {
|
|
|
836
837
|
<div class="flex flex-col h-screen">
|
|
837
838
|
<Header welcome={welcome()} usage={usage()} status={wire.status()} />
|
|
838
839
|
<ReconnectBanner status={wire.status()} />
|
|
840
|
+
<Show when={wire.observerState() === 'observer' && wire.observer()}>
|
|
841
|
+
{(info) => (
|
|
842
|
+
<div class="bg-violet-950/60 border-b border-violet-900 px-4 py-1.5 text-xs text-violet-200 flex items-center gap-2">
|
|
843
|
+
<span class="w-2 h-2 rounded-full bg-violet-500" />
|
|
844
|
+
<span>
|
|
845
|
+
Observing as <span class="font-semibold">{info().label}</span> — read-only, scopes:{' '}
|
|
846
|
+
<code class="font-mono">{info().scopes.join(', ')}</code>. Content outside your scopes is elided.
|
|
847
|
+
</span>
|
|
848
|
+
</div>
|
|
849
|
+
)}
|
|
850
|
+
</Show>
|
|
851
|
+
<Show when={wire.observerState() === 'denied' || wire.observerState() === 'unavailable'}>
|
|
852
|
+
<ObserverGateScreen state={wire.observerState() as 'denied' | 'unavailable'} />
|
|
853
|
+
</Show>
|
|
839
854
|
<Show when={protoMismatch() !== null}>
|
|
840
855
|
<div class="bg-amber-950/60 border-b border-amber-900 px-4 py-1.5 text-xs text-amber-200 flex items-center gap-2">
|
|
841
856
|
<span class="w-2 h-2 rounded-full bg-amber-500" />
|
package/web/src/Context.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* a per-segment table, with an exact total token count.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { createSignal, onMount, For, Show } from 'solid-js';
|
|
11
|
+
import { createSignal, onCleanup, onMount, For, Show } from 'solid-js';
|
|
12
12
|
|
|
13
13
|
interface Seg { messages: number; tokens: number }
|
|
14
14
|
interface Stats {
|
|
@@ -28,9 +28,55 @@ interface Makeup {
|
|
|
28
28
|
error?: string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
interface CoverageLevel {
|
|
32
|
+
level: number;
|
|
33
|
+
summaries: number;
|
|
34
|
+
frontier: number;
|
|
35
|
+
tokens: number;
|
|
36
|
+
coveredChunks: number;
|
|
37
|
+
coveredMessages: number;
|
|
38
|
+
coveredTokens: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface CoverageChunk {
|
|
42
|
+
index: number;
|
|
43
|
+
messages: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
compressed: boolean;
|
|
46
|
+
summaryId: string | null;
|
|
47
|
+
maxLevel: number;
|
|
48
|
+
selectedMin: number;
|
|
49
|
+
selectedMax: number;
|
|
50
|
+
queued: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Coverage {
|
|
54
|
+
agent: string;
|
|
55
|
+
branch: string;
|
|
56
|
+
generatedAt: string;
|
|
57
|
+
supported: boolean;
|
|
58
|
+
totals: {
|
|
59
|
+
chunks: number;
|
|
60
|
+
compressedChunks: number;
|
|
61
|
+
coveredMessages: number;
|
|
62
|
+
coveredTokens: number;
|
|
63
|
+
summaries: number;
|
|
64
|
+
};
|
|
65
|
+
levels: CoverageLevel[];
|
|
66
|
+
chunks: CoverageChunk[];
|
|
67
|
+
queue: {
|
|
68
|
+
inFlight: boolean;
|
|
69
|
+
pending: string | null;
|
|
70
|
+
l1: number[];
|
|
71
|
+
merges: Array<{ targetLevel: number; sourceCount: number; firstSource: string | null; lastSource: string | null }>;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
31
75
|
type Row = { key: string; label: string; messages: number; tokens: number; color: string };
|
|
32
76
|
|
|
33
77
|
const fmt = (n: number) => n.toLocaleString();
|
|
78
|
+
const levelColors = ['#52525b', '#06b6d4', '#10b981', '#f59e0b', '#f43f5e', '#a78bfa', '#60a5fa'];
|
|
79
|
+
const levelColor = (level: number) => levelColors[Math.min(level, levelColors.length - 1)];
|
|
34
80
|
|
|
35
81
|
function rowsOf(s: Stats): Row[] {
|
|
36
82
|
return [
|
|
@@ -45,15 +91,19 @@ function rowsOf(s: Stats): Row[] {
|
|
|
45
91
|
|
|
46
92
|
export function ContextPanel(props: { agent?: string }) {
|
|
47
93
|
const [data, setData] = createSignal<Makeup | null>(null);
|
|
94
|
+
const [coverage, setCoverage] = createSignal<Coverage | null>(null);
|
|
48
95
|
const [loading, setLoading] = createSignal(false);
|
|
96
|
+
const [coverageLoading, setCoverageLoading] = createSignal(false);
|
|
49
97
|
const [err, setErr] = createSignal<string | null>(null);
|
|
98
|
+
const [coverageErr, setCoverageErr] = createSignal<string | null>(null);
|
|
99
|
+
|
|
100
|
+
const query = () => props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
|
|
50
101
|
|
|
51
102
|
const load = async () => {
|
|
52
103
|
setLoading(true);
|
|
53
104
|
setErr(null);
|
|
54
105
|
try {
|
|
55
|
-
const
|
|
56
|
-
const res = await fetch(`/debug/context/makeup${q}`, { credentials: 'same-origin' });
|
|
106
|
+
const res = await fetch(`/debug/context/makeup${query()}`, { credentials: 'same-origin' });
|
|
57
107
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
58
108
|
const j = (await res.json()) as Makeup;
|
|
59
109
|
if (j.error) throw new Error(j.error);
|
|
@@ -65,7 +115,30 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
65
115
|
}
|
|
66
116
|
};
|
|
67
117
|
|
|
68
|
-
|
|
118
|
+
const loadCoverage = async () => {
|
|
119
|
+
setCoverageLoading(true);
|
|
120
|
+
setCoverageErr(null);
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch(`/debug/context/coverage${query()}`, { credentials: 'same-origin' });
|
|
123
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
124
|
+
setCoverage((await res.json()) as Coverage);
|
|
125
|
+
} catch (e) {
|
|
126
|
+
setCoverageErr(e instanceof Error ? e.message : String(e));
|
|
127
|
+
} finally {
|
|
128
|
+
setCoverageLoading(false);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const refreshAll = () => {
|
|
133
|
+
void load();
|
|
134
|
+
void loadCoverage();
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
onMount(() => {
|
|
138
|
+
refreshAll();
|
|
139
|
+
const timer = window.setInterval(() => void loadCoverage(), 5_000);
|
|
140
|
+
onCleanup(() => window.clearInterval(timer));
|
|
141
|
+
});
|
|
69
142
|
|
|
70
143
|
const estTotal = () => data()?.stats?.total.tokens ?? 0;
|
|
71
144
|
const rows = () => {
|
|
@@ -80,10 +153,11 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
80
153
|
<button
|
|
81
154
|
type="button"
|
|
82
155
|
class="px-2 py-0.5 text-neutral-400 hover:text-neutral-100 border border-neutral-700 rounded"
|
|
83
|
-
onClick={
|
|
84
|
-
disabled={loading()}
|
|
156
|
+
onClick={refreshAll}
|
|
157
|
+
disabled={loading() || coverageLoading()}
|
|
158
|
+
title="Refresh context diagnostics"
|
|
85
159
|
>
|
|
86
|
-
{loading() ? '…' : 'refresh'}
|
|
160
|
+
{loading() || coverageLoading() ? '…' : 'refresh'}
|
|
87
161
|
</button>
|
|
88
162
|
</div>
|
|
89
163
|
|
|
@@ -91,6 +165,106 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
91
165
|
<div class="text-rose-400">error: {err()}</div>
|
|
92
166
|
</Show>
|
|
93
167
|
|
|
168
|
+
<Show when={coverageErr()}>
|
|
169
|
+
<div class="text-rose-400">coverage: {coverageErr()}</div>
|
|
170
|
+
</Show>
|
|
171
|
+
|
|
172
|
+
<Show when={coverage()?.supported}>
|
|
173
|
+
<section class="border-y border-neutral-800 py-3 space-y-2.5">
|
|
174
|
+
<div class="flex items-center justify-between">
|
|
175
|
+
<span class="text-neutral-300">Summary coverage</span>
|
|
176
|
+
<span class="text-neutral-600">
|
|
177
|
+
{coverage()!.queue.inFlight || coverage()!.queue.l1.length > 0 || coverage()!.queue.merges.length > 0
|
|
178
|
+
? 'active'
|
|
179
|
+
: 'idle'}
|
|
180
|
+
</span>
|
|
181
|
+
</div>
|
|
182
|
+
|
|
183
|
+
<div class="grid grid-cols-3 gap-2 text-neutral-500">
|
|
184
|
+
<div>
|
|
185
|
+
<div class="text-base text-neutral-200">{coverage()!.totals.compressedChunks}/{coverage()!.totals.chunks}</div>
|
|
186
|
+
<div>chunks</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div>
|
|
189
|
+
<div class="text-base text-neutral-200">{fmt(coverage()!.totals.coveredMessages)}</div>
|
|
190
|
+
<div>msgs covered</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<div class="text-base text-neutral-200">{fmt(coverage()!.totals.summaries)}</div>
|
|
194
|
+
<div>summaries</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div class="space-y-1.5">
|
|
199
|
+
<div class="flex justify-between text-neutral-500"><span>available depth</span><span>oldest → newest</span></div>
|
|
200
|
+
<CoverageStrip chunks={coverage()!.chunks} field="available" />
|
|
201
|
+
<div class="flex justify-between text-neutral-500"><span>selected depth</span><span>{fmt(coverage()!.totals.coveredTokens)} tok covered</span></div>
|
|
202
|
+
<CoverageStrip chunks={coverage()!.chunks} field="selected" />
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-neutral-500">
|
|
206
|
+
<For each={[0, ...coverage()!.levels.map(level => level.level)]}>
|
|
207
|
+
{(level) => (
|
|
208
|
+
<span><span class="inline-block w-2 h-2 rounded-sm mr-1" style={{ background: levelColor(level) }} />L{level}</span>
|
|
209
|
+
)}
|
|
210
|
+
</For>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<table class="w-full">
|
|
214
|
+
<thead>
|
|
215
|
+
<tr class="text-neutral-500 text-left">
|
|
216
|
+
<th class="font-normal py-1">level</th>
|
|
217
|
+
<th class="font-normal text-right">frontier</th>
|
|
218
|
+
<th class="font-normal text-right">total</th>
|
|
219
|
+
<th class="font-normal text-right">coverage</th>
|
|
220
|
+
</tr>
|
|
221
|
+
</thead>
|
|
222
|
+
<tbody>
|
|
223
|
+
<For each={coverage()!.levels}>
|
|
224
|
+
{(level) => (
|
|
225
|
+
<tr class="border-t border-neutral-900">
|
|
226
|
+
<td class="py-1 text-neutral-200">L{level.level}</td>
|
|
227
|
+
<td class="text-right text-neutral-300">{level.frontier}</td>
|
|
228
|
+
<td class="text-right text-neutral-500">{level.summaries}</td>
|
|
229
|
+
<td class="text-right text-neutral-400">{fmt(level.coveredMessages)} msg</td>
|
|
230
|
+
</tr>
|
|
231
|
+
)}
|
|
232
|
+
</For>
|
|
233
|
+
</tbody>
|
|
234
|
+
</table>
|
|
235
|
+
|
|
236
|
+
<div class="border-t border-neutral-900 pt-2 space-y-1">
|
|
237
|
+
<div class="flex items-center justify-between">
|
|
238
|
+
<span class="text-neutral-400">Queue</span>
|
|
239
|
+
<span class={coverage()!.queue.inFlight ? 'text-amber-300' : 'text-neutral-600'}>
|
|
240
|
+
{coverage()!.queue.inFlight ? 'in flight' : 'waiting'}
|
|
241
|
+
</span>
|
|
242
|
+
</div>
|
|
243
|
+
<Show when={coverage()!.queue.pending}>
|
|
244
|
+
<div class="text-amber-200 truncate" title={coverage()!.queue.pending ?? ''}>{coverage()!.queue.pending}</div>
|
|
245
|
+
</Show>
|
|
246
|
+
<For each={coverage()!.queue.l1}>
|
|
247
|
+
{(index) => <div class="text-neutral-400">L1 · chunk {index}</div>}
|
|
248
|
+
</For>
|
|
249
|
+
<For each={coverage()!.queue.merges}>
|
|
250
|
+
{(merge) => (
|
|
251
|
+
<div class="text-neutral-400" title={`${merge.firstSource ?? ''} → ${merge.lastSource ?? ''}`}>
|
|
252
|
+
L{merge.targetLevel} · {merge.sourceCount} source summaries
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</For>
|
|
256
|
+
<Show when={!coverage()!.queue.inFlight && coverage()!.queue.l1.length === 0 && coverage()!.queue.merges.length === 0}>
|
|
257
|
+
<div class="text-neutral-600">No queued work</div>
|
|
258
|
+
</Show>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div class="flex items-center justify-between gap-2 text-neutral-600">
|
|
262
|
+
<span class="min-w-0 truncate" title={coverage()!.branch}>{coverage()!.branch}</span>
|
|
263
|
+
<a class="shrink-0 text-cyan-500 hover:text-cyan-300" href="/curve" target="_blank" rel="noreferrer">curve ↗</a>
|
|
264
|
+
</div>
|
|
265
|
+
</section>
|
|
266
|
+
</Show>
|
|
267
|
+
|
|
94
268
|
<Show when={data()?.stats} fallback={<Show when={!err()}><div class="text-neutral-500">loading…</div></Show>}>
|
|
95
269
|
{/* headline totals */}
|
|
96
270
|
<div class="flex gap-4">
|
|
@@ -156,3 +330,29 @@ export function ContextPanel(props: { agent?: string }) {
|
|
|
156
330
|
</div>
|
|
157
331
|
);
|
|
158
332
|
}
|
|
333
|
+
|
|
334
|
+
function CoverageStrip(props: { chunks: CoverageChunk[]; field: 'available' | 'selected' }) {
|
|
335
|
+
const total = () => props.chunks.reduce((sum, chunk) => sum + Math.max(1, chunk.tokens), 0);
|
|
336
|
+
return (
|
|
337
|
+
<div class="flex h-4 w-full overflow-hidden rounded border border-neutral-800 bg-neutral-900">
|
|
338
|
+
<For each={props.chunks}>
|
|
339
|
+
{(chunk) => {
|
|
340
|
+
const level = () => props.field === 'available' ? chunk.maxLevel : chunk.selectedMax;
|
|
341
|
+
const title = () => {
|
|
342
|
+
const selected = chunk.selectedMin === chunk.selectedMax
|
|
343
|
+
? `L${chunk.selectedMax}`
|
|
344
|
+
: `L${chunk.selectedMin}–L${chunk.selectedMax}`;
|
|
345
|
+
return `chunk ${chunk.index} · ${chunk.messages} msg · ${fmt(chunk.tokens)} tok · available L${chunk.maxLevel} · selected ${selected}${chunk.queued ? ' · queued' : ''}`;
|
|
346
|
+
};
|
|
347
|
+
return (
|
|
348
|
+
<div
|
|
349
|
+
class={`h-full min-w-px ${chunk.queued ? 'ring-1 ring-inset ring-amber-200' : ''}`}
|
|
350
|
+
style={{ width: `${(Math.max(1, chunk.tokens) / Math.max(1, total())) * 100}%`, background: levelColor(level()) }}
|
|
351
|
+
title={title()}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
}}
|
|
355
|
+
</For>
|
|
356
|
+
</div>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Full-screen gate shown when this device's observer key is not granted on
|
|
3
|
+
* this agent ('denied') or WebCrypto is unavailable ('unavailable' —
|
|
4
|
+
* insecure context). Shows the device fingerprint to hand to the agent /
|
|
5
|
+
* operator, and offers password sign-in as the fallback (navigating to
|
|
6
|
+
* /auth/basic triggers the browser's basic-auth prompt; with cached
|
|
7
|
+
* credentials the subsequent WS upgrade authenticates as a full client).
|
|
8
|
+
*/
|
|
9
|
+
import { createResource, Show } from 'solid-js';
|
|
10
|
+
import { deviceFingerprint } from './observer-identity';
|
|
11
|
+
|
|
12
|
+
export function ObserverGateScreen(props: { state: 'denied' | 'unavailable' }) {
|
|
13
|
+
const [fingerprint] = createResource(deviceFingerprint);
|
|
14
|
+
|
|
15
|
+
const copy = (): void => {
|
|
16
|
+
const fp = fingerprint();
|
|
17
|
+
if (fp) void navigator.clipboard?.writeText(fp);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div class="fixed inset-0 z-50 bg-zinc-950/95 flex items-center justify-center p-6">
|
|
22
|
+
<div class="max-w-xl w-full border border-zinc-800 rounded-lg bg-zinc-900 p-6 space-y-4 text-sm text-zinc-300">
|
|
23
|
+
<Show
|
|
24
|
+
when={props.state === 'denied'}
|
|
25
|
+
fallback={
|
|
26
|
+
<>
|
|
27
|
+
<h2 class="text-base font-semibold text-zinc-100">Observer keys unavailable here</h2>
|
|
28
|
+
<p>
|
|
29
|
+
Device-key authentication needs a secure context (https or localhost) for WebCrypto.
|
|
30
|
+
This page was loaded over plain http, so only password sign-in is available.
|
|
31
|
+
</p>
|
|
32
|
+
</>
|
|
33
|
+
}
|
|
34
|
+
>
|
|
35
|
+
<h2 class="text-base font-semibold text-zinc-100">This device isn't authorized yet</h2>
|
|
36
|
+
<p>
|
|
37
|
+
This viewer shows an agent's interior — access is granted per device, by the agent or
|
|
38
|
+
its operator. Share this device's key fingerprint with them:
|
|
39
|
+
</p>
|
|
40
|
+
<div class="flex items-center gap-2">
|
|
41
|
+
<code class="font-mono text-xs bg-zinc-950 border border-zinc-800 rounded px-2 py-1.5 break-all flex-1">
|
|
42
|
+
{fingerprint() ?? 'generating…'}
|
|
43
|
+
</code>
|
|
44
|
+
<button
|
|
45
|
+
class="px-2 py-1.5 text-xs rounded border border-zinc-700 hover:bg-zinc-800"
|
|
46
|
+
onClick={copy}
|
|
47
|
+
>
|
|
48
|
+
copy
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
<p class="text-zinc-400">
|
|
52
|
+
Grant (agent tool or operator edit of <code class="font-mono">data/observers.json</code>):{' '}
|
|
53
|
+
<code class="font-mono text-xs">
|
|
54
|
+
observers--grant key=<fingerprint> label=<who-you-are> scopes=[health,…]
|
|
55
|
+
</code>
|
|
56
|
+
. Takes effect within ~3s — then{' '}
|
|
57
|
+
<button class="underline" onClick={() => location.reload()}>reload</button>.
|
|
58
|
+
</p>
|
|
59
|
+
</Show>
|
|
60
|
+
<div class="pt-2 border-t border-zinc-800">
|
|
61
|
+
<a class="underline text-zinc-400 hover:text-zinc-200" href="/auth/basic">
|
|
62
|
+
Sign in with password instead
|
|
63
|
+
</a>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device observer identity (docs/observability.md §3, browser side).
|
|
3
|
+
*
|
|
4
|
+
* On first use the SPA generates a NON-EXTRACTABLE Ed25519 keypair via
|
|
5
|
+
* WebCrypto and persists the CryptoKey objects in IndexedDB — the private
|
|
6
|
+
* key never exists as bytes the page (or an XSS) could read. The public
|
|
7
|
+
* fingerprint (`ed25519:<base64url raw>`) is what a human shows to the
|
|
8
|
+
* agent/operator to be granted (`observers--grant`).
|
|
9
|
+
*
|
|
10
|
+
* Requires a secure context (https or localhost) — WebCrypto is absent on
|
|
11
|
+
* plain-http non-localhost origins. Callers get `null` and should offer
|
|
12
|
+
* password sign-in instead.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DB_NAME = 'fkm-observer';
|
|
16
|
+
const STORE = 'keys';
|
|
17
|
+
const KEY_ID = 'device-key';
|
|
18
|
+
|
|
19
|
+
interface DeviceKey {
|
|
20
|
+
keyPair: CryptoKeyPair;
|
|
21
|
+
/** `ed25519:<base64url raw 32-byte public key>` */
|
|
22
|
+
id: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function b64url(buf: ArrayBuffer): string {
|
|
26
|
+
let s = '';
|
|
27
|
+
for (const b of new Uint8Array(buf)) s += String.fromCharCode(b);
|
|
28
|
+
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function idb(): Promise<IDBDatabase> {
|
|
32
|
+
return new Promise((resolvePromise, reject) => {
|
|
33
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
34
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
35
|
+
req.onsuccess = () => resolvePromise(req.result);
|
|
36
|
+
req.onerror = () => reject(req.error);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function idbGet<T>(db: IDBDatabase, key: string): Promise<T | undefined> {
|
|
41
|
+
return new Promise((resolvePromise, reject) => {
|
|
42
|
+
const tx = db.transaction(STORE, 'readonly').objectStore(STORE).get(key);
|
|
43
|
+
tx.onsuccess = () => resolvePromise(tx.result as T | undefined);
|
|
44
|
+
tx.onerror = () => reject(tx.error);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function idbPut(db: IDBDatabase, key: string, value: unknown): Promise<void> {
|
|
49
|
+
return new Promise((resolvePromise, reject) => {
|
|
50
|
+
const tx = db.transaction(STORE, 'readwrite').objectStore(STORE).put(value, key);
|
|
51
|
+
tx.onsuccess = () => resolvePromise();
|
|
52
|
+
tx.onerror = () => reject(tx.error);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function fingerprintOf(publicKey: CryptoKey): Promise<string> {
|
|
57
|
+
const raw = await crypto.subtle.exportKey('raw', publicKey);
|
|
58
|
+
return `ed25519:${b64url(raw)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Ed25519 support probe — false on insecure contexts and old browsers. */
|
|
62
|
+
export function observerCryptoAvailable(): boolean {
|
|
63
|
+
return typeof crypto !== 'undefined' && !!crypto.subtle && typeof indexedDB !== 'undefined';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load (or create on first use) this device's observer key. Null when the
|
|
67
|
+
* environment can't do WebCrypto Ed25519. */
|
|
68
|
+
export async function ensureDeviceKey(): Promise<DeviceKey | null> {
|
|
69
|
+
if (!observerCryptoAvailable()) return null;
|
|
70
|
+
try {
|
|
71
|
+
const db = await idb();
|
|
72
|
+
const existing = await idbGet<CryptoKeyPair>(db, KEY_ID);
|
|
73
|
+
if (existing?.privateKey && existing.publicKey) {
|
|
74
|
+
return { keyPair: existing, id: await fingerprintOf(existing.publicKey) };
|
|
75
|
+
}
|
|
76
|
+
const keyPair = (await crypto.subtle.generateKey(
|
|
77
|
+
{ name: 'Ed25519' } as AlgorithmIdentifier,
|
|
78
|
+
false, // non-extractable — the private key never leaves the browser
|
|
79
|
+
['sign', 'verify'],
|
|
80
|
+
)) as CryptoKeyPair;
|
|
81
|
+
await idbPut(db, KEY_ID, keyPair);
|
|
82
|
+
return { keyPair, id: await fingerprintOf(keyPair.publicKey) };
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.warn('[observer] device key unavailable:', err);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build the signed observer-hello identity envelope for `host` (the value
|
|
90
|
+
* the server sent in observer-auth-required — its own Host header). */
|
|
91
|
+
export async function buildHelloIdentity(host: string): Promise<{
|
|
92
|
+
scheme: 'ed25519'; id: string; proof: string; timestamp: string;
|
|
93
|
+
} | null> {
|
|
94
|
+
const key = await ensureDeviceKey();
|
|
95
|
+
if (!key) return null;
|
|
96
|
+
const timestamp = new Date().toISOString();
|
|
97
|
+
const statement = `connectome-observer|v1|${host}|${timestamp}`;
|
|
98
|
+
const sig = await crypto.subtle.sign(
|
|
99
|
+
{ name: 'Ed25519' } as AlgorithmIdentifier,
|
|
100
|
+
key.keyPair.privateKey,
|
|
101
|
+
new TextEncoder().encode(statement),
|
|
102
|
+
);
|
|
103
|
+
return { scheme: 'ed25519', id: key.id, proof: b64url(sig), timestamp };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Current device fingerprint for display (creates the key if absent). */
|
|
107
|
+
export async function deviceFingerprint(): Promise<string | null> {
|
|
108
|
+
const key = await ensureDeviceKey();
|
|
109
|
+
return key?.id ?? null;
|
|
110
|
+
}
|
package/web/src/wire.ts
CHANGED
|
@@ -11,11 +11,31 @@ import type {
|
|
|
11
11
|
WebUiClientMessage,
|
|
12
12
|
WebUiServerMessage,
|
|
13
13
|
} from '@conhost/web/protocol';
|
|
14
|
+
import { buildHelloIdentity } from './observer-identity.js';
|
|
14
15
|
|
|
15
16
|
export type ConnectionStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';
|
|
16
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Observer-auth state (docs/observability.md, browser side).
|
|
20
|
+
* 'none' — basic-auth / open server; historical behavior
|
|
21
|
+
* 'authing' — server demanded observer auth; hello sent
|
|
22
|
+
* 'observer' — key-authenticated; `observer()` carries label + scopes
|
|
23
|
+
* 'denied' — this device's key holds no grant on this agent
|
|
24
|
+
* 'unavailable' — WebCrypto absent (insecure context) — password only
|
|
25
|
+
*/
|
|
26
|
+
export type ObserverAuthState = 'none' | 'authing' | 'observer' | 'denied' | 'unavailable';
|
|
27
|
+
|
|
28
|
+
export interface ObserverInfo {
|
|
29
|
+
label: string;
|
|
30
|
+
scopes: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
17
33
|
export interface WireClient {
|
|
18
34
|
status: Accessor<ConnectionStatus>;
|
|
35
|
+
/** Observer-auth state for this connection. */
|
|
36
|
+
observerState: Accessor<ObserverAuthState>;
|
|
37
|
+
/** Grant info after a successful observer handshake. */
|
|
38
|
+
observer: Accessor<ObserverInfo | null>;
|
|
19
39
|
/** Last received message, or null. Useful for reactive folds. */
|
|
20
40
|
lastMessage: Accessor<WebUiServerMessage | null>;
|
|
21
41
|
/** Subscribe to all incoming messages. Returns an unsubscribe fn. */
|
|
@@ -42,6 +62,8 @@ export function createWireClient(opts: WireOptions = {}): WireClient {
|
|
|
42
62
|
|
|
43
63
|
const [status, setStatus] = createSignal<ConnectionStatus>('connecting');
|
|
44
64
|
const [lastMessage, setLastMessage] = createSignal<WebUiServerMessage | null>(null);
|
|
65
|
+
const [observerState, setObserverState] = createSignal<ObserverAuthState>('none');
|
|
66
|
+
const [observer, setObserver] = createSignal<ObserverInfo | null>(null);
|
|
45
67
|
const handlers = new Set<(m: WebUiServerMessage) => void>();
|
|
46
68
|
|
|
47
69
|
let socket: WebSocket | null = null;
|
|
@@ -49,6 +71,21 @@ export function createWireClient(opts: WireOptions = {}): WireClient {
|
|
|
49
71
|
let delay = initialDelay;
|
|
50
72
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
51
73
|
|
|
74
|
+
/** Server demanded observer auth → sign and reply with this device's key.
|
|
75
|
+
* Failure paths surface via observerState so the UI can show the
|
|
76
|
+
* fingerprint-grant screen or fall back to password sign-in. */
|
|
77
|
+
async function respondToAuthRequired(sock: WebSocket, host: string): Promise<void> {
|
|
78
|
+
setObserverState('authing');
|
|
79
|
+
const identity = await buildHelloIdentity(host);
|
|
80
|
+
if (!identity) {
|
|
81
|
+
setObserverState('unavailable');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (sock.readyState === WebSocket.OPEN) {
|
|
85
|
+
sock.send(JSON.stringify({ type: 'observer-hello', identity }));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
52
89
|
function connect(): void {
|
|
53
90
|
if (stopped) return;
|
|
54
91
|
setStatus(socket ? 'reconnecting' : 'connecting');
|
|
@@ -67,18 +104,35 @@ export function createWireClient(opts: WireOptions = {}): WireClient {
|
|
|
67
104
|
console.warn('[wire] failed to parse message', event.data);
|
|
68
105
|
return;
|
|
69
106
|
}
|
|
107
|
+
// Observer handshake frames are handled here (the wire owns the
|
|
108
|
+
// socket); they also fan out to handlers for any UI that cares.
|
|
109
|
+
if (parsed.type === 'observer-auth-required' && socket) {
|
|
110
|
+
void respondToAuthRequired(socket, parsed.host);
|
|
111
|
+
} else if (parsed.type === 'observer-ack') {
|
|
112
|
+
setObserverState('observer');
|
|
113
|
+
setObserver({ label: parsed.label, scopes: parsed.scopes });
|
|
114
|
+
// Session cookie lets the SPA hit /debug/* and /healthz over HTTP.
|
|
115
|
+
document.cookie = `fkm_obs=${parsed.sessionToken}; path=/; SameSite=Lax; max-age=${12 * 3600}`;
|
|
116
|
+
}
|
|
70
117
|
setLastMessage(parsed);
|
|
71
118
|
for (const h of handlers) {
|
|
72
119
|
try { h(parsed); } catch (err) { console.error('[wire] handler threw', err); }
|
|
73
120
|
}
|
|
74
121
|
});
|
|
75
122
|
|
|
76
|
-
socket.addEventListener('close', () => {
|
|
123
|
+
socket.addEventListener('close', (ev) => {
|
|
77
124
|
socket = null;
|
|
78
125
|
if (stopped) {
|
|
79
126
|
setStatus('closed');
|
|
80
127
|
return;
|
|
81
128
|
}
|
|
129
|
+
if (ev.code === 4401) {
|
|
130
|
+
// Observer auth rejected — reconnect loops would spam the server;
|
|
131
|
+
// hold in 'denied' until the user acts (grant lands / password).
|
|
132
|
+
setObserverState('denied');
|
|
133
|
+
setStatus('closed');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
82
136
|
setStatus('reconnecting');
|
|
83
137
|
reconnectTimer = setTimeout(connect, delay);
|
|
84
138
|
delay = Math.min(delay * 2, maxDelay);
|
|
@@ -93,6 +147,8 @@ export function createWireClient(opts: WireOptions = {}): WireClient {
|
|
|
93
147
|
|
|
94
148
|
return {
|
|
95
149
|
status,
|
|
150
|
+
observerState,
|
|
151
|
+
observer,
|
|
96
152
|
lastMessage,
|
|
97
153
|
onMessage(handler) {
|
|
98
154
|
handlers.add(handler);
|