Skip to content

fix: remote panes lose TERM_PROGRAM, breaking inline image protocols - #2106

Closed
jvm wants to merge 6 commits into
herdrdev:masterfrom
jvm:fix/pane-term-program
Closed

fix: remote panes lose TERM_PROGRAM, breaking inline image protocols#2106
jvm wants to merge 6 commits into
herdrdev:masterfrom
jvm:fix/pane-term-program

Conversation

@jvm

@jvm jvm commented Jul 30, 2026

Copy link
Copy Markdown

Current behavior

Panes opened through herdr --remote <host> have no TERM_PROGRAM. Applications that pick an inline image protocol from it fall back to no-image rendering, so reading an image appears to succeed but displays nothing.

It is not remote-only. Pane children are seeded from a snapshot of the server process environment, and the server is persistent, so its terminal variables belong to whichever terminal started it:

  • Remote: the server is started under sshd and has no terminal identity at all.
  • Local: a server started under kitty and later attached from a terminal without inline image support still hands panes KITTY_WINDOW_ID, so applications select a protocol the real outer terminal cannot render.

apply_pane_terminal_env already normalizes TERM/COLORTERM for this reason and stops there.

Expected behavior

A pane advertises the terminal that actually displays it, which is the attached client's outer terminal, not the server's.

Reproduction

herdr --remote <host>
# in the remote pane:
printf 'TERM_PROGRAM=%q\n' "$TERM_PROGRAM"   # empty

Impact

Inline images do not render in remote panes. Any application keying off TERM_PROGRAM sees no terminal, or on local sessions sees a stale one.

Change

The client reports its outer terminal in Hello; the server stores it per connection and applies it at pane spawn. There is no other channel to a remote server: the bridge runs ssh -T, requests no pty, and forwards no environment.

Two ordering cases needed handling beyond the handshake:

  • Panes are spawned while the app is built, before any client can attach. A restarting server therefore restores its whole session first, which is every pane a remote user lands in. The server is given the identity at launch — inherited from the spawning client locally, named on the bridge command line for remote.
  • --no-session has no client, so it seeds from its own environment.

TERM stays xterm-256color; only the outer identity is propagated. With no identity available TERM_PROGRAM is removed rather than left stale.

Commits are separable. The second drops the other identity variables that no longer describe the attached client (KITTY_WINDOW_ID, LC_TERMINAL, ...); it can be dropped independently if you would rather not change what panes inherit.

Things to flag

  • PROTOCOL_VERSION 18 -> 19. Unavoidable given the ssh boundary, but it is a breaking change: servers must restart, and mismatched client/server pairs reject each other. Source protocol equalled the latest released tag, so this follows the bump rule in AGENTS.md.
  • TERM_PROGRAM=kitty is synthesized. Kitty sets no TERM_PROGRAM, so for kitty this reconstructs a value rather than restoring one. It is what consumers check, and fabricating KITTY_WINDOW_ID would be worse, but it is the one judgement call here and I would rather you see it than find it.
  • Existing panes keep the environment they were spawned with; this cannot be changed after the fact.
  • I looked for prior art and found Expose host terminal app identity to plugins #849, which proposed a similar client -> Hello -> server path for plugin metadata and was closed. This is scoped to the pane environment bug rather than that feature; say the word if you would still rather it went to Discussions first.

Verification

  • just check clean on the pinned 1.96.1 toolchain. Three pre-existing agent-detection failures (live_handoff_keeps_agent_started_pane_after_agent_exits, live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session, pane_info_and_subscriptions_expose_done_agent_status) reproduce identically on an unmodified checkout here.
  • Tests added for the client-side identity, the pane environment with and without an attached client, and the bridge command including its shell quoting. Protocol fixtures and the schema artifact updated for the bump.
  • Manually checked on macOS + Ghostty against a real Linux remote: the remote pane reports TERM_PROGRAM=ghostty, and an image read in a coding agent renders inline. Also checked locally that panes track the attaching client and that --no-session reports its own terminal.

Environment: herdr 0.7.5, stable, macOS 15 arm64, Ghostty local, Linux x86_64 remote.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jvm, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1670ea32-e397-452c-8030-7c6d04d7542e

📥 Commits

Reviewing files that changed from the base of the PR and between 3c08fd5 and 0df12e4.

📒 Files selected for processing (1)
  • src/server/client_transport.rs
📝 Walkthrough

Walkthrough

The protocol version advances to 19, and client handshakes now carry optional outer terminal identity. The server propagates this identity to foreground pane environments, while monolithic and remote bridge paths initialize or forward it.

Changes

Outer terminal identity

