From 0c0bf017d32a960f590d7f8f219d3ee80946f42e Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 4 Nov 2026 08:54:19 -0800 Subject: [PATCH 1/3] fix(node): preserve Claude SDK inbox delivery with mobile enabled --- CHANGELOG.md | 4 + crates/relay-node/README.md | 7 ++ .../scripts/test-claude-mobile-plugin.mjs | 39 +++++++- .../src/relay/claude_mobile_plugin.rs | 47 +++++++-- .../claude_mobile_plugin/hooks/register.mjs | 22 ++++- .../src/relay/claude_mobile_plugin/tests.rs | 18 +++- crates/relay-node/src/relay/local.rs | 30 ++++++ .../src/relay/local/directory_trait.rs | 2 +- .../directory_trait/mobile_claude_roots.rs | 2 +- .../mobile_claude_roots/tests.rs | 51 ++++++++++ crates/relay-node/src/relay/mobile_handoff.rs | 3 + .../src/relay/mobile_handoff/journal.rs | 40 +++++++- .../src/relay/mobile_handoff/journal/tests.rs | 32 ++++++ .../src/relay/mobile_handoff/refusal.rs | 23 +++++ .../src/relay/outbound/mobile_handoff.rs | 98 +++++++++++++++---- 15 files changed, 377 insertions(+), 41 deletions(-) create mode 100644 crates/relay-node/src/relay/mobile_handoff/refusal.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 881434529b..12668d9e25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased - Major] +### Fixed + +- Claude sessions hosted by apps keep receiving inbox messages when mobile continuation is enabled; SDK and pipe-driven sessions no longer enter the terminal's native mobile route. Native submission refusals explain stopped sessions and host limits without retrying uncertain or refused input. + ### Changed - The core is now `agent-relay-node`: `relay init` installs it from `relay-node-v` releases, and `AGENT_RELAY_NODE_PATH` points at a binary of your own. diff --git a/crates/relay-node/README.md b/crates/relay-node/README.md index 121e4c5ddd..bbdb7c2ffc 100644 --- a/crates/relay-node/README.md +++ b/crates/relay-node/README.md @@ -58,6 +58,13 @@ CLI selects (`src/relay/workspace/cli.rs`), with no sign-in needed. ## Build and test +Claude Code terminal sessions can receive the verified owner's mobile messages +as native user turns through Relay's plugin. SDK and pipe-driven sessions, +including app-hosted chats, keep their ordinary peer inbox instead: their host +owns the input and interrupt lifecycle. Turning mobile continuation on does not +replace that inbox. A native submission refused after Stop or by a host limit +is reported without automatic replay; unknown outcomes are never resubmitted. + ```sh cargo build --manifest-path crates/relay-node/Cargo.toml --locked cargo build --manifest-path crates/relay-node/Cargo.toml --locked --release --bin agent-relay-node diff --git a/crates/relay-node/scripts/test-claude-mobile-plugin.mjs b/crates/relay-node/scripts/test-claude-mobile-plugin.mjs index 193915f7cf..d4775e4a5b 100644 --- a/crates/relay-node/scripts/test-claude-mobile-plugin.mjs +++ b/crates/relay-node/scripts/test-claude-mobile-plugin.mjs @@ -80,7 +80,9 @@ async function fixture(options = {}) { prompt: { submit: async (value) => { submitted.push(value); - return options.drop ? { drop: 'Policy refused' } : { text: value.text }; + return options.drop + ? { drop: options.drop === true ? 'Policy refused' : options.drop } + : { text: value.text }; }, }, }; @@ -854,3 +856,38 @@ test( assert.deepEqual(f.submitted, [{ text: turn.text, asUser: true }]); } ); + +test('recognized refusals are bounded, negotiated and never retried', async () => { + for (const [drop, refusal] of [ + ['the session was stopped and nobody has sent input since', 'stopped'], + ['the prompt was removed from the queue before it ran', 'stopped'], + ['the host has ended its input', 'host_ended'], + ['the run reached its --max-turns limit', 'turn_limit'], + ['the run is past its --max-budget-usd limit', 'budget_limit'], + ['secret prompt /private/path refused by a user hook', undefined], + ]) { + const f = await fixture({ action: { ...turn, refusal_codes: true }, drop }); + await f.tick(); + await f.tick(); + const expected = { lease_id: turn.lease_id, outcome: 'failed' }; + if (refusal) expected.refusal = refusal; + assert.deepEqual( + f.calls.filter((call) => call.route === 'ack').map((call) => call.body), + [expected] + ); + assert.equal(f.submitted.length, 1); + assert.equal( + f.calls.some((call) => JSON.stringify(call.body).includes(drop)), + false + ); + } +}); + +test('a new plugin keeps failure acknowledgements compatible with old cores', async () => { + const f = await fixture({ action: turn, drop: 'the session was stopped and nobody has sent input since' }); + await f.tick(); + assert.deepEqual( + f.calls.filter((call) => call.route === 'ack').map((call) => call.body), + [{ lease_id: turn.lease_id, outcome: 'failed' }] + ); +}); diff --git a/crates/relay-node/src/relay/claude_mobile_plugin.rs b/crates/relay-node/src/relay/claude_mobile_plugin.rs index e33357df4a..c5c40b138e 100644 --- a/crates/relay-node/src/relay/claude_mobile_plugin.rs +++ b/crates/relay-node/src/relay/claude_mobile_plugin.rs @@ -164,6 +164,7 @@ impl Core { pub(crate) fn session_roots( config: &crate::relay::claude::ClaudeConfig, sessions: &[crate::relay::claude::TerminalSession], + table: &dyn crate::relay::process::ProcessTable, ) -> std::collections::BTreeMap { let mut roots = std::collections::BTreeMap::new(); for session in sessions { @@ -171,15 +172,14 @@ pub(crate) fn session_roots( crate::relay::store::SessionKind::ClaudeTerminal, &session.session_id, ); - let root = - matches!(session.kind.as_deref(), Some("interactive" | "background")).then(|| { - session - .registry - .as_deref() - .and_then(Path::parent) - .unwrap_or(&config.home) - .to_path_buf() - }); + let root = native_input_supported(session, table).then(|| { + session + .registry + .as_deref() + .and_then(Path::parent) + .unwrap_or(&config.home) + .to_path_buf() + }); roots .entry(target) .and_modify(|known| *known = None) @@ -191,6 +191,35 @@ pub(crate) fn session_roots( .collect() } +/// An app can publish an `interactive` session while owning Claude's input +/// through the SDK. Its stop/input lifecycle does not belong to the mod: +/// `prompt.submit` may reject even while the peer inbox remains available. +/// Keep those sessions on their existing inbox route, independently of the +/// mobile setting. Check the running process too: entrypoint labels alone +/// do not distinguish every host, and unreadable argv proves no capability. +pub(crate) fn native_input_supported( + session: &crate::relay::claude::TerminalSession, + table: &dyn crate::relay::process::ProcessTable, +) -> bool { + if !matches!(session.kind.as_deref(), Some("interactive" | "background")) + || session + .entrypoint + .as_deref() + .is_some_and(|entry| entry == "sdk" || entry.starts_with("sdk-")) + { + return false; + } + let args = table.arguments(session.pid); + !args.is_empty() + && !args.iter().any(|arg| { + arg == "-p" + || arg == "--print" + || arg.starts_with("--print=") + || arg == "--input-format" + || arg.starts_with("--input-format=") + }) +} + pub(crate) fn install(config: &Path, socket: &Path) -> Result<(), String> { if !config.is_absolute() || !socket.is_absolute() { return Err("Claude's configuration and Agent Relay bridge paths must be absolute.".into()); diff --git a/crates/relay-node/src/relay/claude_mobile_plugin/hooks/register.mjs b/crates/relay-node/src/relay/claude_mobile_plugin/hooks/register.mjs index 0f331b43a6..1ef85ff0a1 100644 --- a/crates/relay-node/src/relay/claude_mobile_plugin/hooks/register.mjs +++ b/crates/relay-node/src/relay/claude_mobile_plugin/hooks/register.mjs @@ -24,6 +24,19 @@ const PERMISSION_WAIT_MS = 300000; // comfortably below the core's 96 KiB limit, including the visible notice. const MAX_ANSWER_UNITS = 24000; +// Do not forward arbitrary hook text to other devices. These are provider +// refusals, not transport failures, and none makes a submission retryable. +function refusalCode(reason) { + switch (reason) { + case "the session was stopped and nobody has sent input since": + case "the prompt was removed from the queue before it ran": return "stopped"; + case "the host has ended its input": return "host_ended"; + case "the run reached its --max-turns limit": return "turn_limit"; + case "the run is past its --max-budget-usd limit": return "budget_limit"; + default: return undefined; + } +} + function mobileAnswer(text) { if (text.length <= MAX_ANSWER_UNITS) return text; let end = MAX_ANSWER_UNITS; @@ -205,10 +218,15 @@ async function tick($) { } else if (forward) { controlToken = action.control_token; } - await request($, "ack", { + const acknowledgement = { lease_id: action.lease_id, outcome: admitted?.drop === undefined ? "started" : "failed", - }); + }; + const refusal = refusalCode(admitted?.drop); + // Older cores accept exactly two fields. Negotiate diagnostics on + // each action so a plugin upgrade or rollback cannot lose its ack. + if (action.refusal_codes === true && refusal) acknowledgement.refusal = refusal; + await request($, "ack", acknowledgement); } finally { confirming = false; } diff --git a/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs b/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs index a510ed78d4..071bde35b7 100644 --- a/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs +++ b/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs @@ -324,25 +324,35 @@ fn native_config_root_resolution_rejects_duplicate_session_ids() { let temp = tempfile::tempdir().unwrap(); let config = crate::relay::claude::ClaudeConfig::resolve(&|_| None, temp.path(), &[]); let mut session = crate::relay::claude::TerminalSession::decode(br#"{"sessionId":"00000000-0000-4000-8000-000000000001","pid":123,"messagingSocketPath":"/tmp/claude.sock","kind":"interactive"}"#).unwrap(); + let table = crate::relay::process::FakeProcesses::default(); + for pid in [123, 124] { + table.insert( + pid, + crate::relay::process::FakeProcess { + arguments: vec!["claude".into()], + ..Default::default() + }, + ); + } let target = crate::relay::store::Target::new( crate::relay::store::SessionKind::ClaudeTerminal, &session.session_id, ); assert_eq!( - session_roots(&config, &[session.clone()])[&target], + session_roots(&config, &[session.clone()], &table)[&target], config.home ); session.registry = Some(temp.path().join("work/sessions")); assert_eq!( - session_roots(&config, &[session.clone()])[&target], + session_roots(&config, &[session.clone()], &table)[&target], temp.path().join("work") ); let mut duplicate = session.clone(); duplicate.pid = 124; duplicate.registry = None; - assert!(session_roots(&config, &[session.clone(), duplicate]).is_empty()); + assert!(session_roots(&config, &[session.clone(), duplicate], &table).is_empty()); session.kind = Some("unknown".into()); - assert!(session_roots(&config, &[session]).is_empty()); + assert!(session_roots(&config, &[session], &table).is_empty()); } #[test] diff --git a/crates/relay-node/src/relay/local.rs b/crates/relay-node/src/relay/local.rs index 40d2bb9f3b..73ddb15df2 100644 --- a/crates/relay-node/src/relay/local.rs +++ b/crates/relay-node/src/relay/local.rs @@ -6445,6 +6445,14 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo #[test] fn mobile_native_runtime_requires_exact_native_process_and_rejects_tool_descendants() { let world = world(); + world + .table + .processes + .lock() + .unwrap() + .get_mut(&300) + .unwrap() + .arguments = vec!["claude".into()]; let path = world.sessions.config.claude.registry().join("300.json"); let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); @@ -6462,6 +6470,28 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo "ordinary Relay descendant identification stays available" ); assert_eq!((runtime.pid, runtime.start, runtime.idle), (300, 100, true)); + world + .table + .processes + .lock() + .unwrap() + .get_mut(&300) + .unwrap() + .arguments = vec!["claude".into(), "--input-format=stream-json".into()]; + assert!(world.sessions.mobile_claude_runtime(&target, 300).is_none(), + "a running SDK host cannot submit mobile input even with an interactive registry record"); + assert!( + world.sessions.caller(320).is_ok(), + "the ordinary session route stays available" + ); + world + .table + .processes + .lock() + .unwrap() + .get_mut(&300) + .unwrap() + .arguments = vec!["claude".into()]; assert_eq!( world.sessions.mobile_claude_runtime(&target, 300), Some(runtime.clone()) diff --git a/crates/relay-node/src/relay/local/directory_trait.rs b/crates/relay-node/src/relay/local/directory_trait.rs index ae4eca9f28..9a38ca5ba5 100644 --- a/crates/relay-node/src/relay/local/directory_trait.rs +++ b/crates/relay-node/src/relay/local/directory_trait.rs @@ -36,7 +36,7 @@ impl SessionDirectory for LocalSessions { if matches.next().is_some() { return None; } - if !matches!(session.kind.as_deref()?, "background" | "interactive") { + if !crate::relay::claude_mobile_plugin::native_input_supported(session, &*self.table) { return None; } // The native Mods HTTP client runs inside Claude itself. A tool, diff --git a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots.rs b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots.rs index 5f4b2086e0..42217e7ed9 100644 --- a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots.rs +++ b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots.rs @@ -50,7 +50,7 @@ pub(super) fn read(config: &ClaudeConfig, table: &dyn ProcessTable) -> BTreeMap< } } } - crate::relay::claude_mobile_plugin::session_roots(config, &sessions) + crate::relay::claude_mobile_plugin::session_roots(config, &sessions, table) } #[cfg(test)] diff --git a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs index 4931ebdf7f..7ab472fc19 100644 --- a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs +++ b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs @@ -37,6 +37,7 @@ fn processes(pids: &[i32]) -> FakeProcesses { *pid, FakeProcess { start: 123, + arguments: vec!["claude".into()], ..Default::default() }, ); @@ -102,3 +103,53 @@ fn unreadable_live_record_suspends_attribution_but_dead_record_does_not() { [(Target::new(SessionKind::ClaudeTerminal, SESSION), a)].into() ); } + +#[test] +fn sdk_host_keeps_its_inbox_but_never_advertises_native_mobile_input() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("login"); + let b = dir.path().join("unused"); + record(&a, 42, 123); + let config = config(&a, &b); + let table = processes(&[42]); + assert_eq!(read(&config, &table).len(), 1); + for arguments in [ + vec!["claude", "--input-format", "stream-json"], + vec!["claude", "--input-format=stream-json"], + vec!["claude", "-p", "hello"], + vec!["claude", "--print"], + vec![], + ] { + table + .processes + .lock() + .unwrap() + .get_mut(&42) + .unwrap() + .arguments = arguments.iter().map(|arg| (*arg).into()).collect(); + assert!(read(&config, &table).is_empty(), "{arguments:?}"); + assert_eq!( + claude::registry(&config, &table).sessions.len(), + 1, + "excluding native capability must not remove the peer inbox" + ); + } + table + .processes + .lock() + .unwrap() + .get_mut(&42) + .unwrap() + .arguments = vec!["claude".into()]; + let path = a.join("sessions/42.json"); + let mut value: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + for entrypoint in ["sdk-cli", "sdk-ts", "sdk"] { + value["entrypoint"] = json!(entrypoint); + std::fs::write(&path, value.to_string()).unwrap(); + assert!(read(&config, &table).is_empty(), "{entrypoint}"); + } + value["entrypoint"] = json!("cli"); + std::fs::write(&path, value.to_string()).unwrap(); + assert_eq!(read(&config, &table).len(), 1); +} diff --git a/crates/relay-node/src/relay/mobile_handoff.rs b/crates/relay-node/src/relay/mobile_handoff.rs index 424f06a791..672ba5f2aa 100644 --- a/crates/relay-node/src/relay/mobile_handoff.rs +++ b/crates/relay-node/src/relay/mobile_handoff.rs @@ -11,8 +11,10 @@ use crate::relay::spawn::drive::direct_from; use crate::relay::store::{Registration, SessionKind}; pub(crate) mod journal; +mod refusal; pub(crate) use journal::Binding; pub use journal::Runtime; +pub(crate) use refusal::Refusal; pub(crate) fn eligible(registration: &Registration) -> bool { registration.target.kind == SessionKind::ClaudeTerminal @@ -479,6 +481,7 @@ fn delivery_outcome(outcome: journal::Outcome) -> InjectOutcome { journal::Outcome::Failed => InjectOutcome::Refused( "Claude could not start the mobile message. The message was not submitted.".into(), ), + journal::Outcome::Refused(reason) => InjectOutcome::Refused(reason.notice().into()), journal::Outcome::InDoubt => InjectOutcome::InDoubt( "Claude's native submission was not confirmed; the message will not be sent again." .into(), diff --git a/crates/relay-node/src/relay/mobile_handoff/journal.rs b/crates/relay-node/src/relay/mobile_handoff/journal.rs index d2e6193aee..2f22fff548 100644 --- a/crates/relay-node/src/relay/mobile_handoff/journal.rs +++ b/crates/relay-node/src/relay/mobile_handoff/journal.rs @@ -1,6 +1,7 @@ //! A durable, bounded native-input queue. Taking text is irreversible: a //! lost acknowledgement never makes it eligible for another submission. +use super::Refusal; use crate::platform::fs::{create_private_dir, write_atomic}; use crate::relay::store::random_uuid; use fs2::FileExt; @@ -52,6 +53,8 @@ struct Entry { /// and turning the setting off cancels it while unsent. #[serde(default)] teammate: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + refusal: Option, } #[derive(Serialize, Deserialize)] @@ -75,6 +78,7 @@ pub(crate) enum Outcome { Waiting, Started, Failed, + Refused(Refusal), InDoubt, Unavailable, } @@ -124,7 +128,7 @@ impl Journal { return Err("The queued message no longer matches this session.".into()); } expire(entry, now); - return Ok(outcome(&entry.state)); + return Ok(outcome(entry)); } if text.len() > MAX_TEXT || self.entries.len() >= MAX_ENTRIES { return Err("The mobile message queue is full.".into()); @@ -137,6 +141,7 @@ impl Journal { until: now + LEASE_MS, }, teammate, + refusal: None, }); Ok(Outcome::Waiting) } @@ -192,7 +197,7 @@ impl Journal { cancel(entry); } expire(entry, now); - Some(outcome(&entry.state)) + Some(outcome(entry)) } pub fn pending_binding(&self, scope: &str, delivery_id: &str) -> Option { @@ -284,6 +289,16 @@ impl Journal { } pub fn notice(&self, scope: &str) -> &'static str { + if let Some(reason) = self + .entries + .iter() + .rev() + .find(|entry| entry.binding.scope == scope) + .filter(|entry| matches!(entry.state, State::Failed)) + .and_then(|entry| entry.refusal) + { + return reason.notice(); + } match self.entries.iter().rev().find(|entry| entry.binding.scope == scope).map(|entry| &entry.state) { Some(State::Unavailable) => "Restart or reload Claude to enable mobile control, then send the message again. This message was not submitted.", Some(State::Failed | State::InDoubt) => "Claude's mobile connection could not be confirmed. Check the message status before sending again.", @@ -461,6 +476,18 @@ impl Journal { } Some(delivery_id) } + + /// Called only after this lease's authenticated failed acknowledgement. + /// It adds an explanation, never a second submission or retry path. + pub fn record_refusal(&mut self, delivery_id: &str, refusal: Refusal) { + if let Some(entry) = self + .entries + .iter_mut() + .find(|entry| entry.delivery_id == delivery_id && matches!(entry.state, State::Failed)) + { + entry.refusal = Some(refusal); + } + } } fn cancel(entry: &mut Entry) { @@ -481,10 +508,13 @@ fn expire(entry: &mut Entry, now: i64) { entry.text.clear(); } -fn outcome(state: &State) -> Outcome { - match state { +fn outcome(entry: &Entry) -> Outcome { + match &entry.state { State::Started => Outcome::Started, - State::Failed => Outcome::Failed, + State::Failed => entry + .refusal + .map(Outcome::Refused) + .unwrap_or(Outcome::Failed), State::InDoubt => Outcome::InDoubt, State::Unavailable => Outcome::Unavailable, _ => Outcome::Waiting, diff --git a/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs b/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs index 9a81f08ea3..6002a1e2f8 100644 --- a/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs +++ b/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs @@ -419,3 +419,35 @@ fn an_open_doorbell_keeps_a_message_waiting_through_a_long_turn() { Some(Outcome::Unavailable) ); } + +#[test] +fn recognized_provider_refusals_survive_restart_without_replay_or_control() { + for reason in [ + Refusal::Stopped, + Refusal::HostEnded, + Refusal::TurnLimit, + Refusal::BudgetLimit, + ] { + let mut queue = Journal::default(); + queue + .enqueue(binding(), "delivery", "private message", 0) + .unwrap(); + let submit = queue.poll(&binding(), &runtime(false), 1).unwrap(); + let id = queue + .ack(&binding(), &runtime(false), lease(&submit), false, 2) + .unwrap(); + queue.record_refusal(&id, reason); + assert!(!queue.confirmed_active_runtime(&binding(), &runtime(false))); + let bytes = serde_json::to_vec(&queue).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("private message")); + let mut restored: Journal = serde_json::from_slice(&bytes).unwrap(); + assert_eq!( + restored + .enqueue(binding(), "delivery", "private message", 3) + .unwrap(), + Outcome::Refused(reason) + ); + assert_eq!(restored.notice(&binding().scope), reason.notice()); + assert!(restored.poll(&binding(), &runtime(false), 4).is_none()); + } +} diff --git a/crates/relay-node/src/relay/mobile_handoff/refusal.rs b/crates/relay-node/src/relay/mobile_handoff/refusal.rs new file mode 100644 index 0000000000..70e9e6e153 --- /dev/null +++ b/crates/relay-node/src/relay/mobile_handoff/refusal.rs @@ -0,0 +1,23 @@ +//! Only known provider reasons cross the bridge. Raw hook errors may contain +//! prompt text or local paths and must never become a sender-visible notice. +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Refusal { + Stopped, + HostEnded, + TurnLimit, + BudgetLimit, +} + +impl Refusal { + pub(crate) fn notice(self) -> &'static str { + match self { + Self::Stopped => "Claude was stopped. Send a message in Claude on your computer to resume it, then send this message again. This message was not submitted.", + Self::HostEnded => "Claude's host closed its input. Reopen the session in its app, then send this message again. This message was not submitted.", + Self::TurnLimit => "Claude reached its host's turn limit. Start a new run in its app, then send this message again. This message was not submitted.", + Self::BudgetLimit => "Claude reached its host's spending limit. Check the limit in its app before sending again. This message was not submitted.", + } + } +} diff --git a/crates/relay-node/src/relay/outbound/mobile_handoff.rs b/crates/relay-node/src/relay/outbound/mobile_handoff.rs index 8a0498627a..417665b9d6 100644 --- a/crates/relay-node/src/relay/outbound/mobile_handoff.rs +++ b/crates/relay-node/src/relay/outbound/mobile_handoff.rs @@ -23,6 +23,41 @@ fn supported(version: &str) -> bool { matches!(numbers.as_slice(), [Ok(2), Ok(1), Ok(patch)] if *patch >= 310) } +fn acknowledgement( + fields: &Map, +) -> Result<(&str, bool, Option), OutboundError> { + if !(2..=3).contains(&fields.len()) + || fields + .keys() + .any(|key| !matches!(key.as_str(), "lease_id" | "outcome" | "refusal")) + { + return Err(error( + "Use lease_id, outcome and an optional refusal code only.", + )); + } + let lease = fields + .get("lease_id") + .and_then(Value::as_str) + .filter(|value| crate::relay::store::is_uuid(value)) + .ok_or_else(|| error("The mobile handoff lease is invalid."))?; + let started = match fields.get("outcome").and_then(Value::as_str) { + Some("started") => true, + Some("failed") => false, + _ => return Err(error("The mobile handoff outcome is invalid.")), + }; + let refusal = fields + .get("refusal") + .map(|value| { + serde_json::from_value::(value.clone()) + .map_err(|_| error("The mobile handoff refusal code is invalid.")) + }) + .transpose()?; + if started && refusal.is_some() { + return Err(error("A started submission cannot carry a refusal code.")); + } + Ok((lease, started, refusal)) +} + impl Outbound { pub(super) async fn mobile_handoff_route( &self, @@ -114,8 +149,12 @@ impl Outbound { self.core.mobile_handoff_changed(®istration); } match action { - Some(action) => serde_json::to_value(action) - .map_err(|_| error("Could not encode the mobile handoff action.")), + Some(action) => { + let mut value = serde_json::to_value(action) + .map_err(|_| error("Could not encode the mobile handoff action."))?; + value["refusal_codes"] = json!(true); + Ok(value) + } // Queued while Claude works: the plugin asks again when its turn // completes rather than on a timer. None if waiting => Ok(json!({"action": null, "waiting": true})), @@ -193,19 +232,7 @@ impl Outbound { uid: u32, fields: &Map, ) -> Result { - if fields.len() != 2 { - return Err(error("Use lease_id and outcome only.")); - } - let lease = fields - .get("lease_id") - .and_then(Value::as_str) - .filter(|value| crate::relay::store::is_uuid(value)) - .ok_or_else(|| error("The mobile handoff lease is invalid."))?; - let started = match fields.get("outcome").and_then(Value::as_str) { - Some("started") => true, - Some("failed") => false, - _ => return Err(error("The mobile handoff outcome is invalid.")), - }; + let (lease, started, refusal) = acknowledgement(fields)?; let (_, registration, _) = self.authorize(pid, uid).await?; let runtime = self.mobile_runtime(®istration.target, pid).await?; let binding = self @@ -216,13 +243,17 @@ impl Outbound { error("Mobile handoff is off or the signed-in machine owner could not be verified.") })?; let settled = journal::edit(&self.core.config.app_data_dir, |queue| { - Ok(queue.ack( + let settled = queue.ack( &binding, &runtime, lease, started, crate::relay::state::now_ms(), - )) + ); + if let (Some(delivery_id), Some(reason)) = (&settled, refusal) { + queue.record_refusal(delivery_id, reason); + } + Ok(settled) }) .map_err(error)?; let Some(delivery_id) = settled else { @@ -253,7 +284,38 @@ impl Outbound { #[cfg(test)] mod tests { - use super::supported; + use super::{acknowledgement, supported}; + use crate::relay::mobile_handoff::Refusal; + use serde_json::json; + + #[test] + fn native_ack_accepts_only_safe_failure_codes_and_legacy_success() { + let lease = "00000000-0000-4000-8000-000000000001"; + for (fields, expected) in [ + (json!({"lease_id":lease,"outcome":"started"}), (true, None)), + (json!({"lease_id":lease,"outcome":"failed"}), (false, None)), + ( + json!({"lease_id":lease,"outcome":"failed","refusal":"stopped"}), + (false, Some(Refusal::Stopped)), + ), + ( + json!({"lease_id":lease,"outcome":"failed","refusal":"host_ended"}), + (false, Some(Refusal::HostEnded)), + ), + ] { + let parsed = acknowledgement(fields.as_object().unwrap()).unwrap(); + assert_eq!(parsed, (lease, expected.0, expected.1)); + } + for fields in [ + json!({"lease_id":lease,"outcome":"failed","refusal":"raw error /private/path"}), + json!({"lease_id":lease,"outcome":"started","refusal":"stopped"}), + json!({"lease_id":lease,"outcome":"failed","text":"raw error"}), + json!({"lease_id":"forged","outcome":"failed","refusal":"stopped"}), + json!({"lease_id":lease,"outcome":"failed","refusal":null}), + ] { + assert!(acknowledgement(fields.as_object().unwrap()).is_err()); + } + } #[test] fn native_api_version_requires_proven_release_series() { for version in [ From dc5021e1dee22dba053bfd005bb6b2a3609420fd Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 4 Nov 2026 09:00:54 -0800 Subject: [PATCH 2/3] fix(node): require proven terminal input for Claude native turns --- .../src/relay/claude_mobile_plugin.rs | 13 +++--------- .../src/relay/claude_mobile_plugin/tests.rs | 1 + crates/relay-node/src/relay/local.rs | 8 ++++++++ .../mobile_claude_roots/tests.rs | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/relay-node/src/relay/claude_mobile_plugin.rs b/crates/relay-node/src/relay/claude_mobile_plugin.rs index c5c40b138e..8d370d2648 100644 --- a/crates/relay-node/src/relay/claude_mobile_plugin.rs +++ b/crates/relay-node/src/relay/claude_mobile_plugin.rs @@ -196,7 +196,8 @@ pub(crate) fn session_roots( /// `prompt.submit` may reject even while the peer inbox remains available. /// Keep those sessions on their existing inbox route, independently of the /// mobile setting. Check the running process too: entrypoint labels alone -/// do not distinguish every host, and unreadable argv proves no capability. +/// do not distinguish every host. Require positive terminal input, not only +/// the absence of SDK flags. pub(crate) fn native_input_supported( session: &crate::relay::claude::TerminalSession, table: &dyn crate::relay::process::ProcessTable, @@ -209,15 +210,7 @@ pub(crate) fn native_input_supported( { return false; } - let args = table.arguments(session.pid); - !args.is_empty() - && !args.iter().any(|arg| { - arg == "-p" - || arg == "--print" - || arg.starts_with("--print=") - || arg == "--input-format" - || arg.starts_with("--input-format=") - }) + crate::relay::claude::is_interactive_process(table, session.pid) } pub(crate) fn install(config: &Path, socket: &Path) -> Result<(), String> { diff --git a/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs b/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs index 071bde35b7..3ed59901b1 100644 --- a/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs +++ b/crates/relay-node/src/relay/claude_mobile_plugin/tests.rs @@ -330,6 +330,7 @@ fn native_config_root_resolution_rejects_duplicate_session_ids() { pid, crate::relay::process::FakeProcess { arguments: vec!["claude".into()], + has_terminal: true, ..Default::default() }, ); diff --git a/crates/relay-node/src/relay/local.rs b/crates/relay-node/src/relay/local.rs index 73ddb15df2..0d96fba0ad 100644 --- a/crates/relay-node/src/relay/local.rs +++ b/crates/relay-node/src/relay/local.rs @@ -6453,6 +6453,14 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo .get_mut(&300) .unwrap() .arguments = vec!["claude".into()]; + world + .table + .processes + .lock() + .unwrap() + .get_mut(&300) + .unwrap() + .has_terminal = true; let path = world.sessions.config.claude.registry().join("300.json"); let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); diff --git a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs index 7ab472fc19..4ca04ee026 100644 --- a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs +++ b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs @@ -38,6 +38,7 @@ fn processes(pids: &[i32]) -> FakeProcesses { FakeProcess { start: 123, arguments: vec!["claude".into()], + has_terminal: true, ..Default::default() }, ); @@ -141,6 +142,25 @@ fn sdk_host_keeps_its_inbox_but_never_advertises_native_mobile_input() { .get_mut(&42) .unwrap() .arguments = vec!["claude".into()]; + table + .processes + .lock() + .unwrap() + .get_mut(&42) + .unwrap() + .has_terminal = false; + assert!( + read(&config, &table).is_empty(), + "a pipe without SDK flags is not native terminal input" + ); + assert_eq!(claude::registry(&config, &table).sessions.len(), 1); + table + .processes + .lock() + .unwrap() + .get_mut(&42) + .unwrap() + .has_terminal = true; let path = a.join("sessions/42.json"); let mut value: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); From 4deb4eb47779854d8ccfaeb7d7e95707c89968e0 Mon Sep 17 00:00:00 2001 From: Proactive Runtime Bot Date: Wed, 4 Nov 2026 09:13:55 -0800 Subject: [PATCH 3/3] fix(node): revoke refused provisional control and cover delivery routes --- CHANGELOG.md | 4 +- crates/relay-node/README.md | 13 +- .../scripts/test-claude-mobile-plugin.mjs | 14 +- .../src/relay/claude_mobile_plugin.rs | 2 +- crates/relay-node/src/relay/local.rs | 6 +- .../mobile_claude_roots/tests.rs | 8 + .../src/relay/mobile_handoff/journal.rs | 10 + .../src/relay/mobile_handoff/journal/tests.rs | 27 ++ .../src/relay/mobile_handoff/tests.rs | 2 + .../mobile_handoff/tests/rejection_routes.rs | 263 ++++++++++++++++++ 10 files changed, 339 insertions(+), 10 deletions(-) create mode 100644 crates/relay-node/src/relay/mobile_handoff/tests/rejection_routes.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 12668d9e25..f5ff2091e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Claude sessions hosted by apps keep receiving inbox messages when mobile continuation is enabled; SDK and pipe-driven sessions no longer enter the terminal's native mobile route. Native submission refusals explain stopped sessions and host limits without retrying uncertain or refused input. +- `agent-relay-node` keeps messages to registered Claude Code app chats, Anthropic Agent SDK sessions, `claude --print` and structured JSON runs on their ordinary inbox route when mobile continuation is enabled. +- Updated Claude mobile plugins and cores explain recognized stopped-session and host-limit refusals; older counterparts retain generic failure notices. Refused and uncertain input is never replayed. +- A rejected first Claude mobile submission no longer leaves provisional mobile control active. ### Changed diff --git a/crates/relay-node/README.md b/crates/relay-node/README.md index bbdb7c2ffc..6305160eb6 100644 --- a/crates/relay-node/README.md +++ b/crates/relay-node/README.md @@ -56,14 +56,17 @@ contract), `call`, the harness hooks and `connect`. The account methods answer `method_not_found`, and the core uses the workspace the `agent-relay` CLI selects (`src/relay/workspace/cli.rs`), with no sign-in needed. -## Build and test +## Claude mobile continuation -Claude Code terminal sessions can receive the verified owner's mobile messages -as native user turns through Relay's plugin. SDK and pipe-driven sessions, -including app-hosted chats, keep their ordinary peer inbox instead: their host -owns the input and interrupt lifecycle. Turning mobile continuation on does not +On macOS and Linux, Claude Code terminal sessions can receive the verified owner's +mobile messages as native user turns through Relay's plugin. Agent SDK, +`claude --print` and pipe-driven sessions, including app-hosted chats, keep their +ordinary peer inbox instead: their host owns the input and interrupt lifecycle. Turning mobile continuation on does not replace that inbox. A native submission refused after Stop or by a host limit is reported without automatic replay; unknown outcomes are never resubmitted. +Native mobile continuation is unavailable on Windows. + +## Build and test ```sh cargo build --manifest-path crates/relay-node/Cargo.toml --locked diff --git a/crates/relay-node/scripts/test-claude-mobile-plugin.mjs b/crates/relay-node/scripts/test-claude-mobile-plugin.mjs index d4775e4a5b..124769ff89 100644 --- a/crates/relay-node/scripts/test-claude-mobile-plugin.mjs +++ b/crates/relay-node/scripts/test-claude-mobile-plugin.mjs @@ -80,7 +80,7 @@ async function fixture(options = {}) { prompt: { submit: async (value) => { submitted.push(value); - return options.drop + return options.drop !== undefined ? { drop: options.drop === true ? 'Policy refused' : options.drop } : { text: value.text }; }, @@ -891,3 +891,15 @@ test('a new plugin keeps failure acknowledgements compatible with old cores', as [{ lease_id: turn.lease_id, outcome: 'failed' }] ); }); + +test('every defined drop value refuses input even when it is falsy', async () => { + for (const drop of ['', false, 0, null]) { + const f = await fixture({ action: { ...turn, refusal_codes: true }, drop }); + await f.tick(); + assert.equal(f.submitted.length, 1); + assert.deepEqual( + f.calls.filter((call) => call.route === 'ack').map((call) => call.body), + [{ lease_id: turn.lease_id, outcome: 'failed' }] + ); + } +}); diff --git a/crates/relay-node/src/relay/claude_mobile_plugin.rs b/crates/relay-node/src/relay/claude_mobile_plugin.rs index 8d370d2648..828c67733f 100644 --- a/crates/relay-node/src/relay/claude_mobile_plugin.rs +++ b/crates/relay-node/src/relay/claude_mobile_plugin.rs @@ -210,7 +210,7 @@ pub(crate) fn native_input_supported( { return false; } - crate::relay::claude::is_interactive_process(table, session.pid) + crate::relay::claude::is_interactive_terminal(table, session) } pub(crate) fn install(config: &Path, socket: &Path) -> Result<(), String> { diff --git a/crates/relay-node/src/relay/local.rs b/crates/relay-node/src/relay/local.rs index 0d96fba0ad..9fbbf1a412 100644 --- a/crates/relay-node/src/relay/local.rs +++ b/crates/relay-node/src/relay/local.rs @@ -6486,8 +6486,10 @@ printf '{{"jsonrpc":"2.0","id":"%s","result":{{"stopReason":"end_turn","outputTo .get_mut(&300) .unwrap() .arguments = vec!["claude".into(), "--input-format=stream-json".into()]; - assert!(world.sessions.mobile_claude_runtime(&target, 300).is_none(), - "a running SDK host cannot submit mobile input even with an interactive registry record"); + assert!( + world.sessions.mobile_claude_runtime(&target, 300).is_none(), + "a running SDK host cannot submit mobile input even with an interactive registry record", + ); assert!( world.sessions.caller(320).is_ok(), "the ordinary session route stays available" diff --git a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs index 4ca04ee026..ae768a4ed4 100644 --- a/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs +++ b/crates/relay-node/src/relay/local/directory_trait/mobile_claude_roots/tests.rs @@ -117,6 +117,8 @@ fn sdk_host_keeps_its_inbox_but_never_advertises_native_mobile_input() { for arguments in [ vec!["claude", "--input-format", "stream-json"], vec!["claude", "--input-format=stream-json"], + vec!["claude", "--output-format", "stream-json"], + vec!["claude", "--output-format=stream-json"], vec!["claude", "-p", "hello"], vec!["claude", "--print"], vec![], @@ -172,4 +174,10 @@ fn sdk_host_keeps_its_inbox_but_never_advertises_native_mobile_input() { value["entrypoint"] = json!("cli"); std::fs::write(&path, value.to_string()).unwrap(); assert_eq!(read(&config, &table).len(), 1); + for name in ["sdk-task", "SDK-task", "Sdk-task"] { + value["name"] = json!(name); + std::fs::write(&path, value.to_string()).unwrap(); + assert!(read(&config, &table).is_empty(), "{name}"); + assert_eq!(claude::registry(&config, &table).sessions.len(), 1); + } } diff --git a/crates/relay-node/src/relay/mobile_handoff/journal.rs b/crates/relay-node/src/relay/mobile_handoff/journal.rs index 2f22fff548..846c1a8b51 100644 --- a/crates/relay-node/src/relay/mobile_handoff/journal.rs +++ b/crates/relay-node/src/relay/mobile_handoff/journal.rs @@ -473,6 +473,16 @@ impl Journal { active.confirmed = true; } } + } else { + // Provisional control exists only so the first submission can + // ask for approval before its acknowledgement. A refusal ends + // that authority. A previously confirmed generation, however, + // still belongs to the earlier accepted turn. + self.active.retain(|active| { + active.binding != *binding + || (active.runtime.pid, active.runtime.start) != (runtime.pid, runtime.start) + || active.confirmed + }); } Some(delivery_id) } diff --git a/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs b/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs index 6002a1e2f8..e804b8be04 100644 --- a/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs +++ b/crates/relay-node/src/relay/mobile_handoff/journal/tests.rs @@ -433,14 +433,19 @@ fn recognized_provider_refusals_survive_restart_without_replay_or_control() { .enqueue(binding(), "delivery", "private message", 0) .unwrap(); let submit = queue.poll(&binding(), &runtime(false), 1).unwrap(); + let Action::Submit { control_token, .. } = &submit; + let control_token = control_token.clone(); + assert!(queue.control_valid(&binding(), &runtime(false), &control_token, false)); let id = queue .ack(&binding(), &runtime(false), lease(&submit), false, 2) .unwrap(); queue.record_refusal(&id, reason); assert!(!queue.confirmed_active_runtime(&binding(), &runtime(false))); + assert!(!queue.control_valid(&binding(), &runtime(false), &control_token, false)); let bytes = serde_json::to_vec(&queue).unwrap(); assert!(!String::from_utf8_lossy(&bytes).contains("private message")); let mut restored: Journal = serde_json::from_slice(&bytes).unwrap(); + assert!(!restored.control_valid(&binding(), &runtime(false), &control_token, false)); assert_eq!( restored .enqueue(binding(), "delivery", "private message", 3) @@ -451,3 +456,25 @@ fn recognized_provider_refusals_survive_restart_without_replay_or_control() { assert!(restored.poll(&binding(), &runtime(false), 4).is_none()); } } + +#[test] +fn a_later_refusal_does_not_revoke_an_earlier_confirmed_generation() { + let mut queue = Journal::default(); + queue.enqueue(binding(), "accepted", "first", 0).unwrap(); + let first = queue.poll(&binding(), &runtime(false), 1).unwrap(); + let Action::Submit { control_token, .. } = &first; + queue + .ack(&binding(), &runtime(false), lease(&first), true, 2) + .unwrap(); + queue.enqueue(binding(), "rejected", "second", 3).unwrap(); + let second = queue.poll(&binding(), &runtime(false), 4).unwrap(); + queue + .ack(&binding(), &runtime(false), lease(&second), false, 5) + .unwrap(); + assert!(queue.control_valid(&binding(), &runtime(false), control_token, true)); + assert_eq!( + queue.previous(&binding().scope, "rejected", true, 6), + Some(Outcome::Failed) + ); + assert!(queue.poll(&binding(), &runtime(false), 7).is_none()); +} diff --git a/crates/relay-node/src/relay/mobile_handoff/tests.rs b/crates/relay-node/src/relay/mobile_handoff/tests.rs index fc82f415ed..6fcdb3349f 100644 --- a/crates/relay-node/src/relay/mobile_handoff/tests.rs +++ b/crates/relay-node/src/relay/mobile_handoff/tests.rs @@ -12,6 +12,8 @@ use serde_json::{json, Value}; use std::sync::Arc; use std::time::Duration; +mod rejection_routes; + #[path = "../../../tests/support/fake_relaycast.rs"] mod fake_relaycast; diff --git a/crates/relay-node/src/relay/mobile_handoff/tests/rejection_routes.rs b/crates/relay-node/src/relay/mobile_handoff/tests/rejection_routes.rs new file mode 100644 index 0000000000..833589889a --- /dev/null +++ b/crates/relay-node/src/relay/mobile_handoff/tests/rejection_routes.rs @@ -0,0 +1,263 @@ +//! Exercise the authenticated ack and normal delivery routes, rather than +//! relying only on the journal and capability predicates in isolation. +use super::*; +use crate::relay::delivery::session_text::InputSource; +use crate::relay::inject::Injector; +use crate::relay::local::{LocalConfig, LocalSessions}; +use crate::relay::process::{FakeProcess, FakeProcesses}; +use std::sync::Mutex; + +#[derive(Default)] +struct Inbox(Mutex>); +impl Injector for Inbox { + fn inject(&self, _: &Target, _: &str, text: &str) -> InjectOutcome { + self.0.lock().unwrap().push(text.into()); + InjectOutcome::Delivered + } + fn final_answer(&self, _: &Target, _: &str) -> Option { + None + } +} + +struct Fixture { + _home: tempfile::TempDir, + _server: fake_relaycast::FakeRelaycast, + core: Arc, + registration: Registration, + delivery: Delivery, + inbox: Arc, +} + +async fn fixture(sdk: bool) -> Fixture { + let server = fake_relaycast::FakeRelaycast::start(Default::default()).await; + let owner = server.seed_agent("owner", "human", json!({})); + let agent = server.seed_agent("claude-route-test", "agent", json!({})); + let home = tempfile::tempdir().unwrap(); + let config = fixture_config(home.path()); + let registration: Registration = serde_json::from_value(json!({ + "name":agent.name,"agentID":agent.id,"target":{"kind":"claude-terminal", "id":"00000000-0000-4000-8000-000000000011"}, + "title":"Claude","baseURL":server.base_url,"workspaceKey":fake_relaycast::WORKSPACE_KEY, + "registeredAt":"2026-10-24T00:00:00Z" + })).unwrap(); + let sessions: Arc = if sdk { + let local = LocalConfig::from_env(&|_| None, home.path()); + std::fs::create_dir_all(local.claude.registry()).unwrap(); + std::fs::write( + local.claude.registry().join("4100.json"), + json!({ + "sessionId":registration.target.id,"pid":4100,"procStart":100, + "messagingSocketPath":"/synthetic/claude.sock","kind":"interactive", + "entrypoint":"sdk-cli","peerProtocol":1 + }) + .to_string(), + ) + .unwrap(); + let table = Arc::new(FakeProcesses::default()); + table.insert( + 4100, + FakeProcess { + uid: local.uid, + start: 100, + arguments: vec!["claude".into(), "--input-format=stream-json".into()], + ..Default::default() + }, + ); + Arc::new(LocalSessions::new(local, table)) + } else { + Arc::new(Directory( + DiscoveredSession { + target: registration.target.clone(), + title: "Claude".into(), + directory: None, + updated_at_ms: None, + host: None, + live: true, + holder: None, + }, + Arc::new(Mutex::new(Some(Runtime { + pid: 4100, + start: 100, + idle: true, + }))), + Arc::new(std::sync::atomic::AtomicBool::new(true)), + home.path().join(".claude"), + )) + }; + let inbox = Arc::new(Inbox::default()); + let (core, _) = Core::with_plugins( + config, + Plugins { + cloud: Arc::new(SignedIn { + base: server.base_url.clone(), + owner: owner.id.clone(), + }), + sessions, + injector: inbox.clone(), + ..Plugins::default() + }, + ); + core.store + .update(|store| { + store.cloud = Some(CloudWorkspace { + key: Secret::new(fake_relaycast::WORKSPACE_KEY), + base_url: server.base_url.clone(), + ..Default::default() + }); + store.mobile_claude_handoff = Some(true); + store.add(registration.clone(), Secret::new(agent.token)); + }) + .unwrap(); + std::thread::scope(|scope| scope.spawn(|| core.rediscover_workspace()).join().unwrap()); + core.hub.update(|status| status.spawn = None); + core.person_identity(false).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while core.status().spawn.is_none() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + core.hub.update(|status| { + status.spawn = Some(crate::relay::state::SpawnStatus { + claim: Some(crate::relay::state::SpawnClaim { + state: crate::relay::state::SpawnClaimState::Claimed, + note: None, + }), + ..Default::default() + }) + }); + core.install_mobile_claude_plugin().unwrap(); + let delivery = Delivery { + id: "route-delivery".into(), + message_id: "route-message".into(), + sender: Some(owner.name), + sender_id: Some(owner.id), + reason: Some("dm".into()), + text: Some("Continue".into()), + ..Default::default() + }; + Fixture { + _home: home, + _server: server, + core, + registration, + delivery, + inbox, + } +} + +#[tokio::test] +async fn failed_ack_persists_reason_revokes_provisional_control_and_never_falls_back() { + let f = fixture(false).await; + let bridge = Outbound::new(f.core.clone(), Arc::new(NoDirect)); + let uid = crate::relay::peer::current_uid(); + assert!(matches!( + f.core + .deliver_mobile_owner(&f.registration, &f.delivery, "Continue") + .await, + InjectOutcome::Waiting(_) + )); + let poll = request("poll", json!({"version":"2.1.310"})); + let submit = bridge.handle(4100, uid, &poll).await.unwrap(); + assert_eq!(submit["refusal_codes"], true); + let binding = f + .core + .mobile_handoff_binding(&f.registration) + .await + .unwrap(); + let runtime = Runtime { + pid: 4100, + start: 100, + idle: true, + }; + let token = submit["control_token"].as_str().unwrap(); + assert!(f + .core + .mobile_handoff_control_valid(&binding, &runtime, token, false)); + bridge + .handle( + 4100, + uid, + &request( + "ack", + json!({ + "lease_id":submit["lease_id"],"outcome":"failed","refusal":"stopped" + }), + ), + ) + .await + .unwrap(); + assert!(!f + .core + .mobile_handoff_control_valid(&binding, &runtime, token, false)); + let persisted = journal::edit(&f.core.config.app_data_dir, |queue| { + Ok(( + queue.previous( + &binding.scope, + &f.delivery.id, + true, + crate::relay::state::now_ms(), + ), + queue.notice(&binding.scope), + )) + }) + .unwrap(); + assert_eq!( + persisted, + ( + Some(journal::Outcome::Refused(Refusal::Stopped)), + Refusal::Stopped.notice() + ) + ); + assert_eq!( + f.core + .deliver_session_text( + &f.registration, + &f.delivery, + "Continue".into(), + InputSource::Owner + ) + .await, + InjectOutcome::Refused(Refusal::Stopped.notice().into()) + ); + assert!(f.inbox.0.lock().unwrap().is_empty()); + assert_eq!( + bridge.handle(4100, uid, &poll).await.unwrap(), + json!({"action":null}) + ); +} + +#[tokio::test] +async fn sdk_messages_reach_the_inbox_with_mobile_continuation_on_or_off() { + let f = fixture(true).await; + for (round, enabled) in [true, false, true].into_iter().enumerate() { + f.core + .store + .update(|store| store.mobile_claude_handoff = Some(enabled)) + .unwrap(); + f.core.refresh_mobile_claude_plugin(); + assert!(!f.core.mobile_handoff_enabled(&f.registration)); + for (index, source) in [InputSource::Peer, InputSource::Owner, InputSource::Teammate] + .into_iter() + .enumerate() + { + let mut delivery = f.delivery.clone(); + delivery.id = format!("sdk-{round}-{index}"); + assert_eq!( + f.core + .deliver_session_text(&f.registration, &delivery, "SDK inbox".into(), source) + .await, + InjectOutcome::Delivered + ); + } + } + assert_eq!(f.inbox.0.lock().unwrap().len(), 9); + assert!( + !f.core + .config + .app_data_dir + .join("mobile-claude-handoff/queue.json") + .exists() + || !f.core.mobile_handoff_pending(&f.registration).unwrap() + ); +}