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:
2026-09-09 19:55:54 +04:00
co-authored by Claude Opus 5
parent a40585f419
commit 53421d6cb8
171 changed files with 29275 additions and 51 deletions
+85
View File
@@ -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);
}
+88
View File
@@ -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;
}