Skip to content

feat(host-cli): serve product frames headlessly with --serve - #439

Open
peetzweg wants to merge 1 commit into
mainfrom
feat/serve-mode
Open

feat(host-cli): serve product frames headlessly with --serve#439
peetzweg wants to merge 1 commit into
mainfrom
feat/serve-mode

Conversation

@peetzweg

Copy link
Copy Markdown
Member

Closes #436

What

signing-host --serve runs the host with no terminal UI and no TTY, and stays up until it is stopped. That makes it supervisable, so a dev server can start it, wait for it, and use it:

truapi-host signing-host --serve \
  --frame-listen 127.0.0.1:9955 \
  --product-id myapp.dot \
  --auto-accept

Output is one line per event:

✓ Paired with headlessyvqhet.43
✓ Signing host ready
• Listening for product frames
  ws://127.0.0.1:9955
• Serving product frames until stopped
  ws://127.0.0.1:9955
  Confirmations are approved automatically

Why

Until now there was no supported way to run a host next to a dev server. signing-host refuses to start without a TTY unless it is given --script or exec, and both of those are one-shot: they run and exit. script(1) cannot lend the CLI a pty from a spawned process, because it reads tcgetattr on its own stdin, so a wrapper script cannot fake a terminal either.

The only working headless recipe was a source checkout plus a script that never resolves (--script keepalive.ts), which cannot report readiness and leaves an orphan behind. With --serve the host is an ordinary background process.

The readiness line matters as much as the mode. The frame socket accepts connections well before a signer exists, so "port is open" is not "host is up". Serving product frames until stopped is last in every case and means both halves are up, so that is the line to wait for. (Signing host ready lands either side of the endpoint line, depending on whether the session was cached or is being registered, which is why the README points at the serve line instead.)

How

Small, because the scaffolding was already there. interactive was already script.is_none() && exec_input.is_none(), SystemEvent already prints line by line when no TUI is attached, and with_frame_server already wraps a future in the frame server. So --serve is one more branch beside the exec branch: resolve the signer, announce, then park until stopped.

  • --serve cannot combine with --script or exec, and the TTY error message now points at it.
  • New SystemEvent::ServeReady { url, auto_accept }. It names the endpoint, and says which way confirmations will go, because a process with no terminal cannot prompt: without --auto-accept they are denied, which is worth stating at startup rather than leaving to be discovered on the first signature.
  • Ctrl-C is awaited so the host owns its shutdown. SIGTERM keeps its default action and ends the process, which is what a supervisor sends.
  • The pairing-responder block that --script and exec each carried is now spawn_pairing_responder, shared by all three paths, so --deeplink behaves the same in serve mode.

Verified

Run headlessly against paseo-next-v2 with an existing session, output captured from a pipe rather than a terminal, and a product client connected over the frame socket while it served: getUserId and getAccount for a product account both answered, and the process exited on signal. Details in a comment below.

Companion to #438, which is the client half: with both, a browser product needs two commands and no glue.

`signing-host` refused to start without a TTY unless it was given `--script` or
`exec`, and both of those are one-shot. `script(1)` cannot lend it a pty from a
spawned process either, because it reads `tcgetattr` on its own stdin, so no dev
server could supervise a host.

`--serve` resolves the signer, announces the endpoint, and stays up until it is
stopped, logging one line per event. It refuses to combine with the one-shot
modes, and says at startup which way confirmations will go, because a process
with no terminal cannot prompt. Ctrl-C is awaited so the host owns its shutdown;
SIGTERM keeps its default action.

The pairing-responder block that `--script` and `exec` each carried moves into
`spawn_pairing_responder`, shared by all three paths.
@peetzweg
peetzweg requested a review from a team August 18, 2026 10:58
@peetzweg

Copy link
Copy Markdown
Member Author

Verification detail, run on macOS 15 arm64 against paseo-next-v2 with an existing session, output captured from a pipe so no TTY was involved.

Host, started headless and left running:

truapi-host signing-host --serve --frame-listen 127.0.0.1:9957 \
  --product-id dim2.dot --auto-accept > serve.log 2>&1 &
✓ Paired with headlessyvqhet.43
✓ Signing host ready
• Listening for product frames
  ws://127.0.0.1:9957
• Serving product frames until stopped
  ws://127.0.0.1:9957
  Confirmations are approved automatically

Then a product client over the frame socket, using this crate's own js/ws-provider.ts so the run exercises serve mode and nothing else:

connected to ws://127.0.0.1:9957
getUserId: headlessyvqhet.43
getAccount dim2.dot/0: 0x2eaeef17f7d7c18c3a22b5a5f3731313e66ac297efef13e6e5d8ba12403d9135
signRaw: 0xeed2074c1865ea51…

The host logged the auto-approval while serving:

✓ Approved sign raw data automatically
  A product requested a raw-data signature. The payload is hidden here.

Shutdown: SIGINT exits cleanly through the handled path, SIGTERM ends the process.

Checks run locally: cargo +nightly fmt --check, cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings, and cargo test -p truapi-host-cli --bins (105 passed, including the two new ones).

One thing worth flagging that is not from this branch: three tests in tests/signing_host_cli.rs fail on unmodified main on this machine, all on assert!(!output.stdout.contains(&0x1b)), so ANSI escapes reach stdout locally where CI sees none. Same three fail with my changes stashed, so it is environmental rather than a regression here. Happy to file it separately if it is not already known.

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.

Run a real host beside a dev server: signing-host has no headless mode

1 participant