Skip to content

feat: sphere-cli phase 2 — legacy bridge + host DM transport#1

Merged
vrogojin merged 16 commits intomainfrom
feat/import-from-sphere-sdk
Apr 24, 2026
Merged

feat: sphere-cli phase 2 — legacy bridge + host DM transport#1
vrogojin merged 16 commits intomainfrom
feat/import-from-sphere-sdk

Conversation

@vrogojin
Copy link
Copy Markdown
Contributor

Summary

  • Phase 2 legacy bridge: All sphere-sdk CLI namespaces (wallet, balance, payments, dm, group, market, swap, invoice, nametag, crypto, util, faucet, daemon, config, completions) delegated to the sphere-sdk legacy dispatcher via buildLegacyArgv() translation
  • DM transport layer (src/transport/): DmTransport correlator-map pattern over Sphere DMs; lazy pubkey resolution; early-message buffer + replay; full test suite (13 tests)
  • sphere host command tree (src/host/): 10 HMCP-0 subcommands (spawn, list, stop, start, inspect, remove, pause, resume, help, cmd) wired as DM-native commander subcommands
  • Two steelman rounds: All criticals and warnings addressed — dangerous-key rejection at parse time, UTF-8 byte length cap, exit-code propagation, dispose() iteration safety, type-safe config parsing

Test plan

  • npx tsc --noEmit — passes clean
  • npx vitest run — 21/21 tests pass
  • sphere wallet status — delegates to legacy sphere-sdk CLI
  • sphere host list --manager @mymanager — sends HMCP hm.list DM
  • sphere host spawn --manager @mymanager --name mybot tpl-1 — sends hm.spawn, streams ack+ready
  • sphere --version — outputs package version

Vladimir Rogojin added 11 commits April 22, 2026 14:32
- Copy sphere-sdk/cli/{index,daemon,daemon-config}.ts → src/legacy/
- Rewrite all relative imports to @unicitylabs/sphere-sdk package paths
- Fix 8 TypeScript errors: flags→args.includes(), readonly mutations via
  intermediate Record, inline import() → top-level type imports
- Prefix unused _sphere vars to satisfy no-unused-vars
- Wire all 15 legacy namespaces to dynamically import legacyMain()
- Phase 4 stubs (host, tenant) still show "not implemented yet"
- Update tests to reflect phase 2 behavior (legacy bridge, phase 4 stubs)
- npm run check: lint + typecheck + 8 tests all green
Dispatcher & argv (src/index.ts)
- buildLegacyArgv(): translate commander namespace+subcommand to the exact
  legacy switch/case command the dispatcher expects; fixes 7 namespaces
  (payments, group, market, swap, invoice, crypto, util) that previously
  produced "Unknown command" on every invocation
- program.exitOverride(): commander now throws CommanderError instead of
  calling process.exit(), making --help/--version safe in tests
- main() catch: handle CommanderError by code, sanitize error messages with
  mnemonic-redaction regex before writing to stderr

Legacy CLI (src/legacy/legacy-cli.ts)
- legacyMain(argv: string[]): accept argv param; set module-level args/command
  per-call so second invocations don't reuse frozen import-time state
- writeAtomic(): temp+rename pattern for saveConfig, saveProfiles, invoice-
  export; prevents truncated files on SIGKILL mid-write
- isTTY guard on init mnemonic display: mnemonic suppressed (warning to stderr)
  when stdout is not a terminal, preventing leak into CI logs or piped files
- isTTY guard on generate-key --unsafe-print: refuses to print private key to
  non-TTY stdout
- stripDangerousKeys(): recursive prototype-pollution guard applied to all
  user-supplied JSON before SDK entry (invoice-create, invoice-import)
- process.exit override: installs a shim in main() that calls sphere.destroy()
  before exiting, covering all ~25 in-handler process.exit(1) calls without
  touching each site; ensures Nostr WebSockets, IPFS, SQLite always close
- nametag catch: exits 1 on registration failure so scripts see the error
- mnemonic scrubbing comment: honest explanation that /proc/cmdline is NOT
  cleared by mutating the args slice

Daemon (src/legacy/daemon.ts)
- writePidFileExclusive(): fs.openSync('wx') for exclusive create; prevents
  two concurrent daemon starts both winning the PID file race
- PID file format: JSON {pid, nonce, cmd:'sphere-daemon'} enables /proc comm
  check to detect PID reuse by unrelated processes (prevents SIGTERM to victim)
- earlyShutdown: SIGTERM/SIGINT registered before any await in runDaemon();
  PID file cleanup even if signal arrives during getSphere() startup
- inflight Set: tracks dispatchRule promises; shutdown awaits allSettled with
  10s timeout before closeSphere() — prevents token state corruption on stop
- logStream flush: log('Daemon stopped.') before end(), await finish event
- process.kill ESRCH: wrapped in try/catch so stopDaemon doesn't throw if
  process died between liveness check and kill
- safeUnlink(): replaces existsSync+unlinkSync TOCTOU patterns everywhere
- child.stdin EPIPE: error handler added in executeBash to silence EPIPE when
  command doesn't consume stdin

Config (src/legacy/daemon-config.ts)
- loadDaemonConfig: distinguishes ENOENT from parse errors; warns to stderr
  on corrupt config rather than silently reverting to defaults
- BashAction.command: security notice added (chmod 600, trust boundary)

bin/sphere.mjs
- Always calls process.exit(code) so open Nostr sockets don't keep process alive
- Dev mode: resolve tsx from node_modules/.bin to avoid PATH injection
- Child process: listen on 'close' (not 'exit') to flush stdout before exiting
Adds src/transport/ with:
- hmcp-types.ts: full HMCP-0 type definitions, constructors, and validator
  (parseHmcpResponse validates structure + dangerous keys + 64 KiB size limit)
- errors.ts: TimeoutError, AuthError, TransportError
- dm-transport.ts: DmTransport interface + createDmTransport factory
  * sendRequest: single-response with correlator + timeout
  * sendRequestStream: multi-response (spawn flow: ack → ready/failed)
  * sender auth via resolved pubkey (lazy on first send)
  * compressed (02/03) pubkey normalisation
  * dispose() cancels all in-flight requests
- dm-transport.test.ts: 13 tests covering happy path, timeout, auth,
  multi-correlator, streaming, dispose, malformed/oversized/poisoned messages
Implements all 10 sphere-host subcommands as real HMCP-0 DM commands,
replacing the phase-4 "not implemented yet" stub:

  sphere host spawn <name> --template <id> [--nametag <n>] [--env K=V...]
  sphere host list [--state <filter>]
  sphere host stop <name> [--id]
  sphere host start <name> [--id]
  sphere host inspect <name> [--id]
  sphere host cmd <name> <command> [--params <json>] [--cmd-timeout <ms>]
  sphere host remove <name> [--id]
  sphere host pause <name> [--id]
  sphere host resume <name> [--id]
  sphere host help

Global options on the host parent: --manager, --json, --timeout.
Manager address falls back to $SPHERE_HOST_MANAGER env var.

Multi-step commands (spawn/start/resume) use sendRequestStream; others
use sendRequest. Sphere.init from existing wallet (no autoGenerate).
Errors set process.exitCode rather than calling process.exit().
…nings

- hmcp-types: reviver now flags hadDangerousKeys and rejects the whole
  message instead of silently cleaning payload; isValidHmcpResponse no
  longer calls hasDangerousKeys (reviver catches it earlier)
- hmcp-types: byteLength() uses Buffer.byteLength(utf8) so the 64 KiB
  cap counts bytes, not UTF-16 code units
- dm-transport: early-message buffer (cap 32) + replay after resolvedPubkey
  set — fast manager replies no longer dropped during first sendDM