Layer / File(s) Summary
Terminal identity detection and pane environment
src/terminal_notify.rs, src/pane.rs, src/main.rs
Terminal identities are detected, stored, initialized, propagated to panes, and stale identity variables are cleared.
Handshake contract and terminal metadata propagation
src/protocol/wire.rs, src/client/mod.rs, src/server/..., tests/..., docs/next/api/herdr-api.schema.json
Protocol version 19 adds term_program to Hello messages and propagates normalized values through client connection events.
Headless foreground synchronization
src/server/headless.rs
The headless server updates the stored terminal identity when foreground clients connect, change, or disconnect.
Remote bridge terminal forwarding
src/remote/unix.rs
Remote bridge commands forward terminal identity through a shell-quoted environment assignment.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ClientTransport
  participant HeadlessServer
  participant Pane
  Client->>ClientTransport: Send Hello with term_program
  ClientTransport->>HeadlessServer: Emit ClientConnected with normalized term_program
  HeadlessServer->>Pane: Set outer terminal program
  Pane->>Pane: Apply terminal identity to pane environment
Loading

Possibly related PRs

  • herdrdev/herdr#2081: Both changes update protocol version 19 and related schema/tests, while this change adds terminal identity to the handshake.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary fix: preserving TERM_PROGRAM for remote panes to restore inline image protocol detection.
Description check ✅ Passed The description directly explains the bug, expected behavior, implementation, compatibility impact, and verification for terminal identity propagation.
Docstring Coverage ✅ Passed Docstring coverage is 94.34% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@kangal-bot kangal-bot added the ai-review Trigger automated AI reviews for pull requests admitted by the PR gate label Jul 30, 2026
@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown

Greptile Summary

The PR propagates the attached client’s terminal identity into newly spawned panes.

  • Adds term_program to the client handshake and bumps the wire protocol to 19.
  • Tracks terminal identity per connection and synchronizes it with foreground-client transitions.
  • Seeds terminal identity before local, remote, and no-session pane startup.
  • Normalizes pane terminal variables and removes stale emulator-specific identifiers.
  • Updates protocol fixtures, schema data, and terminal-environment tests.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/client/mod.rs Adds the detected outer-terminal identity to the client handshake.
src/protocol/wire.rs Extends Hello with an optional terminal identity and bumps the protocol version.
src/server/client_transport.rs Validates the reported identity and carries it into the server event stream.
src/server/clients.rs Stores terminal identity as connection-scoped state.
src/server/headless.rs Seeds startup identity and synchronizes pane identity with the foreground client lifecycle.
src/pane.rs Applies the current terminal identity at pane spawn and removes stale terminal-specific variables.
src/remote/unix.rs Passes terminal identity through the quoted remote bridge command for pre-attachment restoration.
src/terminal_notify.rs Derives a normalized terminal-program name from explicit environment data or backend detection.

Sequence Diagram

sequenceDiagram
    participant T as Outer terminal
    participant C as Client
    participant S as Server
    participant P as New pane
    T->>C: Launch herdr
    C->>C: Detect terminal identity
    C->>S: Hello(term_program)
    S->>S: Store identity per connection
    S->>S: Select foreground client
    S->>P: Spawn with TERM_PROGRAM
    P-->>T: Render output through client
Loading

Reviews (4): Last reviewed commit: "fix: reject malformed client-reported te..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server/headless.rs (1)

1052-1101: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve the startup TERM_PROGRAM seed until a real client disconnects. src/server/headless.rs:1052 clears OUTER_TERM_PROGRAM whenever foreground_client_id is None, but handle_api_request_with_shutdown_check_inner() calls this before every API request, including the headless WorkspaceCreate/TabCreate requests from handle_deferred_requests_headless(). That wipes the launch identity seeded in run_server() before those panes are spawned. Move the reset into the actual detach/disconnect path instead of the generic no-client sync.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b11d115b-fe40-474f-9420-01e02015d9f8

📥 Commits

Reviewing files that changed from the base of the PR and between b411274 and 1df625e.

📒 Files selected for processing (16)
  • docs/next/api/herdr-api.schema.json
  • src/client/mod.rs
  • src/main.rs
  • src/pane.rs
  • src/protocol/wire.rs
  • src/remote/unix.rs
  • src/server/autodetect.rs
  • src/server/client_transport.rs
  • src/server/clients.rs
  • src/server/headless.rs
  • src/terminal_notify.rs
  • tests/api_ping.rs
  • tests/cross_area.rs
  • tests/multi_client.rs
  • tests/server_headless.rs
  • tests/support/mod.rs

Comment thread src/pane.rs Outdated
jvm added 4 commits July 30, 2026 23:35
Panes inherit a snapshot of the server process environment, and the server is
persistent: its TERM_PROGRAM belongs to whichever terminal started it, not to
the client currently attached. Under `herdr --remote` the server is started by
sshd and has no terminal identity at all, so remote panes saw no TERM_PROGRAM
and applications fell back to their no-inline-image path.

