Skip to content

Investigation: LLM/chat behavior inside ssh remote sessions #391

Description

@fentas

Summary

The LLM chat module works without artificial restrictions when atty is run locally and the user enters a remote shell via ssh remote-host. Alt+A / Alt+C / Alt+S / Alt+Shift+C are intercepted by the local atty proxy and never reach the remote shell. API calls originate locally. OSC 133 markers from remote shells (if atty init bash is installed on the remote) flow through transparently and drive the local capture chain. There is one semantic caveat worth documenting: ghost text fires on keystrokes typed at the remote prompt using local-context history, which can mislead users into thinking suggestions are aware of remote state.

This issue captures the investigation; no code change is required, but two small doc additions are recommended.

What works (verified)

  • Keybinding interceptionsrc/proxy.zig dispatches LLM actions via D.dispatchAction(&runtimes, &ctx, act) before stdin bytes are forwarded; swallow_after_binding consumes the meta bytes. Independent of foreground subprocess, so ssh remote doesn't shadow Alt+letter bindings.
  • OSC 133 parsersrc/osc133.zig is a byte-state machine with no PID/local-shell gating. SSH is 8-bit clean, so \x1b]133;[ABCD]…\x07 from the remote arrives intact and is parsed identically to local markers. Sticky-on-first-marker (line ~31) means once any shell — local or remote — emits one, the parser is active.
  • Subprocess detectionsrc/subprocess.zig recognizes the ssh command, pushes a .ssh frame on ;C, and tags follow-up history as ssh://host/path. No LLM module code reads currentKind() to gate features. Confirmed via grep through src/modules/llm/.
  • LLM API call originsrc/modules/llm/worker.zig uses the local std.Io passed at attach time. HTTP requests + subprocess providers run on the local machine; nothing routes through the SSH tunnel.
  • Chat persistencesrc/modules/llm/chat_persist.zig writes to the local chat_persist_dir; remote filesystem is never touched.
  • Multi-level SSH nesting — Subprocess stack has max_depth = 8; nothing in the LLM module gates on depth. local → ssh1 → ssh2 → … should preserve LLM features at every level.
  • Inline chat panel paintingsrc/modules/llm/hooks.zig and paint.zig do not check alt-screen status or subprocess context. Standard DECSTBM collision semantics apply (same as locally — TUIs on the remote that take alt-screen will cooperate the same way as local TUIs).

What's degraded / semantically off

1. Ghost text uses local-context history for remote-typed commands

src/line_state.zig tracks every printable byte arriving at atty's stdin, regardless of whether the user is at the local prompt or typing into ssh remote's remote shell. Ghost providers (atuin, history) then surface completions based on the local machine's history and shell function names — not the remote host's.

  • Impact: UX confusion. The user sees a ghost suggestion for ls -la based on a recent local invocation, while typing into a remote shell where that history is irrelevant.
  • Workaround: Disable ghost text inside ssh, or configure providers to suppress when the subprocess frame is .ssh.
  • Fix scope: Moderate — propagate subprocess context into ghost providers and add host-aware filtering. Not a blocker; defer until users report it.

2. line_state doesn't reset on Ctrl+C forwarded to the remote shell

When the user presses Ctrl+C at a remote prompt, the byte is forwarded but atty's local line_state.reset() doesn't fire. Stale buffer content can leave brief ghost suggestions until the next ;A/;B cycle.

  • Impact: Minimal — the uncertain-flag gating in line_state suppresses most bad suggestions.
  • Fix scope: Small — proxy hook to reset line_state on certain forwarded control bytes. Defer unless reported.

Untested but architecturally expected to work

  • Remote shell with atty init bash >> ~/.bashrc on the remote — markers flow through, parser activates, dialog mode and OSC 7 cwd updates should land correctly.
  • Alt+S dialog where the captured command output came from a remote shell (;D close).
  • Nested ssh (ssh → ssh → ssh): subprocess stack supports up to depth 8.

Security note

The OSC 133 parser does not verify marker origin. A compromised remote shell could emit fake ;C/;D to skew atty's subprocess stack or line-state. This is a design choice — same as for a compromised local shell — and the cost of mitigating (signing markers, refusing remote-origin markers) outweighs the benefit. Worth a one-liner in docs/security.md if/when that lands.

Recommendations

  1. Add a "Caveats" section to docs/modules.md (or a new docs/ssh.md) explaining the ghost-text-context mismatch and noting that remote atty init bash is supported and beneficial. Effort: low. ← actionable doc-only change.
  2. Defer the ghost-context-filtering work — track here. Not a blocker. Subprocess context would need to thread to the providers; doable but adds API surface.
  3. No code changes needed for ssh-as-foreground — atty's local interception layer already handles this correctly.

Where to look in the code

Concern File
Keybinding intercept src/proxy.zig (dispatchAction site)
OSC 133 parser src/osc133.zig
Subprocess stack src/subprocess.zig
LLM hooks (Alt+letter) src/modules/llm/hooks.zig
LLM HTTP worker src/modules/llm/worker.zig
Line state + ghost feeds src/line_state.zig, src/ghost.zig
Chat persistence (local-only) src/modules/llm/chat_persist.zig

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions