Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/freshell-protocol/tests/roundtrip.rs
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ fn rich_server_messages() {
other => panic!("expected Error, got {other:?}"),
}

// error — SESSION_IDENTITY_MISMATCH with a populated actualSessionRef
// (the stale-expectation terminal.input bounce).
let wire = r#"{"type":"error","code":"SESSION_IDENTITY_MISMATCH","message":"no such terminal","timestamp":"2026-07-05T00:00:00.000Z","requestId":"req-1","terminalId":"t1","terminalExitCode":1,"expectedSessionRef":{"provider":"opencode","sessionId":"ses_a"},"actualSessionRef":{"provider":"opencode","sessionId":"ses_b"}}"#;
match server_roundtrip(wire, "error") {
ServerMessage::Error(e) => assert_eq!(e.code, ErrorCode::SessionIdentityMismatch),
other => panic!("expected Error, got {other:?}"),
}

// terminal.output.batch — segments[] with a barrier enum.
let wire = r#"{"type":"terminal.output.batch","attachRequestId":"a1","data":"aGVsbG8=","segments":[{"seqStart":1,"seqEnd":3,"endOffset":5,"rawFrameCount":2,"barrier":"turn_complete","data":"aGk="}],"seqStart":1,"seqEnd":3,"serializedBytes":42,"source":"live","streamId":"s1","terminalId":"t1"}"#;
match server_roundtrip(wire, "terminal.output.batch") {
Expand Down
320 changes: 294 additions & 26 deletions crates/freshell-ws/src/opencode_signal.rs

Large diffs are not rendered by default.

170 changes: 170 additions & 0 deletions crates/freshell-ws/src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,28 @@ async fn handle_client_text(
}
}
ClientMessage::TerminalInput(input) => {
// Node-parity frame (server/ws-handler.ts:2902-2925), scoped to
// the opencode lane (see input_session_identity_ok): a
// terminal.input frame whose opencode expectedSessionRef no
// longer matches the terminal's canonical opencode identity is
// bounced with SESSION_IDENTITY_MISMATCH (actualSessionRef
// populated) instead of being written to a PTY the client no
// longer means. Unresolved identity and non-opencode lanes fail
// open -- see input_session_identity_ok.
if let Some(expected) = input.expected_session_ref.as_ref() {
let canonical = state.identity.session_ref_for(&input.terminal_id);
if !input_session_identity_ok(Some(expected), canonical.as_ref()) {
return send(
ws_tx,
&input_session_identity_mismatch_error(
&input.terminal_id,
expected.clone(),
canonical,
),
)
.await;
}
}
// Lane B2: MUST complete before the Enter reaches the PTY — the
// codex locator's FIRST-submit re-snapshot has to finish before
// codex can materialize the rollout this very Enter triggers
Expand Down Expand Up @@ -3611,6 +3633,62 @@ fn attach_geometry_identity_ok(
}
}

/// Input-path identity guard (Node-parity FRAME, `server/ws-handler.ts:2902-2925`
/// `inputIfSessionMatches`, but deliberately NARROWER in scope). Two
/// deviations, both load-bearing:
/// 1. SCOPE: the guard bounces only when BOTH refs are `provider ==
/// "opencode"`. Claude (`claude_signal.rs:192`, every in-TUI
/// /clear-resume) and codex (`codex_association.rs:207-220` fork rebind)
/// have legitimate identity swap windows, and the client fold's
/// conflict-veto can pin a pane on a stale ref indefinitely -- an
/// all-provider guard would bounce keystrokes that are delivered
/// correctly today. Only the opencode signal-rebind lane this plan
/// hardens is guarded; everything else passes through.
/// 2. Deliberately LAXER than `attach_geometry_identity_ok` in the
/// `(Some, None)` case: canonical identity is None during the opencode
/// locator correlation window AND permanently for REST/fresh-agent-created
/// resume panes (they never reach the WS identity registry) -- attach's
/// silent resize-skip is harmless there, a dropped keystroke is not, so
/// unresolved identity FAILS OPEN and the input is delivered.
fn input_session_identity_ok(
expected: Option<&SessionLocator>,
canonical: Option<&SessionLocator>,
) -> bool {
match (expected, canonical) {
(None, _) => true,
(Some(_), None) => true,
(Some(e), Some(c)) => {
if e.provider != "opencode" || c.provider != "opencode" {
return true;
}
e == c
}
}
}