- dm-transport: dispose() snapshots correlators before iterating to avoid
  map mutation during cancel() callbacks
- host-commands: writeStderr accepts unknown; parseJsonParams rejects
  dangerous keys immediately; DEBUG env var logs cleanup errors
- sphere-init: loadConfig() validates each field type individually instead
  of unsafe cast; logs a warning on parse failure before falling back
- index: return process.exitCode when numeric so action-handler errors
  propagate correctly through bin/sphere.mjs → process.exit()
…edger

- types.ts: TradingIntent (with salt), IntentRecord, DealTerms, DealRecord,
  TraderStrategy + DEFAULT_STRATEGY, adapter interfaces (MarketAdapter,
  SwapAdapter, PaymentsAdapter, CommsAdapter), NpMessage envelope
- acp-types.ts: all 7 ACP command param/result interfaces (CREATE_INTENT,
  CANCEL_INTENT, LIST_INTENTS, GET_INTENT, UPDATE_STRATEGY, GET_STRATEGY,
  GET_DEALS) — ACP boundary uses number; bigint conversion at handler layer
- utils.ts: validateIntent(), validateDealTerms(), canonicalJson(),
  encodeDescription/decodeDescription (4-line spec 2.8 format),
  hasDangerousKeys() with depth-20 limit
- volume-reservation-ledger.ts: async mutex (promise-chain) on reserve(),
  reconstruct() for startup reconciliation, all bigint arithmetic
…r, test mocks

- intent-engine.ts: full state machine (ACTIVE/PAUSED/CANCELLED/EXPIRED/FILLED),
  scan loop, feed subscription, 10-criteria matching filter, proposer selection
  (lower pubkey proposes), expiry sweep, monotonic intent updates, MatchEvent type
- negotiation-handler.ts: NP-0 protocol (np.propose/accept/reject/cancel), auth
  validation (sig, ts_ms skew 300s, sender==participant, msg_id dedup 600s/10K),
  rate-limit 3 proposals/60s per counterparty, 64 KiB limit, dangerous-keys check,
  proposal 30s and acceptance 60s timeouts, CryptoAdapter interface
- swap-executor.ts: ACCEPTED→EXECUTING→COMPLETED/FAILED, pingEscrow, deposit_attempted
  flag written before payInvoice, payout polling (30s×10), term binding on
  swap:proposal_received, V2 enforcement, EXECUTING timeout (deposit_timeout_sec+60s),
  sphere event subscriptions with unsubscribe tracking
- test/mocks: MockMarket, MockSwap, MockPayments, MockComms with vi.fn() controls;
  fixtures.ts with makeIntent, makeIntentRecord, makeDealTerms, makeDealRecord,
  makeStrategy factories
…lves

PR #1 CI failed with:
    Cannot find module '@unicitylabs/sphere-sdk' or its corresponding type declarations.
    src/legacy/legacy-cli.ts#12

Root cause: package.json references sphere-sdk via `file:../../sphere-sdk`,
which works locally but doesn't exist on the CI runner. Switching to the
npm-published @unicitylabs/sphere-sdk@0.7.0 fails a different way —
that version lacks several invoice-related type exports
(CreateInvoiceRequest, PayInvoiceParams, GetInvoicesOptions,
ReturnPaymentParams, and ~58 other errors) that were added AFTER the
0.7.0 tag in sphere-sdk commit bc07e89 (the CLI-extraction commit that
promoted previously-internal types to the public surface so the
extracted CLI could consume them).

Fix: before `npm ci`, clone sphere-sdk to ../../sphere-sdk (public
repo, anonymous clone works on GitHub-hosted runners) and build it
so the file: dependency has a populated dist/. No sphere-cli source
changes required.

