ext: S1 native-messaging spike (ADR 0003) + WebSocket transport
Spike S1 — run on the target machine through real snap confinement
(apparmor snap.firefox.firefox enforced; web-ext's direct-exec of the inner
binary bypasses it, so runs were forced through `snap run firefox`):
- manifest in ~/.mozilla/native-messaging-hosts/ -> WORKS; host launched
unconfined with real $HOME and real $XDG_RUNTIME_DIR, bound a socket in
the real /run/user/<uid>. Corroborated by the machine's 1Password host.
- ~/snap/firefox/common/.mozilla/native-messaging-hosts/ -> not read
- /usr/lib/mozilla/native-messaging-hosts/ -> not read
- flatpak path -> N/A (snap Firefox)
Decision: WebSocket stays the default; native messaging is an opportunistic
upgrade taken only when its handshake succeeds. docs/05 §4 corrected in this
commit to point the snap manifest at ~/.mozilla and mark /usr/lib as
deb/tarball-only. ADR carries a self-contained reproduction; the scratch
harness has been removed.
transport/ (build order item 1):
- types.ts VeloxTransport interface + error taxonomy
- rpc.ts JSON-RPC id correlation, per-call deadline, AbortSignal
- backoff.ts exponential backoff with jitter
- discovery.ts 52000-52016 scan ordering (last-good port first)
- websocket.ts scan -> session.hello -> auto-pair (token in
storage.local) -> reconnect; -32001 fatal, refused/
rate-limited pairing latches needsPairing (no retry storm);
a mid-handshake drop aborts hello immediately
- native.ts connectNative(); distinguishes "not installed" (fatal,
lets the picker fall through) from a crash (reconnect)
- index.ts createTransport() runtime picker + persisted Options override
Toolchain: package.json / tsconfig (strict) / vitest; webextension-polyfill
mocked. 38 tests, incl. the WS suite against a real loopback ws server.
No manifest.json yet, so CI's extension-lint guard stays a no-op.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
// Test doubles for the transport suite: a real loopback JSON-RPC server for the
|
||||
// WebSocket transport, and a scriptable fake port for the native transport.
|
||||
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
|
||||
import { PROTOCOL_VERSION } from '../../src/shared/protocol/index.js';
|
||||
import type { NativePort } from '../../src/background/transport/native.js';
|
||||
|
||||
// --- a loopback veloxd, just enough of one ------------------------------------------
|
||||
|
||||
export interface FakeDaemonOptions {
|
||||
port: number;
|
||||
/** Token the daemon will accept on session.hello. null = accept none (forces pairing). */
|
||||
acceptToken?: string | null;
|
||||
/** session.hello answers -32001 before anything else. */
|
||||
versionMismatch?: boolean;
|
||||
/** session.pair answers this error instead of issuing a token. */
|
||||
pairError?: { code: number; message: string; data?: unknown };
|
||||
daemonVersion?: string;
|
||||
capabilities?: string[];
|
||||
/** Don't answer these methods at all, to exercise client timeouts. */
|
||||
blackhole?: string[];
|
||||
}
|
||||
|
||||
export class FakeDaemon {
|
||||
readonly wss: WebSocketServer;
|
||||
readonly sockets = new Set<WebSocket>();
|
||||
helloCount = 0;
|
||||
pairCount = 0;
|
||||
readonly seen: Array<{ method: string; params: unknown; token?: unknown }> = [];
|
||||
private issued = new Set<string>();
|
||||
private nextToken = 'tok-issued-1';
|
||||
|
||||
constructor(private readonly opts: FakeDaemonOptions) {
|
||||
if (opts.acceptToken) this.issued.add(opts.acceptToken);
|
||||
this.wss = new WebSocketServer({ host: '127.0.0.1', port: opts.port });
|
||||
this.wss.on('connection', (ws) => {
|
||||
this.sockets.add(ws);
|
||||
ws.on('close', () => this.sockets.delete(ws));
|
||||
ws.on('message', (raw) => this.onMessage(ws, String(raw)));
|
||||
});
|
||||
}
|
||||
|
||||
static async start(opts: FakeDaemonOptions): Promise<FakeDaemon> {
|
||||
const d = new FakeDaemon(opts);
|
||||
await new Promise<void>((r) => d.wss.once('listening', () => r()));
|
||||
return d;
|
||||
}
|
||||
|
||||
private send(ws: WebSocket, obj: unknown): void {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
private onMessage(ws: WebSocket, raw: string): void {
|
||||
let req: { id?: number; method?: string; params?: Record<string, unknown> };
|
||||
try {
|
||||
req = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const { id, method, params = {} } = req;
|
||||
if (typeof method !== 'string') return;
|
||||
this.seen.push({ method, params, token: params['token'] });
|
||||
|
||||
if (this.opts.blackhole?.includes(method)) return;
|
||||
|
||||
const ok = (result: unknown): void => this.send(ws, { jsonrpc: '2.0', id, result });
|
||||
const err = (code: number, message: string, data?: unknown): void =>
|
||||
this.send(ws, { jsonrpc: '2.0', id, error: data === undefined ? { code, message } : { code, message, data } });
|
||||
|
||||
switch (method) {
|
||||
case 'session.hello': {
|
||||
this.helloCount += 1;
|
||||
if (this.opts.versionMismatch) {
|
||||
err(-32001, 'protocol major version mismatch', { expected: PROTOCOL_VERSION, actual: '9.0.0' });
|
||||
return;
|
||||
}
|
||||
const token = params['token'];
|
||||
if (typeof token !== 'string' || !this.issued.has(token)) {
|
||||
err(-32002, 'not paired: call session.pair first');
|
||||
return;
|
||||
}
|
||||
ok({
|
||||
daemonVersion: this.opts.daemonVersion ?? '1.2.3-fake',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: this.opts.capabilities ?? ['media', 'grabber'],
|
||||
sessionId: 'sess-1',
|
||||
transport: 'ws',
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'session.pair': {
|
||||
this.pairCount += 1;
|
||||
if (this.opts.pairError) {
|
||||
err(this.opts.pairError.code, this.opts.pairError.message, this.opts.pairError.data);
|
||||
return;
|
||||
}
|
||||
const t = this.nextToken;
|
||||
this.issued.add(t);
|
||||
ok({ token: t, expiresAt: null });
|
||||
return;
|
||||
}
|
||||
case 'session.subscribe':
|
||||
ok({ ok: true, events: (params['events'] as string[]) ?? [] });
|
||||
return;
|
||||
case 'download.list':
|
||||
ok({ total: 0, items: [] });
|
||||
return;
|
||||
default:
|
||||
err(-32601, 'no such method');
|
||||
}
|
||||
}
|
||||
|
||||
/** Push a server-to-client notification to every open socket. */
|
||||
notify(method: string, paramsObj: unknown): void {
|
||||
for (const ws of this.sockets) this.send(ws, { jsonrpc: '2.0', method, params: paramsObj });
|
||||
}
|
||||
|
||||
/** Hard-drop every current connection, to exercise reconnect. */
|
||||
dropAll(): void {
|
||||
for (const ws of this.sockets) ws.terminate();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
for (const ws of this.sockets) ws.terminate();
|
||||
await new Promise<void>((r) => this.wss.close(() => r()));
|
||||
}
|
||||
}
|
||||
|
||||
/** A free-ish port inside a private band; each call is distinct within a run. */
|
||||
let portCursor = 53100 + Math.floor(Math.random() * 300);
|
||||
export function nextPort(): number {
|
||||
portCursor += 1;
|
||||
return portCursor;
|
||||
}
|
||||
|
||||
// --- scriptable native port -------------------------------------------------------
|
||||
|
||||
type Listener = (arg: unknown) => void;
|
||||
|
||||
export class FakeNativePort implements NativePort {
|
||||
error: { message: string } | null = null;
|
||||
readonly sent: unknown[] = [];
|
||||
private msgListeners = new Set<Listener>();
|
||||
private discListeners = new Set<Listener>();
|
||||
disconnected = false;
|
||||
|
||||
onMessage = {
|
||||
addListener: (cb: (m: unknown) => void) => this.msgListeners.add(cb as Listener),
|
||||
removeListener: (cb: (m: unknown) => void) => this.msgListeners.delete(cb as Listener),
|
||||
};
|
||||
onDisconnect = {
|
||||
addListener: (cb: (p?: unknown) => void) => this.discListeners.add(cb as Listener),
|
||||
removeListener: (cb: (p?: unknown) => void) => this.discListeners.delete(cb as Listener),
|
||||
};
|
||||
|
||||
/** Auto-answers session.hello unless `autoHello` is false. */
|
||||
constructor(private readonly opts: { autoHello?: boolean; helloError?: { code: number; message: string }; helloResult?: unknown } = {}) {}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.sent.push(message);
|
||||
const req = message as { id?: number; method?: string };
|
||||
if (req.method === 'session.hello' && this.opts.autoHello !== false && !this.disconnected) {
|
||||
queueMicrotask(() => {
|
||||
if (this.disconnected) return;
|
||||
if (this.opts.helloError) {
|
||||
this.emitMessage({ jsonrpc: '2.0', id: req.id, error: this.opts.helloError });
|
||||
} else {
|
||||
this.emitMessage({
|
||||
jsonrpc: '2.0',
|
||||
id: req.id,
|
||||
result: this.opts.helloResult ?? {
|
||||
daemonVersion: '1.2.3-nm',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: ['media'],
|
||||
sessionId: 'nm-sess',
|
||||
transport: 'uds',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
|
||||
emitMessage(msg: unknown): void {
|
||||
for (const cb of this.msgListeners) cb(msg);
|
||||
}
|
||||
|
||||
fireDisconnect(errorMessage?: string): void {
|
||||
this.disconnected = true;
|
||||
if (errorMessage) this.error = { message: errorMessage };
|
||||
for (const cb of this.discListeners) cb(this);
|
||||
}
|
||||
}
|
||||
|
||||
export const tick = (ms = 0): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
Reference in New Issue
Block a user