Skip to content

fix(mirror): capture shell + launcher output to the session page - #451

Merged
ralyodio merged 2 commits into
mainfrom
worktree-mirror-shell-capture
Aug 29, 2026
Merged

fix(mirror): capture shell + launcher output to the session page#451
ralyodio merged 2 commits into
mainfrom
worktree-mirror-shell-capture

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

/merge on app.moshcode.sh/sessions/<id> showed the echoed command line and the exit note with nothing in between. It reads like stderr being dropped; it is not — a shell command's entire output was invisible to the mirror.

Why

/merge is a shell-valued alias (gh-prs-merge-all --apply), so expandAlias turns it into !gh-prs-merge-all --apply and it lands in runShell, which spawned with stdio: "inherit". Those bytes go straight to the tty's own file descriptors: teeOutput (which only sees what this process prints) never sees them, and the script(1) pty capture had only ever been wired into openPassthrough — engines and workflow tools. Every other launcher in the pit was writing to the terminal and nowhere else.

What changed

  • The capture dance moves into one exported helper, captureSpec in src/pty.mjs (transcript, flavour-specific script argv, follower, banner strip, cleanup). openPassthrough now calls it instead of carrying its own copy.
  • captureSpec defaults its sink to the live mirror (activeChildSink() in src/mirror.mjs) rather than waiting to be handed one. A launcher gets capture without knowing the mirror exists — which is the failure mode this bug was: each launcher had to be taught separately, and only one ever was.
  • Now captured: runShell (!cmd, /shell, every shell-valued /alias — the /merge case), /install, and runCmd's inherited branch (/upgrade, /plugin, /skill, /mcp). stdout and stderr both, since they share the pty.
  • Unchanged for an unmirrored pit, a piped/CI run, a box with no usable script(1), or MOSHCODE_MIRROR_PTY=0: the spec comes back exactly as passed and the old inherit path runs.

Second fix, same symptom from the other end

POST /api/sessions/:id/output stored chunk.slice(0, 20000) but published the full chunk to live watchers. A burst larger than the row cap was on screen while you watched and gone after a reload, with nothing to say bytes were missing. The row cap stays; the overflow becomes additional rows in seq order, so a replay reassembles what was posted.

Tests

  • test/mirror-shell-capture.test.mjs (new, 10 tests) — including the full chain: runShell → pty → mirror sink → the /output POST body, asserting stderr survives it. The two that matter fail with MOSHCODE_MIRROR_PTY=0, so they are testing the wiring and not just the parts.
  • apps/pwa/test/sessions-output-seq.test.mjs — the test that pinned truncation now pins splitting; every byte posted must be stored, in order.
  • Root suite: 2366 pass, 0 fail. apps/pwa suite: 710 pass, 0 fail.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SN7dw64sxjSEyyA2JErCPH

`/merge` on app.moshcode.sh/sessions/<id> showed the echoed command line
and the exit note with nothing in between. Not a stderr problem — a
shell command's whole output was invisible.

`/merge` is a shell-valued alias (`gh-prs-merge-all --apply`), so it
expands to `!cmd` and lands in runShell, which spawned with
`stdio: "inherit"`: those bytes go to the tty's own file descriptors and
never pass through this process, so neither the teeOutput hook nor the
pty capture ever saw them. Only engines and workflow tools had been
taught to run under script(1); every other launcher had not.

So the capture moves into one place — captureSpec in src/pty.mjs — and
defaults its sink to the live mirror rather than waiting to be handed
one. runShell (`!cmd`, /shell, every shell alias), /install, and runCmd
(the upgrader, /plugin, /skill, /mcp) all reach the session page now,
stdout and stderr alike, and a launcher written tomorrow gets it without
knowing the mirror exists. Nothing changes for an unmirrored pit or a box
with no usable script(1): the spec comes back untouched.

Second fix, same symptom from the other end: POST /output stored
`chunk.slice(0, 20000)` while publishing the full chunk to watchers, so a
burst bigger than the row cap was on screen live and gone after a reload.
The cap stays; the overflow becomes more rows in seq order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SN7dw64sxjSEyyA2JErCPH
@github-actions

github-actions Bot commented Aug 29, 2026

Copy link
Copy Markdown

ThreatCrush Security Scan

1 finding(s) in the 10 file(s) this pull request changes.

MEDIUM: 1

Severity Rule Location
MEDIUM js-uninitialized-buffer src/pty.mjs:123
81 pre-existing finding(s) elsewhere in the repository — **HIGH/CRITICAL**: 5 | **MEDIUM**: 66 | **LOW**: 10

Not introduced by this pull request. The full set is in the Security tab.

Severity Rule Location
HIGH js-ssrf-outbound-request apps/pwa/public/sw.js:45
HIGH tls-verification-disabled apps/pwa/src/lib/moshpit-gateway.mjs:299
HIGH sh-remote-script-execution install.sh:79
HIGH sh-remote-script-execution install.sh:83
HIGH tls-verification-disabled src/dns.mjs:766
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:138
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:152
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:178
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:365
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:369
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:414
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:663
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:859
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:861
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:920
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:966
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1036
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1139
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1162
MEDIUM sql-template-interpolation apps/pwa/src/moshpit.mjs:1184

…and 61 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

Verified against the build actually installed (v0.74.0, ~/.moshcode/pkg)
by pointing a real mirrored pit at a stand-in app and recording what it
posts: a shell command's stdout AND stderr both reach the terminal and
neither reaches the session page. Not stderr-specific, and not /merge
-specific — every shell-valued alias went the same way.

The async launchers were the first pass. These are the spawnSync ones,
equally invisible and equally pit-reachable: moshscript's shell() (so
`/run`), /billing handing an invoice to coinpay, and /payments connect.
A blocking spawn holds the event loop, so the follower's poll never runs
and the output arrives in the drain stop() does — batched rather than
live, which is still the difference between reading it from a phone and
not.

Left uncaptured on purpose: escalate.mjs (a sudo password prompt is not
something to route through a web page) and the tmux hand-offs, which
hand over the terminal wholesale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SN7dw64sxjSEyyA2JErCPH
@ralyodio
ralyodio merged commit b185803 into main Aug 29, 2026
6 checks passed
@ralyodio
ralyodio deleted the worktree-mirror-shell-capture branch August 29, 2026 19:27
@ralyodio ralyodio mentioned this pull request Aug 29, 2026
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