@animalabs/connectome-host 0.3.1 → 0.3.7

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 (41) hide show
  1. package/.env.example +6 -0
  2. package/.github/workflows/publish.yml +9 -4
  3. package/README.md +5 -0
  4. package/bun.lock +8 -4
  5. package/docs/AGENT-ONBOARDING.md +6 -1
  6. package/package.json +5 -5
  7. package/scripts/import-codex-rollout.ts +288 -0
  8. package/src/call-ledger.ts +371 -0
  9. package/src/call-pricing.ts +119 -0
  10. package/src/commands.ts +57 -3
  11. package/src/index.ts +87 -27
  12. package/src/logging-adapter.ts +72 -9
  13. package/src/modules/fleet-module.ts +21 -9
  14. package/src/modules/mcpl-admin-module.ts +6 -0
  15. package/src/modules/observers-module.ts +180 -0
  16. package/src/modules/time-module.ts +15 -9
  17. package/src/modules/web-ui-module.ts +415 -16
  18. package/src/modules/web-ui-observers.ts +322 -0
  19. package/src/recipe.ts +48 -3
  20. package/src/strategies/frontdesk-strategy.ts +10 -4
  21. package/src/web/protocol.ts +141 -0
  22. package/test/call-ledger.test.ts +91 -0
  23. package/test/call-pricing.test.ts +69 -0
  24. package/test/fleet-subscribe-union-e2e.test.ts +5 -0
  25. package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
  26. package/test/frontdesk-strategy.test.ts +10 -0
  27. package/test/import-codex-rollout.test.ts +36 -0
  28. package/test/logging-adapter.test.ts +58 -1
  29. package/test/recipe-cache-ttl.test.ts +7 -3
  30. package/test/recipe-provider.test.ts +36 -0
  31. package/test/recipe-timezone.test.ts +20 -0
  32. package/test/time-module.test.ts +13 -0
  33. package/test/web-ui-context-coverage.test.ts +61 -0
  34. package/test/web-ui-observers.test.ts +344 -0
  35. package/web/package-lock.json +2446 -0
  36. package/web/src/App.tsx +24 -0
  37. package/web/src/Context.tsx +207 -7
  38. package/web/src/ObserverGate.tsx +78 -0
  39. package/web/src/Usage.tsx +116 -2
  40. package/web/src/observer-identity.ts +110 -0
  41. package/web/src/wire.ts +60 -1
@@ -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,38 @@ 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 until the user acts (grant lands / password). The server also
132
+ // closes never-authenticated connections with 4401 on timeout, so
133
+ // don't overwrite 'unavailable' (no WebCrypto — a hello was never
134
+ // possible) with 'denied' (a key exists but holds no grant).
135
+ if (observerState() !== 'unavailable') setObserverState('denied');
136
+ setStatus('closed');
137
+ return;
138
+ }
82
139
  setStatus('reconnecting');
83
140
  reconnectTimer = setTimeout(connect, delay);
84
141
  delay = Math.min(delay * 2, maxDelay);
@@ -93,6 +150,8 @@ export function createWireClient(opts: WireOptions = {}): WireClient {
93
150
 
94
151
  return {
95
152
  status,
153
+ observerState,
154
+ observer,
96
155
  lastMessage,
97
156
  onMessage(handler) {
98
157
  handlers.add(handler);