@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/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" />
@@ -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 q = props.agent ? `?agent=${encodeURIComponent(props.agent)}` : '';
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
- onMount(load);
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={load}
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=&lt;fingerprint&gt; label=&lt;who-you-are&gt; 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);