Long-term: when sphere-sdk publishes v0.7.1 to npm including the
post-extraction exports, swap the package.json dependency to the
published version and remove this CI workaround. The comment block
at the top of the workflow documents this follow-up.
vrogojin pushed a commit that referenced this pull request Apr 23, 2026
…lves

PR #1 CI failed with:
    Cannot find module '@unicitylabs/sphere-sdk' or its corresponding type declarations.
    src/legacy/legacy-cli.ts#12

Root cause: package.json references sphere-sdk via `file:../../sphere-sdk`,
which works locally but doesn't exist on the CI runner. Switching to the
npm-published @unicitylabs/sphere-sdk@0.7.0 fails a different way —
that version lacks several invoice-related type exports
(CreateInvoiceRequest, PayInvoiceParams, GetInvoicesOptions,
ReturnPaymentParams, and ~58 other errors) that were added AFTER the
0.7.0 tag in sphere-sdk commit bc07e89 (the CLI-extraction commit that
promoted previously-internal types to the public surface so the
extracted CLI could consume them).

Fix: before `npm ci`, clone sphere-sdk to ../../sphere-sdk (public
repo, anonymous clone works on GitHub-hosted runners) and build it
so the file: dependency has a populated dist/. No sphere-cli source
changes required.

Long-term: when sphere-sdk publishes v0.7.1 to npm including the
post-extraction exports, swap the package.json dependency to the
published version and remove this CI workaround. The comment block
at the top of the workflow documents this follow-up.
Vladimir Rogojin added 5 commits April 24, 2026 08:13
1. MAJOR: `sphere host spawn --env KEY=VAL` variadic bug
   Commander treats `<KEY=VAL...>` as variadic, greedily consuming every
   subsequent non-flag token — including the positional `<name>` argument.
   So `sphere host spawn --template tpl-1 --env A=1 B=2 mybot` fails with
   "missing required argument 'name'" because `B=2` and `mybot` are
   swallowed as env values. Drop the `...`; the argParser already
   accumulates across repeated `--env` flags (`--env A=1 --env B=2`).

2. MODERATE: CI pin on moving branch name → pin to commit SHA
   Cloning `refactor/extract-cli-to-sphere-cli` with --depth 1 is a
   moving ref. A force-push / rebase silently changes the code CI
   builds against. Pin to the tip SHA 86468103 with `git checkout
   --detach`. Bump the env var when a new sphere-sdk commit is
   required.

3. MODERATE: README said "Phase 1 scaffold" — stale
   PR lands phase 2 legacy bridge + live `sphere host`. Rewrote the
   Status section with a "What works today" table and a Quickstart
   snippet showing both legacy (`sphere wallet init`) and DM-native
   (`sphere host list/spawn`) flows.
- parseGlobalOpts → Command.optsWithGlobals() — deletes the hand-rolled
  parent-chain walker in favor of commander 12's built-in. Kept as a
  named wrapper so call sites stay readable.
- Extract TRANSPORT_HEADROOM_MS = 10_000 constant. Replaces the inline
  magic number `+5_000` in handleCmd; doubled to 10s to cover realistic
  public-relay RTT. Reasoning documented in the constant's JSDoc.
- Add per-type payload guards (isHmSpawnAckPayload, isHmListResultPayload,
  etc.) so a misbehaving manager's malformed payload produces a clear
  "manager returned malformed <type> payload" error instead of silently
  printing "undefined" in formatted output. New onProtocolError() helper
  owns the stderr format + exitCode=1 on guard-failure paths.
- Extract runStreamingLifecycle() helper used by handleSpawn/Start/Resume.
  Reduces ~180 lines of repeated "collect → dispatch per response type"
  to three compact call sites. A future bug fix in the streaming loop
  now applies to all three commands at once.
- Uniform stderr prefix: writeStderr always prefixes `sphere host: `
  (skipping if already present). main() in index.ts reserves the bare
  `sphere: ` prefix for errors that reach there (parse errors, commander
  throws). Removes the prior inconsistency where some paths leaked raw
  messages without a tool prefix.
