Repository navigation
feat(live-sessions): per-session Remote Control toggle - #101
Conversation
JayantDevkar
left a comment
There was a problem hiding this comment.
The security design here is solid and I'd like to land this — but it needs a rebase and one perf fix first.
Checked and clean
- The typed payload is the constant
RC_COMMAND; nothing request- or session-derived reaches it. All subprocess calls are list-form, AppleScript interpolation goes through_applescript_str, tmux usessend-keys -l --. - The origin gate is exact-match and enforced server-side independent of CORS, so adding
X-Karma-RCtocors_allow_headersdoesn't let:5173/:3000through (they pass preflight, then get 403). Desktop app uses:5180, so it's covered. - Transcript paths are realpath-confined under
projects_dir; thehrefis filtered tohttps://claude.ai/on both sides. Blocking calls in the async handler are all inasyncio.to_thread.
Needs to change before merge
- Rebase: #100 just merged and this now conflicts in
ConversationHeader.svelteandapi/schemas.py(see inline). - The full-transcript scan on the polled single-session GET (see inline).
Follow-up once this lands: #100's resume-in-terminal endpoint should move to this PR's narrower rc_trusted_origins gate — it spawns a process and currently trusts all of cors_origins.
Not checked: real toggle against a live terminal, Claude Code's actual bridge_status wording vs _ON_MARKERS/_OFF_MARKERS (a wording change degrades to unknown → 409, which is the safe direction).
Add a toggle on the live-session header to turn Claude Code Remote
Control on/off for that session. Claude Code exposes no local API for
this, so Karma drives it by typing `/remote-control` into the session's
terminal (tmux / macOS Terminal.app / iTerm2).
- POST /live-sessions/{id}/remote-control {desired}: reads current state
from the transcript's bridge_status lines, types the command, confirms
from the transcript. Honest by construction — sent=false means nothing
was typed; sent=true/confirmed=false means keys went but the transcript
hasn't caught up.
- ON is fully automated. OFF opens Claude Code's interactive "Disconnect
Remote Control" menu and raises the terminal; the user finishes it
there (nothing drives that menu reliably from outside the process).
- Only WAITING_INPUT / STARTING / ENDED are blocked — a slash command
typed during a tool run just queues.
- CSRF: rc_trusted_origins (narrower than cors_origins) + an X-Karma-RC
header, which also required adding X-Karma-RC to cors_allow_headers so
the browser CORS preflight passes.
- state read confined to ~/.claude/projects; only https://claude.ai URLs
surfaced to the frontend <a href>; a process-wide asyncio.Lock
serialises toggles.
remote_control + can_remote_control are computed only on the
single-session GET (they open the JSONL — too costly for the 1s list
poll).
ConversationHeader also moves the live-sync spinner to a badge by the
H1 and hides `resume` on live sessions. api-types.ts carries two
incidental Prettier line-collapses.
33 endpoint/unit tests. ON + OFF verified end-to-end against a throwaway
Terminal.app session.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JLBBkC227GsETwuAfB6SyN
CI installs ruff unpinned (now 0.16.5); its formatter wraps the multi-line lambda in test_disable_opens_menu_and_raises_terminal. No behaviour change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JLBBkC227GsETwuAfB6SyN
…n, no orphan menu Review feedback on #101, plus the rebase onto main (#99/#100/#102). - Memoize the transcript scan on (path, st_mtime_ns, st_size). The single- session GET is polled ~1s per open tab and, when RC has never been used, found no bridge_status line anywhere — so every uncached hit re-read and decoded the whole multi-MB chain. A JSONL transcript is append-only, so an unchanged transcript is now scanned exactly once. - Verify a tmux pane still owns the recorded pid before enabling the toggle. pid_is_live_claude accepts node/bun/deno, so a recycled pid in a recycled pane could have enabled RC on a *different* Claude session. The pane's pane_pid must now be that pid or an ancestor of it; unverifiable => refused. - Require Origin on the toggle, not just "trusted if present". For a keystroke-injecting endpoint, no-Origin isn't worth the benefit of the doubt: this closes DNS-rebinding and the no-Origin-same-origin-POST corner. - Drop the "Esc-dismisses" claim in read_remote_control_state — nothing here sends Esc. A stale "off" (session launched with --remote-control, no bridge_status line) meant the ON click opened the *disconnect* menu and left it open in an unfocused terminal, where the user's next Enter picks an item. Unconfirmed ON now raises the terminal like the OFF path and says so. ConversationHeader: #100's ResumeSessionButton supersedes this branch's live-session hide (it focuses the terminal when live, which is useful), so the rebase keeps #100's version outright and drops the copy-resume block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c738652 to
a08634c
Compare
The pane-ownership check added in a08634c was covered only by mocks, so a wrong `display-message` invocation would have silently withheld the toggle from every tmux user while looking fine on Terminal.app / iTerm2. Adds an integration test that drives a throwaway tmux session end to end: `_tmux_pane_pid` must agree with tmux, the pane's own process and a descendant of it (claude sits under the pane's shell in the real setup) are owned, and init / an unrelated process / a nonexistent pane are not. Skipped when tmux is absent, so CI and non-tmux machines are unaffected. Verified locally on tmux 3.7c: PASSED, not skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.storygraph.db and its -wal/-shm sidecars are an external tool's local artifact that sits in the repo root and shows up as untracked noise in every `git status`. Nothing in this repo references them — karma's own database is ~/.claude_karma/metadata.db. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st on a private server, aria-disabled toggle, one-line comments
|
Re-reviewed: all five fixes from the last round are real, and I found no semantic conflict with current main. Pushed two commits on top so this can land now: the terminal gate ( |
Summary
Adds a Remote Control toggle to the live-session header so you can turn Claude Code Remote Control on/off for a session straight from the Karma dashboard — no need to switch to its terminal to reach it from your phone.
Claude Code exposes no local API for Remote Control; the only lever is the
/remote-controlslash command typed inside the session. Karma runs on the same machine as the tracked terminal, so it drives Remote Control by typing into the session's terminal tab (tmux / macOS Terminal.app / iTerm2), reusing the tty / iTerm-session targeting fromservices.terminal_focus./remote-controlis not a symmetric toggle, so the two directions differ:do scriptcan't deliver arrow keys; System Events needs a machine-wide Accessibility grant that misfires). So Karma types the command to open the menu, raises the terminal, and returnsmethod:"menu-open"— the user picks "Disconnect this session" themselves, and the nextliveStatuspoll flips the pill.State is read back from the session JSONL transcript, where Claude Code writes
{"type":"system","subtype":"bridge_status", …}lines on connect/disconnect. No line anywhere →off; a line that can't be classified →unknown(endpoint 409s rather than blind-toggle).The endpoint is honest by construction:
sent=falsemeans nothing was typed;sent=true, confirmed=falsemeans keys went but the transcript hasn't caught up yet.Changes
POST /live-sessions/{id}/remote-control {desired}— async, process-wideasyncio.Lockso button-mashing can't pile up keystroke sequences, bounded confirm-poll (6 × 0.5s).services/remote_control.py(new) —read_remote_control_state,type_remote_control_command,can_send_remote_control. Whole-transcript scan (48 MB cap) for the lastbridge_status, memoized on(path, st_mtime_ns, st_size); stricttype==system && subtype==bridge_statusmatch so ordinary text containing the string is ignored.RemoteControlToggle.svelte(new) — the pill:off/on(+↗ opento claude.ai/code) /unknown/busy/staged/menu/retry/ok. Never dims; only the button disables.role="switch", live-region announcements, reduced-motion.remote_control+can_remote_controlonLiveSessionSummary— computed only on the single-sessionGET(they open the JSONL — too costly for the 1s-polled list endpoints).rc_trusted_origins(default:5180only, deliberately narrower thancors_originswhich also trusts:5173/:3000) + anX-Karma-RCrequest header, andOriginmust be present. This required addingX-Karma-RCtocors_allow_headersso the browser's CORS preflight passes.WAITING_INPUT(a dialog is open; the trailing Enter could answer it),STARTING(no REPL yet) andENDED. Everything else, includingACTIVE, is allowed — a slash command typed during a tool run just queues.review #Ncomments):~/.claude/projectspath confinement against symlink escape,https://claude.ai/filter on the URL handed to<a href>,session_idregex, live-pid + supported-terminal gate..gitignore— ignores.storygraph.dband its-wal/-shmsidecars. An external tool drops them in the repo root, nothing here references them, and they were showing up as untracked noise in everygit status. (Unrelated to Remote Control, folded in as a drive-by.)api-types.tscarries two incidental Prettier line-collapses (PermissionMode,CronStateTriggerEvent) alongside the new RC types;prettier --checkpasses with them.Review round 2 — all five inline comments addressed
Rebased onto
main(now includes #99, #100, #102); merges clean.ConversationHeader.svelte— conflicts with #100,copiedTarget === 'resume'becomes a type errorResumeSessionButtonoutright and dropped this branch's copy-resume block. Taking the reviewer's note: #100's button focuses the terminal when live, which is useful, so this branch's "hide on live" was the wrong instinct and is gone.CopyTargetstays narrowed to'uuid'.live_sessions.py— full-transcript scan on the 1s-polled single-session GET(path, st_mtime_ns, st_size). A JSONL transcript is append-only, so unchanged mtime+size ⇒ unchanged content and a quiet session is scanned exactly once. LRU-bounded at 256 entries, lock-guarded. Measured — see Testing.live_sessions.py— a request with noOriginpasses the gateOriginnow 403s. For a keystroke-injecting endpoint it isn't worth the benefit of the doubt: this closes DNS-rebinding and the no-Origin-on-same-origin-POST corner. The dashboard always sends one; scripted callers can passOrigin: http://localhost:5180.remote_control.py— "Esc-dismisses" overclaims; a stale-offON click leaves the Disconnect menu open in an unfocused terminaldetailthat says the disconnect menu may be showing. The frontend'smenu-openmethod stays reserved for the OFF click, so the ON path reports the honest "sent, unconfirmed".remote_control.py— tmux gate passes on pid liveness alone; recycled pid + recycled pane could enable RC on the wrong sessiontmux display-message -p -t <pane> '#{pane_pid}'must be the recorded pid or one of its ancestors (walk capped at 8, 5s TTL cache). Unverifiable — no tmux, pane gone — is treated as not owned. Verified against real tmux 3.7c — see below.The reviewer's follow-up (moving #100's
resume-in-terminaltorc_trusted_origins) is deliberately not in this PR — it hardens a different endpoint from a different feature, and was flagged as "follow-up once this lands". It'll be its own PR.tmux gate — verified on real tmux ✅
The pane-ownership check was initially mocked-only, which was called out as the one unproven claim in this PR. It is now verified end to end against tmux 3.7c, and the verification is committed as an integration test (
test_pane_ownership_against_real_tmux,skipifwhen tmux is absent so CI and non-tmux machines are unaffected):_tmux_pane_pid()agrees withtmux display-messageclauderuns under the pane's shell%99999)Run it yourself with
pytest tests/api/test_live_sessions_remote_control.py::test_pane_ownership_against_real_tmux -v— it creates and destroys its own throwaway session and never touches your own. Locally it reports PASSED, not SKIPPED.Component
Type
Testing
API tests pass —
test_live_sessions_remote_control.py40 pass (33 + 7 new: pane-ownership accept/reject/unverifiable, a real-tmux integration test, scan memoization, per-path isolation, unconfirmed-ON raises vs confirmed-ON doesn't). Fulltests/api/1885 pass (the 6test_background_shells_routerfailures are pre-existing, unrelated, and local-only: that test's fixture doesn't stub the router's_maybe_sync(), so each test ingests the developer's real~/.claude/projectshistory into its temp DB and gets 200 rows where it expects a handful. Green on CI, which has no such history. An earlier revision of this description blamed a stale.storygraph.db— that was wrong; karma's DB is~/.claude_karma/metadata.db.).Frontend type-checks (
npm run check, 0 errors) and lints clean (npm run lint, 0 errors)Captain Hook tests pass — N/A (no hook changes)
Memoization measured, not asserted. A/B across two detached worktrees (pre-fix vs post-fix), each a real uvicorn server hit over real HTTP with an isolated
$HOME, against a 40 MB transcript containing zerobridge_statuslines (the worst case: every byte scanned, nothing found). 100 sequential requests:ps(the OS, not the app)That's 40.6 ms → 0.6 ms of CPU per request. Both variants returned
remote_control: {'state': 'off'}, confirming the scan path actually ran rather than an early-out. The regression test was mutation-checked: disabling the memo makes it failassert 3 == 1for the right reason, so it detects the real defect instead of passing vacuously. (The endpoint's@cacheable(max_age=5)throttles how often a browser triggers this; it doesn't change the per-scan cost, which is what was measured.)Manually tested — ON and OFF both verified end-to-end against a throwaway Terminal.app
claudesession:{sent:true, confirmed:true, state:"on", url:"https://claude.ai/code/session_…"}; confirmed three ways (endpoint response, abridge_status"is active" line Claude Code wrote to the throwaway's own transcript, and a fresh KarmaGETre-reading it ason).{sent:true, method:"menu-open", state:"on"};focus_terminalraised the window for the user to finish in the menu.Screenshots
Pill states (off / on+open / menu / retry) render via
npm run check+ component review; no static screenshots attached.Related Issues
Separate feature branch off
main; independent of #99 (settings / schema-drift). Rebased ontomainafter #99/#100/#102 merged.🤖 Generated with Claude Code