Uh oh!
There was an error while loading. Please reload this page.
feat(cli): control-parity subcommands + peer management (#426 #559) - #45
Merged
Conversation
Add a `dig-node`/`dign` CLI verb for every gated `control.*` method the DIG Chrome extension drives, plus `peers` for viewing + managing peer connections — each with `--json` machine output (§6.2) and thin-dispatched over the ONE loopback control plane (no forked logic). - New shared `control_client::call_control` — reads the master control token read-only (#501) and POSTs a `control.*` method to loopback; the single transport `pair`, `control_cli`, and `peers` all use. `pair.rs` refactored onto it (dedupe). - `control_cli` — info/config/cache/stores/sync/updater/subscriptions actions, each mapping to the SAME control method the extension calls. Mutations go through the identical master-token gate as the WS surface (no backdoor). - `peers` (#559) — list (control.peerStatus) with IPv6-first address display (§5.2) + connect/disconnect/ban/pool-config verbs matching the extension. - Parity enforced mechanically: `control::CONTROL_METHODS` is the canonical set and a drift test asserts every method has a CLI verb. - SPEC §8.6/§8.7, CHANGELOG; version 0.36.0 → 0.37.0 (minor: additive CLI). Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Uh oh!
There was an error while loading. Please reload this page.
MichaelTaylor3d added a commit
that referenced
this pull request
Jul 17, 2026
…the control method table (#711) Two control-plane hardening follow-ups from the #45 CLI-parity gates: 1. no_proxy pin: the operator loopback control client now builds its reqwest client with `.no_proxy()`, so a hostile `HTTP_PROXY`/`HTTPS_PROXY` in the environment can no longer route the token-bearing `control.*` POST through an attacker-controlled proxy. reqwest's default proxy behaviour has no automatic loopback bypass; the control plane is loopback-only + token-gated, so the transport is now pinned DIRECT to 127.0.0.1/::1. 2. CONTROL_METHODS <-> dispatch_control lockstep: dispatch_control now routes owned methods via the new OWNED_CONTROL_METHODS routing const (delegating the rest to the node), and a lockstep test asserts CONTROL_METHODS equals OWNED_CONTROL_METHODS + DELEGATED_CONTROL_METHODS exactly (disjoint union). This closes the shell-owned-method drift gap the cli_covers_* test leaves open. The owned match's `_` arm is unreachable by construction. TDD: both tests verified RED first (proxy test dialed the dead proxy; partition test caught an injected drift entry). fmt + clippy -D + full lib suite (213) green. Version bumped 0.38.1 -> 0.38.2 (patch: defense-in-depth hardening, no public-API or behaviour change to the shipped surface). SPEC §7.3 updated. Refs #426 #501 Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit
that referenced
this pull request
Jul 17, 2026
…/dispatch lockstep guard (#711) (#49) * chore: wip control-client no_proxy + lockstep guard (#711) Co-Authored-By: Claude <noreply@anthropic.com> * fix(control): pin control-client to direct loopback + lockstep-guard the control method table (#711) Two control-plane hardening follow-ups from the #45 CLI-parity gates: 1. no_proxy pin: the operator loopback control client now builds its reqwest client with `.no_proxy()`, so a hostile `HTTP_PROXY`/`HTTPS_PROXY` in the environment can no longer route the token-bearing `control.*` POST through an attacker-controlled proxy. reqwest's default proxy behaviour has no automatic loopback bypass; the control plane is loopback-only + token-gated, so the transport is now pinned DIRECT to 127.0.0.1/::1. 2. CONTROL_METHODS <-> dispatch_control lockstep: dispatch_control now routes owned methods via the new OWNED_CONTROL_METHODS routing const (delegating the rest to the node), and a lockstep test asserts CONTROL_METHODS equals OWNED_CONTROL_METHODS + DELEGATED_CONTROL_METHODS exactly (disjoint union). This closes the shell-owned-method drift gap the cli_covers_* test leaves open. The owned match's `_` arm is unreachable by construction. TDD: both tests verified RED first (proxy test dialed the dead proxy; partition test caught an injected drift entry). fmt + clippy -D + full lib suite (213) green. Version bumped 0.38.1 -> 0.38.2 (patch: defense-in-depth hardening, no public-API or behaviour change to the shipped surface). SPEC §7.3 updated. Refs #426 #501 Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
MichaelTaylor3d added a commit
that referenced
this pull request
Aug 21, 2026
…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>MichaelTaylor3d added a commit
that referenced
this pull request
Aug 21, 2026
…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>MichaelTaylor3d added a commit
that referenced
this pull request
Aug 21, 2026
…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>MichaelTaylor3d added a commit
that referenced
this pull request
Aug 21, 2026
…keep the service stoppable
Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no
code.
CORS (#702) is now decided per request rather than once for the router. Local web/extension origins
keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator
has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read
methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and
content on GET, so a route-keyed decision must answer both traffic classes the same way. That is
what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content
read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are
judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a
preflight declaring no method fails closed.
The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard.
The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to
confirm; it now takes the DIG address, falling back only where that address cannot be bound at all
and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test
fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a
literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the
guard with their reasons rather than left to memory.
The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and
does report a STOP-accepting mask. The stop was bridged into the serve future by
`spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's
synchronous database work draws from. With the pool saturated the receiving task never ran, so an
accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it.
That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms.
The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so
a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as
a failed run rather than a clean one.
One defect found by CI and fixed at the call site, not by widening the check that caught it. The new
ephemeral bind first built its URL as `format!("http://{host}:{port}/...")`, which dig-node-core's
`banned_address_patterns` sweep correctly rejects: text concatenation loses the brackets every IPv6
literal needs. The URL is now formatted from the `SocketAddr` itself, whose Display brackets v6 and
leaves v4 alone, and the bind helper returns only the listener so the advertised address can no
longer disagree with the bound one.
The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*`
(PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing
`user_managed` writer, and the help text already names the custody grant. No env var or config key
was added on purpose — that would be a second configuration path into write authority.
Closes #767
Closes #702
Closes #2880
Closes #2870
Co-Authored-By: Claude <noreply@anthropic.com>MichaelTaylor3d added a commit
that referenced
this pull request
Aug 21, 2026
…keep the service stoppable (#291) * feat(node): scope CORS per route+method, hold the DIG loopback rule, keep the service stoppable Three fixes that share `dig-node-service`, plus a measurement that says the fourth ticket needs no code. CORS (#702) is now decided per request rather than once for the router. Local web/extension origins keep the whole surface; desktop-app origins are reflected for content reads only. The discriminator has to be the method, not the route: `POST /` multiplexes content reads and the open wallet-read methods onto one JSON-RPC endpoint, and `/{method}` serves the Sage-parity wallet RPC on POST and content on GET, so a route-keyed decision must answer both traffic classes the same way. That is what #693 deferred. Every open wallet-read method is reached by POST and every cross-origin content read is a GET, so the split closes the wallet-read reach with #669 left intact. Preflights are judged against `Access-Control-Request-Method` so the preflight answer matches the real one, and a preflight declaring no method fails closed. The DIG loopback rule (#767) gains a single source of truth in `loopback.rs` and a build-time guard. The ephemeral content server was still on `127.0.0.1` despite the P0 #745 fix the ticket asks to confirm; it now takes the DIG address, falling back only where that address cannot be bound at all and logging when it does. The guard fails the build on a new literal-loopback bind, ignoring test fixtures and dials, and it immediately found two binds a manual sweep had missed. Three sites keep a literal address for cross-repo dial-contract or crate-layering reasons and are enumerated in the guard with their reasons rather than left to memory. The SCM 1061 wedge (#2880) was neither of its two usual causes: the service does reach RUNNING and does report a STOP-accepting mask. The stop was bridged into the serve future by `spawn_blocking(recv)`, putting it on tokio's blocking pool — the same pool the wallet replica's synchronous database work draws from. With the pool saturated the receiving task never ran, so an accepted stop was never observed and the service kept serving HTTP while the SCM could not stop it. That is also why the wedge correlated with the frozen replica: one blocked resource, two symptoms. The stop is now a watch signal delivered by the runtime itself, and graceful shutdown is bounded, so a body that will not wind down no longer holds the service RUNNING and a forced stop is reported as a failed run rather than a clean one. One defect found by CI and fixed at the call site, not by widening the check that caught it. The new ephemeral bind first built its URL as `format!("http://{host}:{port}/...")`, which dig-node-core's `banned_address_patterns` sweep correctly rejects: text concatenation loses the brackets every IPv6 literal needs. The URL is now formatted from the `SocketAddr` itself, whose Display brackets v6 and leaves v4 alone, and the bind helper returns only the listener so the advertised address can no longer disagree with the bound one. The trusted-Chia-peer surface (#2870) is already shipped and no code was added: `control.chiaPeers.*` (PR #248) and `dign chia-peers` (PR #45) already wire the user-facing surface to the existing `user_managed` writer, and the help text already names the custody grant. No env var or config key was added on purpose — that would be a second configuration path into write authority. Closes #767 Closes #702 Closes #2880 Closes #2870 Co-Authored-By: Claude <noreply@anthropic.com> * fix(service): exit on a forced stop instead of blocking in the runtime drop SEC-1 (gating): the forced-stop branch reported `Stopped` to the SCM and then returned, dropping the tokio runtime. `Runtime::drop` joins the blocking pool with no timeout, and a forced stop is by definition the case where a blocking task never finished, so the drop blocked forever AFTER the SCM had been told the service was stopped. Measured on the pinned tokio 1.53.0: still blocked after 8.03s, then 50.19ms once the wedged closure was released. That is a privileged action reporting success without taking effect: `sc stop` succeeds, the service is marked stopped, and the process stays alive holding its binary image locked - the exact symptom dig_ecosystem#2880 exists to remove. It also leaves an SCM-stopped-but-alive state from which a `StartService` yields a second process against the same wallet replica. A forced stop now abandons the pool (`shutdown_background`) and exits. The graceful branch, the watch-based stop signal and the 20s deadline are unchanged. SEC-2 (low): `Access-Control-Allow-Methods` now mirrors the requested method. tower-http emits it on every answered preflight independent of the origin verdict, so a static [GET, POST, OPTIONS] answered an approved app-origin GET preflight by also advertising POST, seeding the browser preflight cache with a POST entry a later `POST /` could use to skip its preflight. SEC-3: narrow two overclaiming sentences. The sandboxed-blob comment claimed origin isolation from "anything else"; cookies ignore port, so the blob shares the 127.0.0.2 cookie jar with the bare-IP content surface. The SPEC's app-origin scoping sentence is true of Access-Control-Allow-Origin, not of the whole response. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
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 freeto 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.
Closes #426 #559
TLDR
The
dig-node/dignCLI now has a subcommand for every node control the DIG Chrome extension can drive (over the gatedcontrol.*WS surface), pluspeersfor viewing + managing peer connections — all with--jsonmachine output. Each subcommand is a THIN dispatch over the SAME control method the extension calls, through one shared loopback client; no logic is forked from the control plane.Control-surface parity audit (#426)
Canonical set =
control::CONTROL_METHODS(the union of shell-owned + node-delegated control methods). Coverage:infostatus, the unauth/healthliveness probe)config [get]/config set-upstream <url>cache [get]/cache set-cap <bytes>/cache clearstores [list]/stores pin|unpin|status <store>sync [status]/sync trigger <store>updater status|set-channel|pause|resume|check-nowsubscriptions add|remove|listpair list|approve|revokepeers [list]A drift test (
cli_covers_every_node_control_method) asserts every method inCONTROL_METHODShas a CLI verb — so a new node control method cannot ship without a CLI subcommand.Peer commands (#559)
peers [list]→control.peerStatus— running flag, connected count, relay reservation, and (when a newer node fills it) the per-peer list. Addresses displayed IPv6-first per §5.2.peers connect\|disconnect <peer>,peers ban <peer> --state <ban\|blacklist\|none>,peers pool-config --max-connections <n>— match the extension'speersApi.tssurface.Auth-gate handling
Every mutating CLI control goes through the identical gate as the WS surface: the shared
control_client::call_controlpresents the node's MASTER control token (read WITHOUT minting, #501) asX-Dig-Control-Tokenover the loopback endpoint. Possession of the on-disk master token = local-machine control — no unauthenticated backdoor.pair.rswas refactored onto this shared client (dedupe).--jsoncoverageEvery new subcommand shows in
--help, emits the standard{ok,action,service,version,...result}success /{ok:false,error:{code,exit_code,...}}error envelope under--json, and uses the existing differentiated exit-code table.Cross-repo drift flagged (follow-up, NOT fixed here)
The extension defines
control.peers.connect/disconnect/setBan/setPoolConfig+ an extendedcontrol.peerStatus(peers[]/bans[]/pool), but the node has not implemented them (documented gap inpeersApi.ts). This CLI adds the matching verbs so the surface is at parity, but they currently return the node's METHOD_NOT_FOUND until the node ships the peer-management RPCs (dig-nat/dig-gossip AddressManager wiring). Recommend a dig-node follow-up ticket to implement the node-side peer-management control methods + the extendedcontrol.peerStatuspayload; the CLI + extension light up with no client change once it lands.SemVer
minor (0.36.0 → 0.37.0) — additive, backwards-compatible new CLI capability.
Verification
cargo build -p dig-node-servicegreen;cargo clippy -p dig-node-service --all-targetszero warnings;cargo fmtclean.cargo test -p dig-node-service --lib→ 201 passed, 0 failed (incl. the parity drift test, params/summary tests, and the IPv6-first peer-display tests).dig-node --helpshows all new subcommands.gitnexus blast radius
Scoped to
dig-node-service: new modulescontrol_client/control_cli/peers; edits toentrypoint.rs(subcommand tree),control.rs(+CONTROL_METHODS),pair.rs(refactor onto shared client),lib.rs(module registration). No change to the node engine (dig-node-core) or the control-plane dispatch behaviour — the CLI only reaches existing methods over the existing loopback endpoint.Co-Authored-By: Claude noreply@anthropic.com