@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.
- package/.env.example +6 -0
- package/.github/workflows/publish.yml +9 -4
- package/README.md +5 -0
- package/bun.lock +8 -4
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +5 -5
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/commands.ts +57 -3
- package/src/index.ts +87 -27
- package/src/logging-adapter.ts +72 -9
- package/src/modules/fleet-module.ts +21 -9
- package/src/modules/mcpl-admin-module.ts +6 -0
- package/src/modules/observers-module.ts +180 -0
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +415 -16
- package/src/modules/web-ui-observers.ts +322 -0
- package/src/recipe.ts +48 -3
- package/src/strategies/frontdesk-strategy.ts +10 -4
- package/src/web/protocol.ts +141 -0
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/fleet-subscribe-union-e2e.test.ts +5 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +2 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-context-coverage.test.ts +61 -0
- package/test/web-ui-observers.test.ts +344 -0
- package/web/package-lock.json +2446 -0
- package/web/src/App.tsx +24 -0
- package/web/src/Context.tsx +207 -7
- package/web/src/ObserverGate.tsx +78 -0
- package/web/src/Usage.tsx +116 -2
- package/web/src/observer-identity.ts +110 -0
- 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);
|