* fix(cli): announce when nodes list degrades to paired-only data
tryReadNodeList swallowed every enrichment failure, so the unfiltered nodes list silently rendered a table without connected/commands state. The fallback is now announced on stderr, keeping --json stdout parseable.
* fix(gateway): silently widen local pairing scopes as documented
`autoApproveLocal` has documented "silently approves pairing, role upgrades,
and scope upgrades from trusted local connections" since the loopback
auto-approval landed, but a later hardening pass forced every scope-upgrade
pairing request non-silent. That block protected nothing locally — silent
initial pairing grants a fresh identity arbitrary requested scopes, so any
local process could mint a new keypair instead of upgrading — while it
stranded every row-authorized client (CLI, native apps, node hosts) on a
manual approval no local surface could perform.
Scope upgrades now ride the same silent-local rule as initial pairing, with
one new restriction that encodes the real boundary: the connect must itself
prove local-grade credentials (auth mode none, or the shared token/password).
Identity-proxy connects (tailscale, trusted-proxy) and bearer device tokens
never did, so their pairing rows remain a durable scope cap, and
`autoApproveLocal: false` still forces manual approval for everything.
The silent self-grant also approves the union of requested plus already-held
scopes: approval merges the existing row back in, so a client requesting only
its missing scope no longer fails the caller-authority check.
The decision surface shrinks with the behavior change: the scope-upgrade veto
contradicted shouldAllowSilentLocalPairing's answer, the CLI-container
locality was a duplicate of the shared-secret-loopback predicate, and three
classifiers re-derived the same shared-secret auth check.
Live-verified on an isolated auth-none loopback gateway: a CLI identity
paired at operator.pairing silently widens to operator.read on the next wider
command, with the scope-upgrade security audit line still emitted.
* test(gateway): rewrite veto-era pairing locks for silent local widening
Five control-ui pairing suite cases and the silent-scope-upgrade poc locked
the removed non-silent veto. The suite cases now assert the new invariant
(local shared-auth upgrades widen silently, malformed and legacy-shaped rows
are repaired by the fresh approval, node-then-operator grants complete
without a stranded prompt), and the poc case now exercises the surviving
manual-approval gate by disabling autoApproveLocal after its watcher
connects, keeping the pairing-request broadcast and remediation-hint
assertions alive on a real remaining path. The voice-node bootstrap failure
was leakage from the aborted sibling tests, and passes again once they
complete their flows.
* fix(computer-use): unblock the macOS live-rig proof flow
The rig ran its operator CLI and its proof runner from one state dir, so both
shared one device identity. A paired operator device is pinned to the scopes of
its first connect, and `nodes list` connects first for `node.pair.list`
(operator.pairing); the proof runner then needs operator.write, which is a scope
upgrade the gateway never approves silently and which no rig client can approve
for itself. The proof runner is a GATEWAY_CLIENT/BACKEND client, so on a
loopback auth-none gateway it is admitted unpaired with the scopes it asks for:
giving the CLI its own `cli-state` identity is enough, and `agent-state` now
never accumulates a pairing row.
`nodes list` also read `node.list` through the plain CLI client while
`nodes status`/`describe` used the diagnostics ladder. On any gateway where the
CLI must pair, the unfiltered list silently dropped connected/commands/
computerUse and `--connected` failed outright, so the documented rig gate could
not confirm the node. Both call sites now use `callNodeDiagnosticsGatewayCli`.
Docs drop the `devices approve <requestId>` instruction, which was circular:
that invocation is its own new device identity.
* test(cli): share the runtime-log formatter across nodes CLI e2e files
The extracted diagnostics-auth file stringified captured log arguments directly, which the type-aware core lint stripe rejects (no-base-to-string). Move the existing formatter into the shared node test helpers instead of duplicating it.
Four nodes-CLI repairs at their owners:
- nodes status --last-connected joined a second pairing-scoped RPC
(node.pair.list) to reconstruct last-connection times the gateway already
records on every node.list row (lastConnectedAtMs, max of stored pairing
history and live connection, since 2dcd47d4f4). The client-side join also
preferred stored history over a live connection instead of taking the max,
and made the command fail for callers whose auth grants operator.read but
not operator.pairing. The filter now reads the recorded fact;
connectedAtMs covers gateways predating it.
- nodes list --connected blanked pending rows and then printed "Pending: 0"
while requests waited — asserting a fact it never checked. Pending rows
carry no connection state to filter on; they now always show.
- nodes remove/rename carried unreachable !nodeId guards (resolveCliNodeId
throws "node required" on blank input and every parse layer drops id-less
rows) whose dead text pointed at the wrong command (nodes pending targets
pairing requests, not paired nodes). Guards deleted; the reachable empty
--name branch now hints at nodes list.
- status.node-mode derived node-service liveness from the formatted
runtimeShort display string — parsing a fact back out of its own
projection, dead in all first-party call paths (both callers pass the
runtime object it is derived from). Field and branch deleted.
e2e test updated to pin the single-RPC shape (asserts node.pair.list is NOT
called); node-mode fixtures now use the production shape.
* fix(cli): keep nodes list aligned with nodes status
* fix(clownfish): address review for ghcrawl-156588-autonomous-smoke (1)
* fix(cli): keep nodes list aligned with nodes status