Carry the identity in ClientMessage::Hello, store it per connection, and apply
it in apply_pane_terminal_env, which every pane spawn path already funnels
through. The SSH bridge forwards no environment, so the wire is the only
channel that reaches a remote server.

TERM stays xterm-256color; only the outer identity is propagated. With no
attached client TERM_PROGRAM is removed rather than left stale.
TERM_PROGRAM is not the only identity panes inherit from the server process.
A server started under kitty and later attached from a terminal without
inline image support still handed panes KITTY_WINDOW_ID, so applications
selected a graphics protocol the real outer terminal could not render.

Remove the identity variables that no longer describe the attached client
alongside TERM_PROGRAM. Applications that read them fall back to the
TERM_PROGRAM the client actually reported.
Move outer_term_program next to detect_backend in terminal_notify, which
already owns terminal identification, and give the backend enum the
TERM_PROGRAM spelling it maps to. The client no longer keeps its own copy of
that table.

Monolithic mode has no client to report an identity, so panes were losing
TERM_PROGRAM with nothing set in its place. That process owns the terminal
directly, so seed the identity from its own environment.

Also fold the two identical no-foreground-client resets in
sync_foreground_client_state into one, apply the identity without cloning it
per spawn, keep a poisoned lock from stranding panes on a departed client,
and extend the scrub list to the identity variables the previous set missed.
LC_TERMINAL matters most: SSH forwards LC_* by default, so it reaches a
remote server naming the local terminal.
…panes

Panes are spawned while the app is built, which happens before any client can
attach and report its terminal. Restored panes therefore had TERM_PROGRAM
removed and never received one. Remote attach hits this every time: a server
that has to restart restores the whole session first, so every pane the user
lands in predates the client.

Seed the identity at server startup from whoever launched the process. A
locally spawned daemon inherits it from the client that started it. A remote
server is started by the ssh bridge, which runs under `ssh -T` and so is
given no pty and neither TERM_PROGRAM nor TERM; name the local terminal on
that command line instead. An attaching client still overrides it.
@jvm
jvm force-pushed the fix/pane-term-program branch from 1df625e to 4c59de6 Compare July 30, 2026 22:44
@jvm

jvm commented Jul 30, 2026

Copy link
Copy Markdown
Author

Pushed a fix for the ubuntu failure: tests/cli/sessions.rs had six more hardcoded protocol: 18 expectations that I missed when bumping the version.

I could not have caught that locally — tests/cli.rs and tests/auto_detect.rs are #![cfg(not(target_os = "macos"))], so the whole CLI suite is invisible on my machine and the macOS job passes without it. I have since run the full suite on a Linux box: 3114/3114 passing, plus cargo fmt --check and clippy --all-targets --locked -D warnings clean on Linux with the pinned 1.96.1 toolchain.

Grepped the macOS-excluded suites for any other assumptions this change could break; the remaining protocol references all derive from support::CURRENT_PROTOCOL. website/preview.json also pins protocol 18, but I left it alone since release CI owns it.

Two gaps in the identity propagation, both found in review.

sync_foreground_client_state cleared the identity whenever no client was in
the foreground, and it runs before every API request. The headless workspace
and tab creates that spawn panes at startup have no client yet, so the launch
seed was dropped before the panes it exists for were spawned. Clearing now
happens in promote_latest_remaining_client, the actual detach transition.

Pane children also inherited HERDR_OUTER_TERM_PROGRAM. It outranks TERM_PROGRAM
in launched_outer_term_program, so a nested persistent herdr started inside a
pane preferred the outer server's identity over the pane's own. It joins the
stale identity variables stripped at spawn.

Both tests fail without their fix.
@jvm

jvm commented Jul 30, 2026

Copy link
Copy Markdown
Author

@coderabbitai both findings were real and are fixed in 3c08fd5.

Startup seed cleared before deferred panes spawn (Major). Confirmed: the sync_foreground_client_state() call at src/server/headless.rs:3194 is inside handle_api_request_with_shutdown_check_inner and runs before dispatch, so any API request with no client attached reached the no-client branch and dropped the seed — including the headless workspace/tab creates that spawn panes at startup, which is exactly what the seed exists for. I took your suggestion and moved the reset to promote_latest_remaining_client, the real detach transition (called from the three client-removal paths at 1474/1739/2601). The seed is deliberately not restored on detach: it described whoever started the server, not whoever just left.

Launch seed inherited by pane children (Minor). Fixed as you proposed, with the variable seeded in capture_shell_output and asserted cleared.

Both new tests were verified to fail with their fix reverted, so they pin the behavior rather than just passing. Full suite on Linux: 3117/3117, with fmt --check and clippy --all-targets --locked -D warnings clean on Linux and macOS under the pinned 1.96.1.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@jvm Thanks for the thorough confirmation. The revised lifecycle is correct: preserving the launch seed through startup/deferred pane creation and clearing identity only on an actual client-detach transition addresses the major finding. The child-environment regression coverage addresses the minor finding as well.