- Reset process.exitCode = 0 at entry to main(). The prior flow carried
  exitCode across repeated in-process invocations (vitest's default).
  Production is unaffected (bin/sphere.mjs calls main() once per process),
  but the reset prevents tests from seeing ambient state from prior runs
  — and makes future test-harness imports of main() work correctly.
- Tighten the redaction regex in main() to require tokens of length 3-8
  (matches BIP-39 shape more precisely; less false-positive prone on
  stack traces with short identifiers). Added defensive-in-depth note
  on the expectation that this regex should never actually fire.
- Show inherited options (--manager/--json/--timeout) in every `sphere
  host <subcommand> --help` output. Previously discoverable only from
  `sphere host --help`. Implemented via post-construction
  addHelpText('after', …) so new subcommands inherit automatically.
- eslint config: ignore .claude/** (agent-worktree scratch directories).
…d-side size cap

Addresses remaining security-audit and code-review follow-ups:

- hasDangerousKeys: recursive walk → iterative with explicit stack +
  MAX_PARAMS_DEPTH=64 cap. Prior implementation could blow the JS
  interpreter stack on a pathological 10k-deep `--params` payload,
  producing a confusing RangeError instead of the clear "forbidden
  keys" message. Too-deep input is now conservatively rejected as if
  it contained a dangerous key.
- safeParse: return `value: null` when dangerous keys are seen. Callers
  that forget to check `hadDangerousKeys` cannot accidentally use a
  half-stripped object. parseHmcpResponse still short-circuits on the
  flag; this is defense-in-depth.
- dm-transport: send-side MAX_MESSAGE_SIZE check. Symmetric with the
  receive-side guard in parseHmcpResponse. Prevents `sphere host cmd
  --params '<huge JSON>'` from handing a 10 MB payload to the relay
  and getting an opaque TransportError in response.
- dm-transport handleIncoming: short-circuit when `disposed` is true.
  Closes the race between setting `this.disposed = true` and the
  async `unsubscribeDMs()` returning.
- dm-transport handleIncoming: early byte-size guard before any other
  work — a 10 MB garbage DM never enters the early-message buffer.
- dm-transport: log early-message overflow at DEBUG so a chatty
  manager during the handshake window is diagnosable.
- byteLength: exported from hmcp-types so the transport can symmetrize
  size checks across send and receive paths.

Tests: +25 new
- host-commands.test.ts: parseEnvPairs, parseJsonParams, parseTimeout,
  targetPayload, and the --env variadic-bug regression test verifying
  that `--env FOO=1 --env BAR=2 mybot` captures both env pairs AND the
  positional name (the exact failure mode from the pre-merge review).
- parseJsonParams depth-bound test with 200-level nested input — asserts
  we reject with "forbidden keys" before stack overflow.
- dm-transport.test.ts: send-side MAX_MESSAGE_SIZE rejection case.
- inherited-options help test: captures outputHelp() stdout to verify
  --manager/--json/--timeout are visible in every subcommand's --help
  (helpInformation() doesn't emit afterHelp events; outputHelp() does).

All 46 tests pass; typecheck clean; lint clean on src/host + src/transport.
Two comments addressing low-priority notes from the pre-merge review:

* src/index.ts: explain that `await import('./legacy/legacy-cli.js')` is
  intentional — keeps the ~40-file legacy dispatcher out of the hot start
  path for phase-4 DM-native commands (`sphere host …`) that don't need it.
* src/host/sphere-init.ts: document that all config paths are CWD-relative
  by design to share a wallet with `sphere wallet …` invocations.
eslint's consistent-type-imports flagged the value import since Command
is only used as a type annotation in 'this: Command'. CI fails on error.
@vrogojin vrogojin merged commit 42d0eba into main Apr 24, 2026
2 checks passed
Sign up for free to 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