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,108 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
RpcError,
|
||||
RpcTimeoutError,
|
||||
TransportClosedError,
|
||||
} from '../../src/background/transport/types.js';
|
||||
import { NativeTransport } from '../../src/background/transport/native.js';
|
||||
import { FakeNativePort, tick } from './helpers.js';
|
||||
|
||||
let transport: NativeTransport | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
transport?.disconnect();
|
||||
transport = undefined;
|
||||
});
|
||||
|
||||
describe('NativeTransport', () => {
|
||||
it('completes session.hello over the port and connects', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
|
||||
await transport.connect();
|
||||
|
||||
expect(transport.state).toBe('connected');
|
||||
expect(transport.kind).toBe('uds');
|
||||
expect(transport.status.daemonVersion).toBe('1.2.3-nm');
|
||||
expect(transport.status.capabilities).toEqual(['media']);
|
||||
expect((port.sent[0] as { method: string }).method).toBe('session.hello');
|
||||
expect((port.sent[0] as { params: { token: unknown } }).params.token).toBeNull();
|
||||
});
|
||||
|
||||
it('marks the transport fatal when the host is not installed', async () => {
|
||||
const port = new FakeNativePort({ autoHello: false });
|
||||
transport = new NativeTransport({ connectNative: () => port, backoff: { baseMs: 10 } });
|
||||
|
||||
const p = transport.connect();
|
||||
queueMicrotask(() => port.fireDisconnect('No such native application com.velox.host'));
|
||||
await expect(p).rejects.toBeInstanceOf(RpcError);
|
||||
|
||||
expect(transport.status.fatal).toMatch(/not installed/);
|
||||
// fatal => no reconnect
|
||||
const calls = [] as unknown[];
|
||||
const t2 = new NativeTransport({
|
||||
connectNative: () => {
|
||||
calls.push(1);
|
||||
const fp = new FakeNativePort({ autoHello: false });
|
||||
queueMicrotask(() => fp.fireDisconnect('No such native application'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10 },
|
||||
});
|
||||
await t2.connect().catch(() => undefined);
|
||||
await tick(60);
|
||||
expect(calls).toHaveLength(1);
|
||||
t2.disconnect();
|
||||
});
|
||||
|
||||
it('reconnects when an installed host drops during the handshake', async () => {
|
||||
let attempts = 0;
|
||||
transport = new NativeTransport({
|
||||
connectNative: () => {
|
||||
attempts += 1;
|
||||
const fp = new FakeNativePort({ autoHello: attempts > 1 });
|
||||
if (attempts === 1) queueMicrotask(() => fp.fireDisconnect('pipe closed'));
|
||||
return fp;
|
||||
},
|
||||
backoff: { baseMs: 10, factor: 1, jitter: 0 },
|
||||
});
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(TransportClosedError);
|
||||
for (let i = 0; i < 40 && transport.state !== 'connected'; i += 1) await tick(10);
|
||||
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||
expect(transport.state).toBe('connected');
|
||||
});
|
||||
|
||||
it('treats a protocol-major mismatch as fatal', async () => {
|
||||
const port = new FakeNativePort({ helloError: { code: -32001, message: 'daemon speaks 9.x' } });
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
|
||||
await expect(transport.connect()).rejects.toBeInstanceOf(RpcError);
|
||||
expect(transport.status.fatal).toMatch(/protocol mismatch/);
|
||||
});
|
||||
|
||||
it('forwards calls and fails them on a drop', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({
|
||||
connectNative: () => port,
|
||||
backoff: { baseMs: 10_000 },
|
||||
});
|
||||
await transport.connect();
|
||||
|
||||
const pending = transport.call('download.list', { filter: null }, { timeoutMs: 5000 });
|
||||
expect((port.sent.at(-1) as { method: string }).method).toBe('download.list');
|
||||
port.fireDisconnect('host exited');
|
||||
await expect(pending).rejects.toBeInstanceOf(TransportClosedError);
|
||||
expect(transport.state).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('times a call out when the host goes quiet', async () => {
|
||||
const port = new FakeNativePort();
|
||||
transport = new NativeTransport({ connectNative: () => port });
|
||||
await transport.connect();
|
||||
await expect(
|
||||
transport.call('download.list', { filter: null }, { timeoutMs: 30 }),
|
||||
).rejects.toBeInstanceOf(RpcTimeoutError);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user