/// The bounce frame for a stale-expectation `terminal.input`: the FIRST
/// `actual_session_ref: Some(..)` emitter in the Rust tree. The client's
/// stale-window suppression (terminal-view-utils.ts
/// isStaleSessionIdentityMismatch) keys on that echo, so it must carry the
/// canonical ref whenever one exists (and the guard only fires when it does).
fn input_session_identity_mismatch_error(
terminal_id: &str,
expected: SessionLocator,
actual: Option<SessionLocator>,
) -> ServerMessage {
ServerMessage::Error(ErrorMsg {
code: ErrorCode::SessionIdentityMismatch,
message: "Terminal session does not match the expected session.".to_string(),
timestamp: crate::now_iso(),
actual_session_ref: actual,
expected_session_ref: Some(expected),
request_id: None,
retry_after_ms: None,
terminal_exit_code: None,
terminal_id: Some(terminal_id.to_string()),
})
}

/// Node-parity geometry bounds for `terminal.attach` / `terminal.resize`
/// (`shared/ws-protocol.ts:344-345, 364-365`): `cols` must be an integer in
/// `[2, 1000]` and `rows` in `[2, 500]`. Node enforces this at the Zod
Expand Down Expand Up @@ -4957,6 +5035,98 @@ mod attach_geometry_tests {
}
}

#[cfg(test)]
mod input_identity_tests {
use super::{input_session_identity_mismatch_error, input_session_identity_ok};
use freshell_protocol::SessionLocator;

fn locator(provider: &str, session_id: &str) -> SessionLocator {
SessionLocator {
provider: provider.to_string(),
session_id: session_id.to_string(),
}
}

#[test]
fn no_expectation_always_ok() {
assert!(input_session_identity_ok(None, None));
assert!(input_session_identity_ok(
None,
Some(&locator("opencode", "ses_a"))
));
}

#[test]
fn unresolved_identity_fails_open() {
// Deliberately laxer than attach_geometry_identity_ok: canonical
// identity is None not only during the opencode locator correlation
// window after spawn but PERMANENTLY for REST/fresh-agent-created
// resume panes (they never reach the WS identity registry) -- while
// the client already sends an expectation. Never drop their
// keystrokes: (Some, None) fails open, for life if need be.
assert!(input_session_identity_ok(
Some(&locator("opencode", "ses_a")),
None
));
}

#[test]
fn non_opencode_divergence_passes_through() {
// Guard scope (LB3): claude (/clear-resume) and codex (fork rebind)
// have LEGITIMATE identity swap windows -- a divergent (Some, Some)
// pair outside the opencode lane must deliver, not bounce.
assert!(input_session_identity_ok(
Some(&locator("claude", "ses_a")),
Some(&locator("claude", "ses_b"))
));
assert!(input_session_identity_ok(
Some(&locator("codex", "thread_a")),
Some(&locator("codex", "thread_b"))
));
// Cross-provider disagreement (never produced by this plan's lanes)
// also fails open.
assert!(input_session_identity_ok(
Some(&locator("codex", "ses_a")),
Some(&locator("opencode", "ses_b"))
));
}

#[test]
fn matching_expectation_ok() {
assert!(input_session_identity_ok(
Some(&locator("opencode", "ses_a")),
Some(&locator("opencode", "ses_a"))
));
}

#[test]
fn differing_expectation_is_a_mismatch() {
assert!(!input_session_identity_ok(
Some(&locator("opencode", "ses_a")),
Some(&locator("opencode", "ses_b"))
));
}

#[test]
fn mismatch_error_serializes_with_actual_session_ref() {
let msg = input_session_identity_mismatch_error(
"term-1",
locator("opencode", "ses_a"),
Some(locator("opencode", "ses_b")),
);
let value = serde_json::to_value(&msg).expect("serialize");
assert_eq!(value["type"], "error");
assert_eq!(value["code"], "SESSION_IDENTITY_MISMATCH");
assert_eq!(value["terminalId"], "term-1");
assert_eq!(value["expectedSessionRef"]["sessionId"], "ses_a");
assert_eq!(value["actualSessionRef"]["sessionId"], "ses_b");
assert_eq!(
value["message"],
"Terminal session does not match the expected session."
);
}
}

