proto: freeze the wire contract at 1.0.0
Schemas for the whole v1 surface: 38 methods, 9 events, 25 named types and the
JSON-RPC envelope, with x-privileged / x-transports / x-deadlineMs / x-errors
annotations that both generators emit as data rather than prose.
Four generators over one IR (contracts/codegen/schema_ir.py), so the C++ structs,
the TypeScript types and the OpenRPC document cannot disagree about what the
contract says:
gen_cpp.py -> core/generated/velox_proto.{hpp,cpp}
gen_ts.py -> extension/src/shared/protocol/
gen_openrpc.py -> contracts/openrpc.json
gen_cpp_conformance.py -> tests/conformance/cpp/fixture_dispatcher.hpp
Inbound parsing never throws: parse<T>() returns std::expected<T, ParseError> and
nlohmann's throwing ADL from_json is deliberately not emitted. Schema constraints
(minimum, maxLength, pattern, ...) become real runtime checks in both languages —
the daemon does not trust the extension and the extension does not trust the
daemon.
59 golden fixtures: a success case per method, 12 error cases, 9 events. Replayed
by tests/conformance/ against both the generated C++ and a live server over both
transports. tools/mockd serves the same fixtures with unhappy-path flags so the
GUI and EXT lanes never wait for veloxd.
run.sh also proves capture.offer fails open: with a daemon answering slower than
750 ms the client gives up and lets Firefox take the download.
core/generated/ is libveloxproto, a separate target from libveloxcore, which
still never sees JSON — see docs/adr/0009.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Unix socket transport: newline-delimited JSON, one request per line.
|
||||
*
|
||||
* This is what the GUI, the CLI and velox-nmhost speak. The real daemon checks
|
||||
* SO_PEERCRED here and needs no token; mockd does the same by simply trusting the socket,
|
||||
* because anything that can open it is already the same user.
|
||||
*/
|
||||
|
||||
import { createServer, type Server, type Socket } from 'node:net';
|
||||
import { mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Dispatcher, Session } from '../dispatch.js';
|
||||
|
||||
export interface Connection {
|
||||
send(frame: unknown): void;
|
||||
readonly session: Session;
|
||||
}
|
||||
|
||||
export function startUds(
|
||||
path: string,
|
||||
dispatcher: Dispatcher,
|
||||
connections: Set<Connection>,
|
||||
log: (msg: string) => void,
|
||||
delayMs: number,
|
||||
): Server {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
rmSync(path, { force: true });
|
||||
|
||||
const server = createServer((socket: Socket) => {
|
||||
const session: Session = { transport: 'uds', paired: true, subscribed: new Set(), sessionId: randomUUID() };
|
||||
const conn: Connection = {
|
||||
session,
|
||||
send: (frame) => {
|
||||
if (!socket.destroyed) socket.write(JSON.stringify(frame) + '\n');
|
||||
},
|
||||
};
|
||||
connections.add(conn);
|
||||
log(`uds: client connected (${connections.size} open)`);
|
||||
|
||||
let buffer = '';
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let nl = buffer.indexOf('\n');
|
||||
while (nl !== -1) {
|
||||
const line = buffer.slice(0, nl).trim();
|
||||
buffer = buffer.slice(nl + 1);
|
||||
nl = buffer.indexOf('\n');
|
||||
if (line.length === 0) continue;
|
||||
handleLine(line, conn, dispatcher, log, delayMs);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => log(`uds: socket error: ${err.message}`));
|
||||
socket.on('close', () => {
|
||||
connections.delete(conn);
|
||||
log(`uds: client disconnected (${connections.size} open)`);
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(path, () => log(`uds: listening on ${path}`));
|
||||
return server;
|
||||
}
|
||||
|
||||
function handleLine(
|
||||
line: string,
|
||||
conn: Connection,
|
||||
dispatcher: Dispatcher,
|
||||
log: (msg: string) => void,
|
||||
delayMs: number,
|
||||
): void {
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(line);
|
||||
} catch {
|
||||
conn.send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
||||
return;
|
||||
}
|
||||
const reply = dispatcher.handle(conn.session, frame);
|
||||
if (!reply) return;
|
||||
const method = (frame as { method?: string }).method ?? '?';
|
||||
log(`uds: ${method} -> ${'error' in reply ? `error ${(reply['error'] as { code: number }).code}` : 'ok'}`);
|
||||
if (delayMs > 0) setTimeout(() => conn.send(reply), delayMs);
|
||||
else conn.send(reply);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Loopback WebSocket transport: one JSON message per text frame.
|
||||
*
|
||||
* The extension's fallback for snap-confined Firefox, and the reason the security rules in
|
||||
* docs/05 exist: this port is reachable by every process on the machine. mockd enforces
|
||||
* the two that a client can actually observe — bind 127.0.0.1 only, and check the Origin —
|
||||
* so the EXT lane finds out here rather than against the real daemon.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, type WebSocket } from 'ws';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Dispatcher, Session } from '../dispatch.js';
|
||||
import type { Connection } from './uds.js';
|
||||
|
||||
export interface WsOptions {
|
||||
readonly port: number;
|
||||
readonly delayMs: number;
|
||||
/** Drop every connection every N seconds, to exercise reconnect logic. */
|
||||
readonly dropEverySec: number;
|
||||
readonly allowAnyOrigin: boolean;
|
||||
}
|
||||
|
||||
export function startWs(
|
||||
opts: WsOptions,
|
||||
dispatcher: Dispatcher,
|
||||
connections: Set<Connection>,
|
||||
log: (msg: string) => void,
|
||||
): WebSocketServer {
|
||||
const server = new WebSocketServer({
|
||||
host: '127.0.0.1', // never 0.0.0.0 — see docs/05-extension-spec.md §4
|
||||
port: opts.port,
|
||||
verifyClient: ({ origin }, done) => {
|
||||
const ok = opts.allowAnyOrigin || origin === undefined || origin.startsWith('moz-extension://');
|
||||
if (!ok) log(`ws: refused connection from origin ${origin}`);
|
||||
done(ok, 403, 'origin not allowed');
|
||||
},
|
||||
});
|
||||
|
||||
server.on('listening', () => log(`ws: listening on ws://127.0.0.1:${opts.port}`));
|
||||
|
||||
server.on('connection', (socket: WebSocket) => {
|
||||
const session: Session = {
|
||||
transport: 'ws',
|
||||
paired: false, // the extension must pair or present a token first
|
||||
subscribed: new Set(),
|
||||
sessionId: randomUUID(),
|
||||
};
|
||||
const conn: Connection = {
|
||||
session,
|
||||
send: (frame) => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame));
|
||||
},
|
||||
};
|
||||
connections.add(conn);
|
||||
log(`ws: client connected (${connections.size} open)`);
|
||||
|
||||
socket.on('message', (data) => {
|
||||
let frame: unknown;
|
||||
try {
|
||||
frame = JSON.parse(data.toString());
|
||||
} catch {
|
||||
conn.send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } });
|
||||
return;
|
||||
}
|
||||
const reply = dispatcher.handle(session, frame);
|
||||
if (!reply) return;
|
||||
const method = (frame as { method?: string }).method ?? '?';
|
||||
log(`ws: ${method} -> ${'error' in reply ? `error ${(reply['error'] as { code: number }).code}` : 'ok'}`);
|
||||
if (opts.delayMs > 0) setTimeout(() => conn.send(reply), opts.delayMs);
|
||||
else conn.send(reply);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
connections.delete(conn);
|
||||
log(`ws: client disconnected (${connections.size} open)`);
|
||||
});
|
||||
socket.on('error', (err) => log(`ws: socket error: ${err.message}`));
|
||||
});
|
||||
|
||||
if (opts.dropEverySec > 0) {
|
||||
setInterval(() => {
|
||||
log(`ws: dropping ${server.clients.size} connection(s) (--drop-connection)`);
|
||||
for (const client of server.clients) client.terminate();
|
||||
}, opts.dropEverySec * 1000).unref();
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
Reference in New Issue
Block a user