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
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env -S npx tsx
/**
* mockd — a fake veloxd that is good enough to build a GUI and an extension against.
*
* It serves contracts/fixtures over both transports, keeps just enough state that adding
* and pausing a download does something visible, and fakes progress events at 4 Hz. Its
* unhappy-path flags exist so the GUI and EXT lanes can test the cases that are hard to
* arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing —
* long before the real daemon exists.
*
* npm start -- --help
*/
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { rmSync } from 'node:fs';
import { Dispatcher, type Session } from './dispatch.js';
import { indexByMethod, loadFixtures, resolvePlaceholders } from './fixtures.js';
import { MockState } from './state.js';
import { startUds, type Connection } from './transport/uds.js';
import { startWs } from './transport/ws.js';
import type { TaskSummary } from '../../../extension/src/shared/protocol/types.js';
import { PROTOCOL_VERSION } from '../../../extension/src/shared/protocol/types.js';
const HERE = resolve(fileURLToPath(import.meta.url), '..');
const REPO = resolve(HERE, '..', '..', '..');
const FIXTURE_DIR = resolve(REPO, 'contracts', 'fixtures');
const USAGE = `mockd — mock veloxd, serving contracts/fixtures over both transports
--uds <path> Unix socket path
(default: $XDG_RUNTIME_DIR/velox/velox.sock)
--ws-port <n> loopback WebSocket port (default: 52000)
--no-uds do not listen on the Unix socket
--no-ws do not listen on the WebSocket
--progress-hz <n> progress event rate (default: 4, the contract's maximum)
--speed <bytes> synthetic per-task speed in bytes/sec (default: 8388608)
Unhappy paths, for the GUI and EXT lanes:
--slow <ms> delay every reply by <ms>. Past 750 ms, capture.offer must
fail open and let Firefox take the download.
--flaky <0..1> answer this fraction of calls with -32603
--drop-connection <s> terminate every connection every <s> seconds
--refuse-pairing session.pair always fails, as if the user clicked Deny
--lockout session.pair answers -32014, as if the brute-force lockout tripped
--allowed-root <dir> add a root that download.add's saveDir may resolve inside
--allow-any-origin skip the moz-extension:// Origin check (debugging only)
--no-validate do not validate params against the generated validators
-h, --help this message
`;
interface Args {
uds: string | null;
wsPort: number | null;
progressHz: number;
speed: number;
slow: number;
flaky: number;
dropEverySec: number;
refusePairing: boolean;
allowAnyOrigin: boolean;
validate: boolean;
lockout: boolean;
allowedRoots: string[];
}
function parseArgs(argv: readonly string[]): Args {
const runtime = process.env['XDG_RUNTIME_DIR'] ?? `/run/user/${process.getuid?.() ?? 1000}`;
const args: Args = {
uds: resolve(runtime, 'velox', 'velox.sock'),
wsPort: 52000,
progressHz: 4,
speed: 8 * 1024 * 1024,
slow: 0,
flaky: 0,
dropEverySec: 0,
refusePairing: false,
allowAnyOrigin: false,
validate: true,
lockout: false,
allowedRoots: [resolve(process.env['HOME'] ?? '/home/sami', 'Downloads'), '/tmp'],
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = (): string => {
const v = argv[i + 1];
if (v === undefined) throw new Error(`${arg} needs a value`);
i += 1;
return v;
};
switch (arg) {
case '-h': case '--help': process.stdout.write(USAGE); process.exit(0); break;
case '--uds': args.uds = next(); break;
case '--no-uds': args.uds = null; break;
case '--ws-port': args.wsPort = Number(next()); break;
case '--no-ws': args.wsPort = null; break;
case '--progress-hz': args.progressHz = Number(next()); break;
case '--speed': args.speed = Number(next()); break;
case '--slow': args.slow = Number(next()); break;
case '--flaky': args.flaky = Number(next()); break;
case '--drop-connection': args.dropEverySec = Number(next()); break;
case '--refuse-pairing': args.refusePairing = true; break;
case '--lockout': args.lockout = true; break;
case '--allowed-root': args.allowedRoots.push(resolve(next())); break;
case '--allow-any-origin': args.allowAnyOrigin = true; break;
case '--no-validate': args.validate = false; break;
default:
process.stderr.write(`mockd: unknown option ${arg}\n\n${USAGE}`);
process.exit(2);
}
}
return args;
}
function main(): void {
const args = parseArgs(process.argv.slice(2));
const log = (msg: string): void => {
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
};
const fixtures = loadFixtures(FIXTURE_DIR);
const byMethod = indexByMethod(fixtures);
log(`loaded ${fixtures.length} fixtures covering ${byMethod.size} methods from contracts/fixtures`);
// Seed the task list from the download.list fixture, so a client that connects before
// adding anything still has rows to draw.
const listFixture = byMethod.get('download.list');
const seed = (resolvePlaceholders(
(listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [],
) as TaskSummary[]);
const state = new MockState(seed, { progressHz: args.progressHz, speedBps: args.speed });
const dispatcher = new Dispatcher(state, byMethod, {
protocolVersion: PROTOCOL_VERSION,
daemonVersion: '0.0.0-mockd',
refusePairing: args.refusePairing,
flaky: args.flaky,
validate: args.validate,
allowedRoots: args.allowedRoots,
lockout: args.lockout,
});
const connections = new Set<Connection>();
const broadcast = (method: string, params: unknown): void => {
const frame = { jsonrpc: '2.0', method, params };
for (const conn of connections) {
if (conn.session.subscribed.has(method)) conn.send(frame);
}
};
if (args.uds !== null) {
startUds(args.uds, dispatcher, connections, log, args.slow);
const path = args.uds;
const cleanup = (): void => {
rmSync(path, { force: true });
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
}
if (args.wsPort !== null) {
startWs({ port: args.wsPort, delayMs: args.slow, dropEverySec: args.dropEverySec,
allowAnyOrigin: args.allowAnyOrigin }, dispatcher, connections, log);
}
// Progress at the contract's 4 Hz ceiling, as one batched array — never one
// notification per task. The GUI lane needs this shape to build its coalescing against.
setInterval(() => {
const { moved, completed } = state.tick();
if (moved.length > 0) {
broadcast('event.task.progress', {
tasks: moved.map((t) => ({
taskId: t.taskId,
downloadedBytes: t.downloadedBytes,
speedBps: t.speedBps,
etaSeconds: t.etaSeconds,
segments: Array.from({ length: Math.min(t.segments, 8) }, (_, i) => ({
index: i,
downloadedBytes: Math.floor(t.downloadedBytes / t.segments),
speedBps: Math.floor(t.speedBps / t.segments),
})),
})),
at: new Date().toISOString(),
});
}
for (const task of completed) {
broadcast('event.task.state', {
taskId: task.taskId, state: 'complete', previousState: 'downloading',
summary: task, error: null,
});
broadcast('event.notify', {
level: 'success', title: 'Download complete',
body: `${task.filename} finished.`, taskId: task.taskId, sound: 'complete',
});
}
}, Math.max(1, Math.round(1000 / args.progressHz)));
setInterval(() => {
const speed = state.globalSpeed();
broadcast('event.speed.global', {
...speed,
limitBps: state.limiter.enabled ? state.limiter.globalBps : null,
});
}, 1000);
log(`protocol v${PROTOCOL_VERSION}; ${state.tasks.size} seeded task(s)`);
if (args.slow > 750) {
log(`WARNING --slow ${args.slow} exceeds capture.offer's 750 ms deadline: a correct extension will fail open`);
}
}
main();
// Referenced so the Session type stays exported for transport implementations.
export type { Session };