Repository navigation
A python-can application opens a cannet bus by naming a trusted server - #466
Merged
Merged
Conversation
Owner
Author
This was referenced Sep 6, 2026
thebhef
force-pushed
the
task136-core-bus
branch
from
September 7, 2026 05:41
d9c7531 to
3ca0c9f
Compare
thebhef
force-pushed
the
feedback-capture
branch
from
September 7, 2026 05:41
6673462 to
76de676
Compare
This was referenced Sep 7, 2026
thebhef
force-pushed
the
graphite-base/466
branch
from
September 7, 2026 19:46
76de676 to
ec94521
Compare
thebhef
force-pushed
the
task136-core-bus
branch
from
September 7, 2026 19:46
3ca0c9f to
91d6c04
Compare
thebhef
changed the base branch from
graphite-base/466
to
task141-dbc-search
September 7, 2026 19:47
This was referenced Sep 7, 2026
This was referenced Sep 15, 2026
This was referenced Sep 21, 2026
thebhef
marked this pull request as ready for review
September 23, 2026 14:47
This was referenced Sep 23, 2026
thebhef
force-pushed
the
task136-core-bus
branch
from
September 23, 2026 15:43
755a114 to
d77d833
Compare
This was referenced Sep 23, 2026
…491) Nothing on the wire said which protocol revision either side spoke. That was survivable while the GUI shipped in the same bundle as the server it talked to; it stopped being survivable once the python client module and its CLI existed and third-party clients became the point. A mismatch presented as `UNIMPLEMENTED`, `connect_flow.rs` had no arm for it, and the retry loop dialled an incompatible server forever — so it looked exactly like a server that keeps flapping. The rule (owner ruling, ADR 0059) is the protobuf package-major convention. `cannet.v1` is the major and it is already in every gRPC method path, so both peers state it on every request; inside a major only additive changes land, and a breaking change is a new package served beside the old one for a deprecation window. Rejected: a version integer with exact match (ADR 0011's pattern — right for a project file, wrong for a wire, because it forces every client to upgrade the day a breaking change ships) and a negotiated min/current range. ADR 0051 §6's "one monotonic Extension API version carried by cannet.proto" is amended to be this package major, so tasks 145 and 69 share one number. `cannet.proto` states the rule in its header — above `syntax`, because protoc folds a declaration's leading comments into every language's generated docstrings and the first placement put forty lines of policy into `CannetServerStub.__doc__` — and `Envelope` reserves the tag-9 hole so nothing can pick it up later. `proto/cannet_info.proto` is a second file declaring `package cannet;`, deliberately unversioned and frozen, carrying one unary `ServerInfo` that answers with the packages served, the build version and the instance name. Outside every major on purpose: a server that has dropped `cannet.v1` still has to be able to tell a `cannet.v1` client so. `cannet_wire::PROTOCOL_PACKAGE` is the one string both sides compare. Every server mode answers it — the proxy, `debug replay`, `debug vbus`, and `serve_virtual_bus_ephemeral` — as does the python sidecar, whose stubs are regenerated from both protos. `cannet-client` calls it inside `ConnectConfig::client()`, the single choke point `list_interfaces`, `watch_interfaces` and `connect_and_subscribe` already shared, and refuses with `ConnectionError::IncompatibleProtocol`. `UNIMPLEMENTED` on any RPC becomes `ConnectionError::Unimplemented`; `classify` sends both to `Outcome::Fatal`, so neither retries. Both read "serves cannet.v2; this client speaks cannet.v1". `ServerInfo` is unauthenticated, so a client that cannot speak to the server at all is told that rather than that its token is wrong. That forced a real change: the token gate was a server-wide `Server::layer`, and a tonic interceptor is handed a `Request<()>` whose URI has already been dropped, so it cannot tell which service a call is for. The gate moved onto the services (`auth::gated`), and `tests/auth.rs` is now what holds the line — it asserts each gated RPC refuses an absent and a wrong token, and that `ServerInfo` answers with neither. mDNS gains `proto=`, the comma-separated packages served; `ver=` stays the build string it always was. Advisory — absent for a hand-added server and for `--no-mdns` — so the Servers panel greys out a row it cannot speak to before dialling it, and `ServerInfo` on the connection is the gate. `run_proxy`'s advertisement block moved into `advertise()` to stay under the 100-line clippy limit. CI gains two jobs. `wire-breaking` runs `buf breaking` (pinned 1.47.2, CI-only, no codegen role) against the last release tag — `GIT_LFS_SKIP_SMUDGE` is set because buf clones this repository and has no LFS endpoint for the example captures it does not need. `wire-gencode` runs `scripts/regen-proto-gencode.sh`, which drives every package's own `scripts/regen_proto.sh` found under `servers/` and `libs/`, then fails on any diff; nothing checked that the committed stubs still matched the proto before. `regen_proto.sh` gained `newline=""` on its import-rewrite step, without which a Windows regeneration rewrites every line of the gencode and that job can never be green. Tests, written against a fake server that serves only `cannet.v2` while serving the real `cannet.v1` service, so the refusal cannot be an accident of a missing RPC: `crates/cannet-client/tests/protocol_gate.rs` covers the sentence, all three entry points, a server with no `ServerInfo` at all, a server serving both majors (accepted — that is the deprecation window), and an ordinary server. Every existing test server in the workspace had to mount `ServerInfo`; without it they fail with `Unimplemented("")`, which is how the gate was falsified before the new tests were written. Two inherited breakages fixed in passing rather than reported and left: `apps/gui/src-tauri/src/interfaces.rs` was not rustfmt-clean (7d18a42 — CI runs no `cargo fmt --check`, only the pre-commit hook, which `--no-verify` skips), and three files carried CRLF against LF blobs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
#466) `clients/cannet-python-client` is a uv-managed sibling of the sidecar that registers `CannetBus(can.BusABC)` under python-can's `can.interface` entry-point group, so `can.Bus(interface="cannet", server=..., channel=...)` opens a remote bus with no cannet-specific import and no credential pasted into application code. Full BusABC citizenship: `_recv_internal` (so `recv`, iteration and `Listener`/`Notifier` work), `send`, `shutdown`, `state`, `channel_info`. `server=` is resolved against the GUI's own trust store — `servers.json` in the platform config dir under `dev.cannet.app` — whose location and schema are now documented as a read contract, in the package README and in `trust.py`. The library never writes it: accepting a server means comparing a fingerprint against what the server printed, and only a person can do that. The plan it derives is the GUI's (ADR 0041): loopback is plaintext whatever is stored, a pin never degrades to plaintext, a plaintext target carries no token, and an unknown non-loopback server raises rather than inventing an answer. Pinning is by certificate rather than by fingerprint, because Python's gRPC exposes no verifier hook: the library fetches the server's certificate, checks its SHA-256 against the stored pin, and hands that exact certificate to gRPC as the channel's sole root — equivalent in what it accepts. A second handshake, trusting the fetched certificate as its own root, reads back the SAN list so the target-name override names something the certificate actually carries. Reviewers should note this path has no end-to-end coverage: both hardware-free debug servers terminate no TLS, so the fingerprint form, the pin comparison and the name choice are unit-tested and the handshake is not. Session semantics are `cannet-client`'s, so a hardware server sees the same client whichever language is talking to it. `ConfigureBus` precedes the `Subscribe` it configures. A bare `virtual:` id is a factory and waits for its `InterfaceAllocated` (ADR 0021). The three per-frame codes are tallied on `bus.rejections` and the stream goes on; everything else, including a code this build does not recognise, ends the session and is raised. One consequence worth reading twice: the wire does not acknowledge an ordinary `Subscribe`, so an unknown interface surfaces on the first `recv` rather than out of the constructor — waiting on a reply a server has no reason to send would trade a working session for a stall. Everything both ends of the wire have to agree on is a package of its own, `libs/cannet-python-wire`: the checked-in `_proto` gencode and its `regen_proto.sh`, the `Frame`/`FrameKind` pair, and the four mappers — `message_to_frame` / `frame_to_message` either side of a python-can `Message`, `frame_to_proto` / `proto_to_frame` either side of the generated type. The sidecar and the client both take it as a path dependency, so there is one encoding of the wire in this repository and nothing for a second copy to drift from. The cut is at the seam and not past it: the hardware driver, its adapters and the gRPC service stay in the sidecar, and `driver.py` re-exports `Frame`/`FrameKind` because they remain part of the contract an alternative-driver author reads. A client that never speaks to a sidecar does not depend on one. `frame_to_message` leaves timestamp and direction unset — it is the transmit direction, where neither means anything — so the receive path supplies both. Tests are hardware-free throughout: the units cover the trust-store contract, the fingerprint form, the envelope order and the batch routing; the integration half opens real buses against `cannet-server debug vbus` and `debug replay` on explicit loopback, and skips when the binary is not built. A `python-client` CI job builds the server and runs them for real, and four pre-commit hooks mirror the sidecar's. The wire package carries the tests of the code in it — the kind ladder, the proto round-trip, the timestamp fallbacks — and gets the same four hooks and its own `python-wire` lane; the sidecar's and the client's mypy/pytest hooks watch its paths too, because a change there can break either. All three Python packages hold ruff below 0.16. Adding a package beside the sidecar at 0.16 would have put two ruffs in the tree that do not agree about the same code: ruff 0.16 *changes* its default rule selection rather than only widening it, dropping E4xx and adding `I`/`UP`/`S`/`PL`/`RUF`. Under 0.16 the sidecar reports 213 findings, among them 50 deliberate `# noqa: BLE001` / `E402` suppressions that `RUF100` deletes and five `S110`/`S112` best-effort `except: pass` shutdown paths whose only remedy is to start logging. Moving every package to 0.16 is that change, not this one. Also drops 17 inherited `(task 129)` references from `apps/gui/src/` comments (from #450). `CLAUDE.md` forbids naming a task in source; every one was a bare parenthetical carrying nothing else, so it goes. The client asks `ServerInfo` before it opens a session (ADR 0059), on the channel it has just dialled and before any credential goes out, and raises `IncompatibleProtocol` — "serves cannet.v2; this client speaks cannet.v1" — when the server's package list does not hold ours. A server that does not implement `ServerInfo` at all is refused the same way: it predates the rule, so nothing it answers can be read as agreement about the wire. `cannet_python_wire` grows `PROTOCOL_PACKAGE`, read off the gencode's own descriptor rather than written out, so the sidecar and the client compare against the package their stubs were actually generated from; the sidecar's `info.py` reads it from there instead of carrying its own copy. The wire package pins `grpcio-tools` to 1.80.x, and that pin is load-bearing rather than tidiness: every generated stub records the toolchain that emitted it and refuses to import under an older runtime, and the consumers lock grpcio 1.80.0 / protobuf 6.33.6. The lock as first resolved here reached grpcio-tools 1.84 while the committed gencode came from 1.80 — so the stubs were not reproducible from the lock, and regenerating under it produced `_pb2_grpc.py` demanding grpcio >= 1.81.1 and `_pb2.py` carrying protobuf 7.x gencode, both of which import-error in the sidecar and the client. Nothing caught that before, because nothing regenerated; the gencode-drift CI job does now. `technology-inventory.md` records the pin and the rule it comes from beside the existing grpcio entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
thebhef
force-pushed
the
task136-core-bus
branch
from
September 23, 2026 16:46
d77d833 to
5b8f5a2
Compare
This was referenced Sep 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

servers/cannet-python-clientis a uv-managed sibling of the sidecarthat registers
CannetBus(can.BusABC)under python-can'scan.interfaceentry-point group, so
can.Bus(interface="cannet", server=..., channel=...)opens a remote bus with no cannet-specific import and no credential
pasted into application code. Full BusABC citizenship:
_recv_internal(so
recv, iteration andListener/Notifierwork),send,shutdown,state,channel_info.server=is resolved against the GUI's own trust store —servers.jsonin the platform config dir under
dev.cannet.app— whose location andschema are now documented as a read contract, in the package README and
in
trust.py. The library never writes it: accepting a server meanscomparing a fingerprint against what the server printed, and only a
person can do that. The plan it derives is the GUI's (ADR 0041):
loopback is plaintext whatever is stored, a pin never degrades to
plaintext, a plaintext target carries no token, and an unknown
non-loopback server raises rather than inventing an answer.
Pinning is by certificate rather than by fingerprint, because Python's
gRPC exposes no verifier hook: the library fetches the server's
certificate, checks its SHA-256 against the stored pin, and hands that
exact certificate to gRPC as the channel's sole root — equivalent in
what it accepts. A second handshake, trusting the fetched certificate as
its own root, reads back the SAN list so the target-name override names
something the certificate actually carries. Reviewers should note this
path has no end-to-end coverage: both hardware-free debug servers
terminate no TLS, so the fingerprint form, the pin comparison and the
name choice are unit-tested and the handshake is not.
Session semantics are
cannet-client's, so a hardware server sees thesame client whichever language is talking to it.
ConfigureBusprecedesthe
Subscribeit configures. A barevirtual:id is a factory andwaits for its
InterfaceAllocated(ADR 0021). The three per-frame codesare tallied on
bus.rejectionsand the stream goes on; everything else,including a code this build does not recognise, ends the session and is
raised. One consequence worth reading twice: the wire does not
acknowledge an ordinary
Subscribe, so an unknown interface surfaces onthe first
recvrather than out of the constructor — waiting on a replya server has no reason to send would trade a working session for a
stall.
Frames are encoded by the sidecar's own mappers, reached through a path
dependency and four new public aliases there. There is one encoding of
the wire in this repository and nothing for a second copy to drift from.
frame_to_messageleaves timestamp and direction unset — it is thesidecar's transmit path, where neither means anything — so the receive
path supplies both.
Tests are hardware-free throughout: the units cover the trust-store
contract, the fingerprint form, the envelope order and the batch
routing; the integration half opens real buses against
cannet-server debug vbusanddebug replayon explicit loopback, and skips when thebinary is not built. A
python-clientCI job builds the server and runsthem for real, and four pre-commit hooks mirror the sidecar's.
Also drops 17 inherited
(task 129)references fromapps/gui/src/comments (from #450).
CLAUDE.mdforbids naming a task in source; everyone was a bare parenthetical carrying nothing else, so it goes.