#[cfg(test)]
mod terminal_dims_range_tests {
use super::{invalid_dims_error, terminal_dims_in_range, unknown_terminal_input_blocked};
Expand Down
90 changes: 90 additions & 0 deletions crates/freshell-ws/tests/opencode_switch_rebind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,42 @@ fn write_opencode_signal(root: &std::path::Path, terminal_id: &str, seq: u64, se
.unwrap();
}

/// Send one raw client frame (the [`send_create`] transmit idiom, generalized
/// to any payload).
#[cfg(unix)]
async fn send_json(ws: &mut common::TestWs, value: serde_json::Value) {
ws.send(WsMessage::Text(value.to_string()))
.await
.expect("send raw client frame");
}

/// Scan WS text frames until `pred` matches (10 s budget, the
/// [`next_associated_frame`] deadline-poll loop generalized to an arbitrary
/// predicate), returning the matching frame; panic with `label` on timeout.
#[cfg(unix)]
async fn wait_for_frame(
ws: &mut common::TestWs,
label: &str,
pred: impl Fn(&serde_json::Value) -> bool,
) -> serde_json::Value {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while tokio::time::Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
match tokio::time::timeout(remaining.max(Duration::from_millis(1)), ws.next()).await {
Ok(Some(Ok(WsMessage::Text(text)))) => {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
if pred(&value) {
return value;
}
}
}
Ok(Some(Ok(_))) => {}
other => panic!("[{label}] ws ended/errored/timed out awaiting frame: {other:?}"),
}
}
panic!("[{label}] no matching frame within 10s");
}

/// [`common::spawn_server_with_specs`], but ALSO returning the `WsState`
/// handle so the test can drive `drain_and_rebind_opencode` directly
/// (deterministic — no sweep-timer race), and with an ENABLED pane ledger
Expand Down Expand Up @@ -476,6 +512,60 @@ async fn tui_switch_signal_rebinds_and_restart_resumes_the_new_id() {
"the acted-on signal file must be deleted (act-then-delete)"
);

// ────── Phase 1b — stale-expectation input bounce (Node-parity frame,
// server/ws-handler.ts:2902-2925; guard SCOPED to the opencode lane,
// which this rebind is): an in-flight terminal.input still
// carrying the OLD ref A after the A→B rebind is bounced with
// SESSION_IDENTITY_MISMATCH echoing actualSessionRef=B and is NOT
// delivered; a frame carrying the NEW ref B is delivered with no error.
// (Non-opencode divergence never bounces — pinned by the in-module
// test non_opencode_divergence_passes_through.)
send_json(
&mut ws,
json!({
"type": "terminal.input",
"terminalId": tid1,
"data": "stale-ref-keystroke",
"expectedSessionRef": { "provider": "opencode", "sessionId": A },
}),
)
.await;
let bounce = wait_for_frame(&mut ws, "phase1b/mismatch", |v| {
v["type"] == "error"
&& v["code"] == "SESSION_IDENTITY_MISMATCH"
&& v["terminalId"] == json!(tid1)
})
.await;
assert_eq!(
bounce["actualSessionRef"],
json!({ "provider": "opencode", "sessionId": B }),
"bounce must echo the canonical (post-rebind) ref: {bounce}"
);
assert_eq!(
bounce["expectedSessionRef"],
json!({ "provider": "opencode", "sessionId": A }),
"bounce must echo the stale expectation: {bounce}"
);

send_json(
&mut ws,
json!({
"type": "terminal.input",
"terminalId": tid1,
"data": "fresh-ref-keystroke\r",
"expectedSessionRef": { "provider": "opencode", "sessionId": B },
}),
)
.await;
let bounced_again = frame_seen_within(&mut ws, Duration::from_secs(1), |v| {
v["type"] == "error" && v["code"] == "SESSION_IDENTITY_MISMATCH"
})
.await;
assert!(
!bounced_again,
"a matching expectedSessionRef must be delivered, not bounced"
);

// ── Phase 2 — the restart story: kill, then replay EXACTLY what a
// client that accepted the rebind persists (sessionRef {opencode, B} +
// restore:true). The respawned opencode must launch `--session B`.
Expand Down
Loading
Loading