🐇 ✅


✏️ Learnings added
Learnt from: jvm
URL: https://github.com/herdrdev/herdr/pull/2106

Timestamp: 2026-07-30T22:59:30.674Z
Learning: In `src/server/headless.rs`, the launched outer-terminal identity seed must survive headless startup API requests and deferred pane creation. It is cleared only as part of a real client-removal transition in `promote_latest_remaining_client`; it is not restored after detachment because it represents the terminal that launched the server, not the departing client.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/server/client_transport.rs (1)

522-533: 🎯 Functional Correctness | 🔵 Trivial

Consider hardening the term_program filter beyond empty strings.

This is the sole normalization point for client-reported term_program before it flows (per the PR summary) into pane spawn environment. Only empty strings are filtered; NUL bytes or other control characters are passed through unchanged. Since this value is client-controlled (including over herdr --remote) and later used as an env var value for spawned processes, it's worth filtering out NUL bytes (and maybe other control characters) here as well, to avoid depending on downstream spawn-env handling to gracefully reject malformed values.

🛡️ Proposed hardening
-                term_program.filter(|value| !value.is_empty())
+                term_program.filter(|value| !value.is_empty() && !value.contains('\0'))

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a37b1c48-7bc4-4a19-8d83-8eed37859a64

📥 Commits

Reviewing files that changed from the base of the PR and between 1df625e and 3c08fd5.

📒 Files selected for processing (17)
  • docs/next/api/herdr-api.schema.json
  • src/client/mod.rs
  • src/main.rs
  • src/pane.rs
  • src/protocol/wire.rs
  • src/remote/unix.rs
  • src/server/autodetect.rs
  • src/server/client_transport.rs
  • src/server/clients.rs
  • src/server/headless.rs
  • src/terminal_notify.rs
  • tests/api_ping.rs
  • tests/cli/sessions.rs
  • tests/cross_area.rs
  • tests/multi_client.rs
  • tests/server_headless.rs
  • tests/support/mod.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/api_ping.rs
  • tests/cross_area.rs

The identity a client reports for itself becomes an environment value on every
pane it spawns, and it arrives from whatever is on the other end of the socket,
including a remote bridge. Only empty values were filtered.

No real TERM_PROGRAM contains control characters, and an interior NUL cannot
survive the conversion to a C string at spawn time, so a malformed value would
have failed every pane spawn for that client. Treat it as no identity instead.
@jvm

jvm commented Jul 30, 2026

Copy link
Copy Markdown
Author

@coderabbitai took the nitpick too, in 0df12e4 — it sits on a real trust boundary, since the reported identity crosses the socket from a possibly remote client and lands in the spawn environment of every pane.

Went slightly wider than the proposed diff: rejecting all control characters rather than NUL alone, since no real TERM_PROGRAM contains one and the NUL case is only the subset that happens to be fatal at CString conversion. Extracted the predicate as normalize_reported_term_program with unit tests for the empty, NUL, newline, and escape-sequence cases.

Linux suite now 3119/3119, lint clean on Linux and macOS.

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@jvm Thanks—this is the right boundary to harden. Rejecting all control characters in normalize_reported_term_program prevents untrusted handshake data from affecting pane child environments, including the fatal NUL case and terminal-control injection cases. The focused regression tests cover the relevant inputs well.

🐇 ✅


✏️ Learnings added
Learnt from: jvm
URL: https://github.com/herdrdev/herdr/pull/2106

Timestamp: 2026-07-30T23:10:38.133Z
Learning: In the Rust terminal-identity handshake path, `TERM_PROGRAM` is client-reported data crossing a socket trust boundary before being applied to pane child-process environments. Normalize it with `normalize_reported_term_program` by treating empty values as absent and rejecting every Unicode control character (not only NUL); unit coverage should include empty, NUL, newline, and escape-sequence values.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@ogulcancelik

ogulcancelik commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

thanks for the detailed work. i think the coding agent in your reproduction is pi: pi’s terminal ui chooses image support from TERM_PROGRAM and falls back to no images when it is absent.

we tested current herdr 0.7.5 over --remote against linux. yazi asks herdr whether kitty graphics are supported and renders the image correctly without TERM_PROGRAM. sending kitty graphics directly also works. the remote image path itself is not broken.

we do not want panes to claim they are running directly in the attached outer terminal. the outer client can change while a running process’s environment cannot, and forwarding the identity can make applications choose protocols herdr does not support. for example, yazi chooses the iterm image protocol when it sees wezterm, while herdr currently supports kitty graphics.

this requires fix on upstream pi.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Trigger automated AI reviews for pull requests admitted by the PR gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants