Skip to content

feat(cli): control-parity subcommands + peer management (#426 #559) - #45

Merged
MichaelTaylor3d merged 2 commits into
mainfrom
feat/cli-control-parity
Jul 17, 2026
Merged

feat(cli): control-parity subcommands + peer management (#426 #559)#45
MichaelTaylor3d merged 2 commits into
mainfrom
feat/cli-control-parity

Conversation

@MichaelTaylor3d

Copy link
Copy Markdown
Contributor

Closes #426 #559

TLDR

The dig-node/dign CLI now has a subcommand for every node control the DIG Chrome extension can drive (over the gated control.* WS surface), plus peers for viewing + managing peer connections — all with --json machine 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:

control.* methodCLI verbstatus
control.statusinfoadded (distinct from status, the unauth /health liveness probe)
control.config.get / setUpstreamconfig [get] / config set-upstream <url>added
control.cache.get / setCap / clearcache [get] / cache set-cap <bytes> / cache clearadded
control.hostedStores.list / pin / unpin / statusstores [list] / stores pin|unpin|status <store>added
control.sync.status / triggersync [status] / sync trigger <store>added
control.updater.status / setChannel / pause / resume / checkNowupdater status|set-channel|pause|resume|check-nowadded
control.subscribe / unsubscribe / listSubscriptionssubscriptions add|remove|listadded
control.pairing.list / approve / revokepair list|approve|revokealready existed (#280)
control.peerStatuspeers [list]added (#559)

A drift test (cli_covers_every_node_control_method) asserts every method in CONTROL_METHODS has 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's peersApi.ts surface.

Auth-gate handling

Every mutating CLI control goes through the identical gate as the WS surface: the shared control_client::call_control presents the node's MASTER control token (read WITHOUT minting, #501) as X-Dig-Control-Token over the loopback endpoint. Possession of the on-disk master token = local-machine control — no unauthenticated backdoor. pair.rs was refactored onto this shared client (dedupe).

--json coverage

Every 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 extended control.peerStatus (peers[]/bans[]/pool), but the node has not implemented them (documented gap in peersApi.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 extended control.peerStatus payload; 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-service green; cargo clippy -p dig-node-service --all-targets zero warnings; cargo fmt clean.
  • 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 --help shows all new subcommands.

gitnexus blast radius

Scoped to dig-node-service: new modules control_client/control_cli/peers; edits to entrypoint.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

MichaelTaylor3dand others added 2 commits July 16, 2026 17:05
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>
@MichaelTaylor3d
MichaelTaylor3d merged commit ed0e10a into mainJul 17, 2026
13 checks passed
@MichaelTaylor3d
MichaelTaylor3d deleted the feat/cli-control-parity branch July 17, 2026 00:27
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>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@MichaelTaylor3d