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
@@ -0,0 +1,32 @@
{
"name": "capture.offer \u2014 a monitored type below the minimum size is declined",
"description": "Not an error: a normal 'no'. The daemon answers well inside the deadline and the extension lets Firefox handle it. reason is what the popup's diagnostics show.",
"request": {
"jsonrpc": "2.0",
"id": 111,
"method": "capture.offer",
"params": {
"url": "https://example.org/thumb.zip",
"method": "GET",
"tabUrl": "https://example.org/gallery",
"contentType": "application/zip",
"contentLength": 4096,
"filename": "thumb.zip",
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
}
},
"response": {
"jsonrpc": "2.0",
"id": 111,
"result": {
"action": "ignore",
"taskId": null,
"reason": "below_min_size"
}
},
"assertions": [
"action ignore means the extension returns {} and Firefox downloads normally",
"the answer still arrives within 750 ms",
"reason is set on every ignore so a puzzled user can find out why"
]
}
@@ -0,0 +1,29 @@
{
"name": "capture.offer \u2014 the daemon does not answer within 750 ms",
"description": "The most important test in this directory. The daemon is slow, wedged or gone; the extension must abandon the offer and let Firefox download the file normally. A download manager that eats downloads when its daemon is down is worse than no download manager, and this behaviour is non-negotiable.",
"kind": "timeout",
"deadlineMs": 750,
"request": {
"jsonrpc": "2.0",
"id": 110,
"method": "capture.offer",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"method": "GET",
"tabUrl": "https://releases.ubuntu.com/26.04/",
"contentType": "application/octet-stream",
"contentLength": 6228541440,
"origin": "moz-extension://11111111-2222-3333-4444-555555555555"
}
},
"response": null,
"assertions": [
"the extension gives up at 750 ms measured from send, not from connect",
"webRequest returns {} so Firefox downloads the file itself",
"no task is created, and the user sees the download in Firefox's own list",
"the extension does not retry: a retry would race the browser's own download",
"the same behaviour applies when the transport is disconnected entirely",
"reproduced against mockd with --slow 2000, and by killing the daemon outright"
],
"requires": "a daemon that is slow, wedged or absent"
}
@@ -0,0 +1,28 @@
{
"name": "download.add \u2014 not enough space to preallocate",
"description": "Preallocating at the final size means the failure happens now, on add, rather than at 97 % after an hour.",
"request": {
"jsonrpc": "2.0",
"id": 106,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 106,
"error": {
"code": -32012,
"message": "not enough free space to preallocate 5.8 GB"
}
},
"assertions": [
"the check is against the actual filesystem holding saveDir, not the home directory",
"the partially created .veloxpart is removed before this error is returned",
"not replayable against a mock: exercised in tests/integration on a small tmpfs"
],
"requires": "a filesystem with no free space"
}
@@ -0,0 +1,29 @@
{
"name": "download.add \u2014 segments above the contract's maximum",
"description": "Params are validated against the schema before any handler runs, so a handler never sees an out-of-range value.",
"request": {
"jsonrpc": "2.0",
"id": 108,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"segments": 64,
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 108,
"error": {
"code": -32602,
"message": "params/segments: value is above the maximum of 32",
"data": {
"path": "params/segments"
}
}
},
"assertions": [
"data.path is a JSON Pointer at the offending field",
"validation happens before the handler, on both transports"
]
}
@@ -0,0 +1,30 @@
{
"name": "download.add \u2014 a destination that escapes the allowed roots",
"description": "Paths are canonicalized before the check, so ../ traversal and symlinks cannot smuggle a write outside saveTo.allowedRoots.",
"request": {
"jsonrpc": "2.0",
"id": 105,
"method": "download.add",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/../../etc",
"startMode": "now"
}
},
"response": {
"jsonrpc": "2.0",
"id": 105,
"error": {
"code": -32011,
"message": "destination is outside the allowed roots",
"data": {
"path": "/home/sami/Downloads/../../etc"
}
}
},
"assertions": [
"the path is canonicalized first: the check is on the resolved path, never the literal string",
"no file, no .veloxpart and no database row is created",
"a symlink whose target escapes the roots is refused the same way"
]
}
@@ -0,0 +1,27 @@
{
"name": "download.get \u2014 an unknown task id",
"description": "The ordinary stale-client case: the GUI asks about a row another client has since removed.",
"request": {
"jsonrpc": "2.0",
"id": 104,
"method": "download.get",
"params": {
"taskId": "00000000-0000-4000-8000-000000000000"
}
},
"response": {
"jsonrpc": "2.0",
"id": 104,
"error": {
"code": -32010,
"message": "no such task",
"data": {
"taskId": "00000000-0000-4000-8000-000000000000"
}
}
},
"assertions": [
"a bulk method reports this per id in failed[] instead of failing the whole call",
"the client's correct response is to drop the row, not to retry"
]
}
@@ -0,0 +1,30 @@
{
"name": "download.probe \u2014 the server answered 403",
"description": "data.httpStatus is what lets the GUI say 'the link has expired' instead of 'probe failed'.",
"request": {
"jsonrpc": "2.0",
"id": 107,
"method": "download.probe",
"params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso?token=expired"
}
},
"response": {
"jsonrpc": "2.0",
"id": 107,
"error": {
"code": -32013,
"message": "probe failed: HTTP 403",
"data": {
"httpStatus": 403
}
}
},
"assertions": [
"data.httpStatus is present whenever there was an HTTP response at all",
"a DNS or connection failure returns -32013 with httpStatus null",
"no task is created by a failed probe",
"not replayable against a mock: exercised in tests/integration against tools/testserver"
],
"requires": "an origin server that answers 403"
}
@@ -0,0 +1,22 @@
{
"name": "an unknown method name",
"description": "Version skew and local port-scanning both look like this. Neither gets a useful reply.",
"request": {
"jsonrpc": "2.0",
"id": 109,
"method": "download.deleteEverything",
"params": {}
},
"response": {
"jsonrpc": "2.0",
"id": 109,
"error": {
"code": -32601,
"message": "no such method"
}
},
"assertions": [
"the reply does not enumerate valid methods",
"an unknown method never closes the connection: a newer client may simply be probing for a capability"
]
}
@@ -0,0 +1,29 @@
{
"name": "session.hello \u2014 an unpaired WebSocket client is refused",
"description": "The WS transport is reachable by any local process, so a token is mandatory there. The Unix socket needs none: SO_PEERCRED already proved same-UID.",
"transport": "ws",
"request": {
"jsonrpc": "2.0",
"id": 101,
"method": "session.hello",
"params": {
"clientType": "extension",
"clientName": "Velox for Firefox",
"protocolVersion": "1.0.0",
"token": "not-a-real-token"
}
},
"response": {
"jsonrpc": "2.0",
"id": 101,
"error": {
"code": -32002,
"message": "not paired: call session.pair first"
}
},
"assertions": [
"only session.pair is served on an unauthenticated WebSocket connection",
"the reply does not reveal whether the token was absent, malformed or merely wrong",
"this failure counts towards the pairing rate limit"
]
}
@@ -0,0 +1,33 @@
{
"name": "session.hello \u2014 a client built against protocol 2.x is refused",
"description": "Major mismatch fails loudly at connect rather than subtly at the tenth field. The GUI renders this as 'Velox needs updating'.",
"request": {
"jsonrpc": "2.0",
"id": 100,
"method": "session.hello",
"params": {
"clientType": "gui",
"clientName": "velox-gui 9.9.9",
"protocolVersion": "2.0.0"
}
},
"response": {
"jsonrpc": "2.0",
"id": 100,
"error": {
"code": -32001,
"message": "protocol major version mismatch: daemon speaks 1.x, client speaks 2.x",
"data": {
"expected": "1.0.0",
"actual": "2.0.0"
}
}
},
"assertions": [
"the connection is closed after this reply; no method is served on a mismatched major",
"a differing minor or patch is accepted, never refused",
"the message is safe to show a user verbatim",
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002"
],
"transport": "uds"
}
@@ -0,0 +1,33 @@
{
"name": "session.pair \u2014 the sixth failed attempt in a minute is locked out",
"description": "Rate limiting is what stops another local process brute-forcing its way to a token.",
"transport": "ws",
"request": {
"jsonrpc": "2.0",
"id": 102,
"method": "session.pair",
"params": {
"clientName": "Velox for Firefox",
"extensionId": "11111111-2222-3333-4444-555555555555",
"code": "0000"
}
},
"response": {
"jsonrpc": "2.0",
"id": 102,
"error": {
"code": -32014,
"message": "too many pairing attempts; try again later",
"data": {
"retryAfterSec": 60
}
}
},
"assertions": [
"five failures per minute, then a 60 s lockout",
"the lockout is per-origin and survives a reconnect, or it is not a lockout",
"no user prompt is shown while locked out \u2014 the prompt itself is the thing being flooded",
"the runner does not brute-force a live daemon; mockd reproduces it under --lockout"
],
"requires": "six failed pairing attempts inside one minute"
}
@@ -0,0 +1,30 @@
{
"name": "settings.set \u2014 a privileged method called over the WebSocket transport",
"description": "The extension may request a download; it may not reconfigure the daemon. Letting it write saveTo.allowedRoots would defeat every path check in the project.",
"transport": "ws",
"request": {
"jsonrpc": "2.0",
"id": 103,
"method": "settings.set",
"params": {
"values": {
"saveTo.allowedRoots": [
"/"
]
}
}
},
"response": {
"jsonrpc": "2.0",
"id": 103,
"error": {
"code": -32003,
"message": "method is not permitted on this transport"
}
},
"assertions": [
"the check happens before params are even parsed",
"every method with x-privileged true behaves identically here",
"nothing is written and no event is emitted"
]
}