返回 CodeWhale
remote_control.rs
根目录 / crates / tui / src / remote_control.rs
1 //! Account-owned remote control for the active TUI session.
2 //!
3 //! This is deliberately a typed relay, not a remote shell. The control plane
4 //! may send prompts, approval decisions, and run-control requests for the exact
5 //! enrolled target. Provider credentials, paths, environment variables, and
6 //! arbitrary command strings never cross this boundary.
7
8 use std::{
9 collections::{BTreeMap, HashMap, HashSet},
10 fs::File,
11 path::{Path, PathBuf},
12 time::{Duration, Instant, SystemTime, UNIX_EPOCH},
13 };
14
15 use reqwest::Url;
16 use reqwest::{Client, Method, StatusCode};
17 use serde::{Deserialize, Serialize};
18 use serde_json::{Value, json};
19 use sha2::{Digest, Sha256};
20 use tokio::sync::mpsc;
21
22 use codewhale_models::{ContentBlock, Message};
23
24 use crate::{
25 core::events::{Event as EngineEvent, TurnOutcomeStatus},
26 runtime_chat_relay::{
27 RuntimeChatControlScope, RuntimeChatProjection, RuntimeChatPrompt, RuntimeChatRelayHost,
28 },
29 };
30
31 const PRODUCTION_CONTROL_PLANE: &str = "https://api.codewhale.net/";
32 const ENROLLMENT_SECRET_SLOT: &str = "cwc-remote-control-enrollment-v1";
33 /// Machine-stable device identity. It outlives individual enrollments so the
34 /// control plane can fold every folder enrolled from this terminal into one
35 /// computer instead of one row per `/rc`.
36 const DEVICE_IDENTITY_SECRET_SLOT: &str = "cwc-remote-control-device-v1";
37 /// The only web origin whose session links the terminal will surface or open.
38 const APP_ORIGIN_HOST: &str = "app.codewhale.net";
39 const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
40 const SYNC_INTERVAL: Duration = Duration::from_millis(1_200);
41 const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
42 const MAX_RUNS: usize = 64;
43 const MAX_COMMANDS: usize = 128;
44 const JS_MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
45 const MAX_RUNTIME_ENVELOPE_BYTES: usize = 128 * 1024;
46 const SNAPSHOT_ENVELOPE_BYTE_BUDGET: usize = 120 * 1024;
47 const MAX_SNAPSHOT_MESSAGES: usize = 64;
48 const MAX_SNAPSHOT_MESSAGE_CHARS: usize = 128 * 1024;
49 const MIN_TRUNCATED_MESSAGE_CHARS: usize = 32;
50 const MAX_REMOTE_ERROR_MESSAGE_BYTES: usize = 4 * 1024;
51 const RUNTIME_UPLOAD_RETRY_INTERVAL: Duration = Duration::from_millis(250);
52 const RUNTIME_UPLOAD_MAX_BACKOFF: Duration = Duration::from_secs(5);
53 const RUNTIME_CHAT_RELAY_PROTOCOL: &str = "codewhale.runtime-chat-relay.v1";
54 const RUNTIME_CHAT_CATALOG_TIMESTAMP: &str = "1970-01-01T00:00:00Z";
55 const CAPABILITIES: &[&str] = &["evidence-ledger", "fim", "git", "shell"];
56 /// How long an aborted or failed relay keeps local input locked. Matches the
57 /// server-side runner lease expiry with margin; local input never returns
58 /// while the server could still consider a remote owner live.
59 const OWNERSHIP_LOCK_AFTER_FAILURE: Duration = Duration::from_secs(95);
60 /// Ceiling for draining unacknowledged runtime events during `/rc stop`.
61 /// Deliberately below `OWNERSHIP_LOCK_AFTER_FAILURE` so a failed drain still
62 /// resolves into the ownership-locked path before the lease question is moot.
63 const STOP_DRAIN_DEADLINE: Duration = Duration::from_secs(45);
64 const JOURNAL_SCHEMA_VERSION: u64 = 2;
65 const CLASSIC_SESSION_STATE_SCHEMA_VERSION: u64 = 3;
66 /// Hard bounds for the crash-recoverable unacknowledged-envelope journal.
67 const MAX_JOURNAL_EVENTS: usize = 256;
68 const MAX_JOURNAL_ENCODED_BYTES: usize = 4 * 1024 * 1024;
69 /// Capacity held back exclusively for integrity-critical envelopes (terminal
70 /// turn state, approvals, failures, resynchronization snapshots). Ordinary
71 /// deltas may never consume this headroom.
72 const JOURNAL_RESERVED_INTEGRITY_EVENTS: usize = 64;
73 const JOURNAL_RESERVED_INTEGRITY_BYTES: usize = 1024 * 1024;
74 /// A deferred (not yet handed to transport) delta envelope may grow to this
75 /// encoded size through coalescing before it is forced onto the wire.
76 const DELTA_COALESCE_BYTE_CAP: usize = 32 * 1024;
77 const JOURNAL_SETUP_ERROR: &str = "Remote control could not prepare its private delivery journal.";
78 const JOURNAL_UNTRUSTED_ERROR: &str = "The saved remote-control delivery journal could not be trusted; it was set aside. The account run may show an incomplete turn.";
79 const JOURNAL_LEGACY_SCOPE_ERROR: &str = "A saved legacy remote-control delivery journal is not bound to this workspace and cannot be replayed safely; it was preserved for explicit recovery.";
80 const CLASSIC_LEASE_SCOPE_ERROR: &str = "This saved session still owns an unfinished account turn in its original workspace; reconnect that workspace or create a new local session.";
81
82 #[cfg(test)]
83 static TEST_JOURNAL_PERSIST_FAILURES: std::sync::Mutex<Vec<(PathBuf, usize)>> =
84 std::sync::Mutex::new(Vec::new());
85
86 #[cfg(test)]
87 fn inject_journal_persist_failures(path: &Path, count: usize) {
88 assert!(count > 0);
89 TEST_JOURNAL_PERSIST_FAILURES
90 .lock()
91 .unwrap_or_else(std::sync::PoisonError::into_inner)
92 .push((path.to_path_buf(), count));
93 }
94
95 #[cfg(test)]
96 fn take_journal_persist_failure(path: &Path) -> bool {
97 let mut failures = TEST_JOURNAL_PERSIST_FAILURES
98 .lock()
99 .unwrap_or_else(std::sync::PoisonError::into_inner);
100 let Some(index) = failures.iter().position(|(target, _)| target == path) else {
101 return false;
102 };
103 if failures[index].1 > 1 {
104 failures[index].1 -= 1;
105 } else {
106 failures.remove(index);
107 }
108 true
109 }
110
111 /// Envelopes whose loss would strand account-side truth: terminal turn state,
112 /// approval requests, failure records, and resynchronization snapshots. They
113 /// draw on reserved journal capacity, are never dropped silently, and gate
114 /// `/rc stop` until the server cursor covers them.
115 fn integrity_critical_event(event: &str) -> bool {
116 matches!(
117 event,
118 "turn.completed"
119 | "approval.required"
120 | "approval.resolved"
121 | "item.failed"
122 | "session.snapshot"
123 | "runtime.catalog"
124 )
125 }
126
127 fn runtime_envelope_event(envelope: &Value) -> Option<&str> {
128 envelope.get("event").and_then(Value::as_str)
129 }
130
131 #[derive(Debug, Clone, PartialEq, Eq)]
132 pub enum RemoteControlAction {
133 Start,
134 Stop,
135 }
136
137 #[derive(Clone)]
138 pub struct RemoteStart {
139 pub workspace_label: String,
140 pub target_ref: String,
141 pub session_id: String,
142 pub runtime_version: String,
143 pub runtime_commit: String,
144 /// Directory that holds the crash-recoverable delivery journal. `None`
145 /// runs memory-only and is reserved for tests; production callers must
146 /// always provide a private directory under the Codewhale home.
147 pub journal_dir: Option<PathBuf>,
148 /// Observed `owner/name` from `git remote get-url origin`, when the folder
149 /// is a Git checkout. This is a display receipt, never a path or GitHub App
150 /// grant.
151 pub git_remote: Option<String>,
152 }
153
154 #[derive(Debug, Clone)]
155 pub enum RemoteEvent {
156 Notice(String),
157 Connected {
158 account_ref: String,
159 runner_id: String,
160 target_ref: String,
161 attachment: RemoteAttachment,
162 links: RemoteLinks,
163 },
164 Attachment {
165 account_ref: String,
166 target_ref: String,
167 attachment: RemoteAttachment,
168 links: RemoteLinks,
169 },
170 RuntimeCursor {
171 run_id: String,
172 cursor: u64,
173 },
174 Command {
175 run_id: String,
176 seq: u64,
177 command: RemoteCommand,
178 },
179 RuntimeChatProjection(RuntimeChatProjection),
180 /// Internal handoff receipt: the connected worker has dropped its clone
181 /// of the old immutable Runtime Chat host, so the controller can safely
182 /// reopen the same durable scope with a freshly supplied provider config.
183 RuntimeChatHostReleased,
184 Failed(String),
185 /// The relay died before any server-confirmed lease existed — during
186 /// enrollment, device authorization, or the first connect. No lease can
187 /// still be live, so nothing is locked and `/rc` can retry immediately.
188 FailedPreLease(String),
189 Stopped,
190 OwnershipRestored {
191 approvals: Vec<PendingRemoteApproval>,
192 },
193 }
194
195 #[derive(Debug, Clone, PartialEq, Eq)]
196 pub struct RemoteAttachment {
197 pub run_id: String,
198 pub workspace_id: String,
199 pub runtime_cursor: u64,
200 pub snapshot_present: bool,
201 pub runtime_chat_relay_protocol: String,
202 pub runtime_chat_relay_challenge: String,
203 }
204
205 /// Web links the control plane advertises for the attached session. Both are
206 /// optional: an older control plane omits them and the terminal then simply
207 /// shows no link. Links are validated against the Codewhale app origin before
208 /// they are ever displayed or opened; the terminal never invents one.
209 #[derive(Debug, Clone, Default, PartialEq, Eq)]
210 pub struct RemoteLinks {
211 /// `https://app.codewhale.net/session?run=<runId>` for the live run.
212 pub run_url: Option<String>,
213 /// `https://app.codewhale.net/settings?section=workspaces` for this computer.
214 pub computer_url: Option<String>,
215 }
216
217 #[derive(Debug, Clone, PartialEq, Eq)]
218 struct RunnerConnection {
219 runner_id: String,
220 attachment: RemoteAttachment,
221 links: RemoteLinks,
222 }
223
224 #[derive(Debug, Clone, PartialEq, Eq)]
225 pub enum RemoteCommand {
226 Prompt {
227 turn_id: String,
228 prompt: String,
229 },
230 RuntimeChatPrompt(Box<RuntimeChatPrompt>),
231 Approval {
232 gate: String,
233 approved: bool,
234 },
235 Control {
236 action: RemoteControlRequest,
237 turn_id: Option<String>,
238 runtime_chat: Option<RuntimeChatControlScope>,
239 },
240 }
241
242 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
243 pub enum RemoteControlRequest {
244 Interrupt,
245 Cancel,
246 }
247
248 enum RelayPhase {
249 /// Everything before the first server-confirmed lease: control-plane base
250 /// resolution, enrollment, device authorization, and the first connect.
251 Enrolling,
252 /// The server confirmed a lease (Connected was emitted). Any failure now
253 /// is a lost-after-lease disconnect and stays fail-closed.
254 Leased,
255 }
256
257 impl RelayPhase {
258 fn lease_confirmed(&self) -> bool {
259 matches!(self, Self::Leased)
260 }
261 }
262
263 #[derive(Clone)]
264 enum WorkerCommand {
265 Upload {
266 run_id: String,
267 acknowledgements: Vec<CommandAcknowledgement>,
268 envelopes: Vec<Value>,
269 },
270 ReleaseRuntimeChatHost,
271 InstallRuntimeChatHost(RuntimeChatRelayHost),
272 Stop,
273 }
274
275 struct PendingRuntimeChatConfiguration {
276 config: crate::config::Config,
277 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
278 private_root: PathBuf,
279 target_ref: String,
280 session_id: String,
281 }
282
283 #[derive(Debug, Default)]
284 struct RuntimeTransportOutbox {
285 events: BTreeMap<(String, u64), Value>,
286 }
287
288 /// One unacknowledged runtime envelope owned by the controller.
289 ///
290 /// `handed_off` records whether the envelope may already have reached the
291 /// server through the transport worker. Once true the envelope is immutable:
292 /// ambiguous retries must resend byte-identical JSON.
293 #[derive(Debug, Clone, PartialEq)]
294 struct PendingRuntimeEnvelope {
295 envelope: Value,
296 encoded_len: usize,
297 integrity: bool,
298 handed_off: bool,
299 }
300
301 /// Crash-recoverable journal of unacknowledged runtime envelopes.
302 ///
303 /// The file name is hash-derived so nothing about the workspace or session
304 /// leaks through the path; the directory is private and the file owner-only.
305 /// Neither the path nor the contents are ever reported to the control plane
306 /// or written to logs. Acknowledged prefixes are compacted on every persist,
307 /// and a journal that cannot be verified fails closed at load time.
308 struct RuntimeEventJournal {
309 path: PathBuf,
310 scope_tag: String,
311 legacy_path: PathBuf,
312 legacy_unscoped_path: PathBuf,
313 active_index_path: PathBuf,
314 classic_lease: parking_lot::Mutex<Option<ClassicRunLease>>,
315 _classic_session_lock: ClassicSessionOwnerLock,
316 }
317
318 #[derive(Debug)]
319 struct ClassicSessionOwnerLock {
320 _file: File,
321 }
322
323 impl ClassicSessionOwnerLock {
324 fn acquire(path: &Path) -> Result<Self, String> {
325 let mut options = std::fs::OpenOptions::new();
326 options.create(true).read(true).write(true);
327 #[cfg(unix)]
328 {
329 use std::os::unix::fs::OpenOptionsExt as _;
330 options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
331 }
332 let file = options
333 .open(path)
334 .map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
335 #[cfg(unix)]
336 {
337 use std::os::fd::AsRawFd as _;
338 // SAFETY: `file` is open and live.
339 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 {
340 return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string());
341 }
342 }
343 #[cfg(windows)]
344 {
345 use std::os::windows::io::AsRawHandle as _;
346 use windows_sys::Win32::Storage::FileSystem::LockFile;
347 // SAFETY: `file` is open and live.
348 if unsafe { LockFile(file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX) } == 0 {
349 return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string());
350 }
351 }
352 Ok(Self { _file: file })
353 }
354 }
355
356 fn runtime_journal_scope_tag(target_ref: &str, session_id: &str) -> String {
357 let mut hasher = Sha256::new();
358 hasher.update(b"cwc-remote-control-journal.v2\0");
359 hasher.update(target_ref.as_bytes());
360 hasher.update(b"\0");
361 hasher.update(session_id.as_bytes());
362 bytes_to_hex(&hasher.finalize())[..32].to_string()
363 }
364
365 fn runtime_journal_session_tag(session_id: &str) -> String {
366 let mut hasher = Sha256::new();
367 hasher.update(b"cwc-remote-control-journal\0");
368 hasher.update(session_id.as_bytes());
369 bytes_to_hex(&hasher.finalize())[..32].to_string()
370 }
371
372 fn classic_recovery_turn_id(run_id: &str, lease_id: &str) -> String {
373 let mut hasher = Sha256::new();
374 hasher.update(b"codewhale.classic-recovery-turn.v1\0");
375 hasher.update(run_id.as_bytes());
376 hasher.update(b"\0");
377 hasher.update(lease_id.as_bytes());
378 format!("turn_recovered_{}", &bytes_to_hex(&hasher.finalize())[..24])
379 }
380
381 impl RuntimeEventJournal {
382 fn open(dir: &Path, target_ref: &str, session_id: &str) -> Result<Self, String> {
383 let scope_tag = runtime_journal_scope_tag(target_ref, session_id);
384 let legacy_session_tag = runtime_journal_session_tag(session_id);
385 std::fs::create_dir_all(dir).map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
386 #[cfg(unix)]
387 {
388 use std::os::unix::fs::PermissionsExt;
389 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
390 .map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
391 }
392 let legacy_path = dir.join(format!("journal_{legacy_session_tag}.json"));
393 let active_index_path = dir.join(format!("active_classic_{legacy_session_tag}.json"));
394 let classic_session_lock =
395 ClassicSessionOwnerLock::acquire(&active_index_path.with_extension("lock"))?;
396 if !active_index_path.exists()
397 && (legacy_path.exists() || legacy_path.with_extension("unscoped").exists())
398 {
399 return Err(JOURNAL_LEGACY_SCOPE_ERROR.to_string());
400 }
401 let journal = Self {
402 path: dir.join(format!("journal_{scope_tag}.json")),
403 scope_tag,
404 legacy_unscoped_path: legacy_path.with_extension("unscoped"),
405 legacy_path,
406 active_index_path,
407 classic_lease: parking_lot::Mutex::new(None),
408 _classic_session_lock: classic_session_lock,
409 };
410 if journal.active_index_path.exists() {
411 let lease = journal.read_active_index()?;
412 *journal.classic_lease.lock() = lease;
413 } else {
414 journal.write_active_index(None)?;
415 }
416 Ok(journal)
417 }
418
419 /// Loads every journaled envelope, or fails closed when the journal
420 /// cannot be trusted (corrupt, oversized, or written for another
421 /// session). A missing file is an ordinary empty journal.
422 fn load(&self) -> Result<HashMap<String, BTreeMap<u64, Value>>, String> {
423 let bytes = match std::fs::read(&self.path) {
424 Ok(bytes) => bytes,
425 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
426 if self.legacy_path.exists() || self.legacy_unscoped_path.exists() {
427 // Schema 1 authenticated only a session id. The same
428 // saved session can be opened from another target, so its
429 // envelopes cannot be relabelled into this v2
430 // target+session scope without server-side ownership
431 // proof. Preserve it and fail closed.
432 return Err(JOURNAL_LEGACY_SCOPE_ERROR.to_string());
433 }
434 return Ok(HashMap::new());
435 }
436 Err(_) => return Err(JOURNAL_UNTRUSTED_ERROR.to_string()),
437 };
438 if bytes.len() > MAX_JOURNAL_ENCODED_BYTES.saturating_mul(2) {
439 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
440 }
441 let value: Value =
442 serde_json::from_slice(&bytes).map_err(|_| JOURNAL_UNTRUSTED_ERROR.to_string())?;
443 let identity_valid = value.get("schemaVersion").and_then(Value::as_u64)
444 == Some(JOURNAL_SCHEMA_VERSION)
445 && value.get("scope").and_then(Value::as_str) == Some(self.scope_tag.as_str());
446 if !identity_valid {
447 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
448 }
449 let runs = value
450 .get("runs")
451 .and_then(Value::as_object)
452 .ok_or_else(|| JOURNAL_UNTRUSTED_ERROR.to_string())?;
453 let mut restored: HashMap<String, BTreeMap<u64, Value>> = HashMap::new();
454 let mut total_events = 0usize;
455 let mut total_bytes = 0usize;
456 for (run_id, envelopes) in runs {
457 if !valid_opaque_ref(run_id) {
458 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
459 }
460 let envelopes = envelopes
461 .as_array()
462 .ok_or_else(|| JOURNAL_UNTRUSTED_ERROR.to_string())?;
463 let mut events = BTreeMap::new();
464 for envelope in envelopes {
465 let seq = runtime_envelope_seq(envelope)
466 .ok_or_else(|| JOURNAL_UNTRUSTED_ERROR.to_string())?;
467 let encoded_len = serde_json::to_vec(envelope)
468 .map(|body| body.len())
469 .unwrap_or(usize::MAX);
470 if encoded_len > MAX_RUNTIME_ENVELOPE_BYTES {
471 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
472 }
473 total_events += 1;
474 total_bytes = total_bytes.saturating_add(encoded_len);
475 if total_events > MAX_JOURNAL_EVENTS || total_bytes > MAX_JOURNAL_ENCODED_BYTES {
476 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
477 }
478 if events.insert(seq, envelope.clone()).is_some() {
479 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
480 }
481 }
482 if !events.is_empty() {
483 restored.insert(run_id.clone(), events);
484 }
485 }
486 Ok(restored)
487 }
488
489 /// Atomically replaces the journal with the current unacknowledged set.
490 /// An empty set removes the file entirely (prompt compaction).
491 fn persist(
492 &self,
493 pending: &HashMap<String, BTreeMap<u64, PendingRuntimeEnvelope>>,
494 ) -> Result<(), String> {
495 #[cfg(test)]
496 if take_journal_persist_failure(&self.path) {
497 return Err(JOURNAL_SETUP_ERROR.to_string());
498 }
499 let classic_lease = self.classic_lease.lock().clone();
500 if pending.values().all(BTreeMap::is_empty) && classic_lease.is_none() {
501 self.remove();
502 return Ok(());
503 }
504 let mut runs = serde_json::Map::new();
505 for (run_id, events) in pending {
506 if events.is_empty() {
507 continue;
508 }
509 runs.insert(
510 run_id.clone(),
511 Value::Array(
512 events
513 .values()
514 .map(|entry| entry.envelope.clone())
515 .collect(),
516 ),
517 );
518 }
519 let body = serde_json::to_vec(&json!({
520 "schemaVersion": JOURNAL_SCHEMA_VERSION,
521 "scope": self.scope_tag,
522 "runs": runs,
523 }))
524 .map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
525 crate::utils::write_atomic(&self.path, &body).map_err(|_| JOURNAL_SETUP_ERROR.to_string())
526 }
527
528 fn remove(&self) {
529 if self.classic_lease.lock().is_some() {
530 return;
531 }
532 let _ = std::fs::remove_file(&self.path);
533 let _ = std::fs::remove_file(self.path.with_extension("tmp"));
534 }
535
536 /// Moves an untrusted journal aside so the failure is explicit and a
537 /// deliberate later `/rc` start can proceed from a clean slate.
538 fn quarantine_current(&self) {
539 let _ = std::fs::rename(&self.path, self.path.with_extension("corrupt"));
540 }
541
542 fn quarantine_legacy(&self) {
543 if self.legacy_path.exists() {
544 let _ = std::fs::rename(&self.legacy_path, &self.legacy_unscoped_path);
545 }
546 }
547
548 fn classic_lease(&self) -> Option<ClassicRunLease> {
549 self.classic_lease.lock().clone()
550 }
551
552 fn read_active_index(&self) -> Result<Option<ClassicRunLease>, String> {
553 let bytes = std::fs::read(&self.active_index_path)
554 .map_err(|_| JOURNAL_UNTRUSTED_ERROR.to_string())?;
555 if bytes.len() > 2048 {
556 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
557 }
558 let value: Value =
559 serde_json::from_slice(&bytes).map_err(|_| JOURNAL_UNTRUSTED_ERROR.to_string())?;
560 if value.get("schemaVersion").and_then(Value::as_u64)
561 != Some(CLASSIC_SESSION_STATE_SCHEMA_VERSION)
562 || value.get("scope").and_then(Value::as_str) != Some(self.scope_tag.as_str())
563 {
564 return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string());
565 }
566 match value.get("lease") {
567 None | Some(Value::Null) => Ok(None),
568 Some(value) => {
569 let lease: ClassicRunLease = serde_json::from_value(value.clone())
570 .map_err(|_| JOURNAL_UNTRUSTED_ERROR.to_string())?;
571 if !lease.valid() {
572 return Err(JOURNAL_UNTRUSTED_ERROR.to_string());
573 }
574 Ok(Some(lease))
575 }
576 }
577 }
578
579 fn write_active_index(&self, lease: Option<&ClassicRunLease>) -> Result<(), String> {
580 #[cfg(test)]
581 if take_journal_persist_failure(&self.active_index_path) {
582 return Err(JOURNAL_SETUP_ERROR.to_string());
583 }
584 let index = serde_json::to_vec(&json!({
585 "schemaVersion": CLASSIC_SESSION_STATE_SCHEMA_VERSION,
586 "scope": self.scope_tag,
587 "lease": lease,
588 }))
589 .map_err(|_| JOURNAL_SETUP_ERROR.to_string())?;
590 crate::utils::write_atomic(&self.active_index_path, &index)
591 .map_err(|_| JOURNAL_SETUP_ERROR.to_string())
592 }
593
594 fn set_classic_lease(
595 &self,
596 lease: Option<ClassicRunLease>,
597 pending: &HashMap<String, BTreeMap<u64, PendingRuntimeEnvelope>>,
598 ) -> Result<(), String> {
599 let previous = self.classic_lease.lock().clone();
600 let indexed = self.read_active_index()?;
601 if indexed != previous {
602 return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string());
603 }
604 if let Some(lease) = &lease {
605 self.write_active_index(Some(lease))?;
606 }
607 *self.classic_lease.lock() = lease.clone();
608 if let Err(error) = self.persist(pending) {
609 *self.classic_lease.lock() = previous.clone();
610 let _ = self.write_active_index(previous.as_ref());
611 return Err(error);
612 }
613 if lease.is_none()
614 && let Err(error) = self.write_active_index(None)
615 {
616 *self.classic_lease.lock() = previous.clone();
617 let _ = self.write_active_index(previous.as_ref());
618 return Err(error);
619 }
620 Ok(())
621 }
622
623 /// Advances the canonical sequence floor for the active classic Work run.
624 ///
625 /// The target journal can compact an acknowledged prefix, so the
626 /// session-wide lease must retain the highest server/local sequence while
627 /// a provider turn is still active. Recovery uses this floor before it
628 /// synthesizes a terminal event and can therefore never reuse a sequence
629 /// the server already acknowledged.
630 fn advance_classic_seq_floor(&self, run_id: &str, seq: u64) -> Result<(), String> {
631 let Some(mut lease) = self.classic_lease.lock().clone() else {
632 return Ok(());
633 };
634 if lease.run_id != run_id || seq <= lease.seq_floor {
635 return Ok(());
636 }
637 let indexed = self.read_active_index()?;
638 if indexed.as_ref() != self.classic_lease.lock().as_ref() {
639 return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string());
640 }
641 lease.seq_floor = seq;
642 self.write_active_index(Some(&lease))?;
643 *self.classic_lease.lock() = Some(lease);
644 Ok(())
645 }
646 }
647
648 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
649 enum RuntimePostOutcome {
650 Accepted(u64),
651 Retryable,
652 AccessTokenExpired,
653 }
654
655 #[derive(Debug, Clone, PartialEq, Eq)]
656 enum RuntimeFlushOutcome {
657 Idle,
658 Accepted { run_id: String, cursor: u64 },
659 Retryable,
660 AccessTokenExpired,
661 }
662
663 #[derive(Debug, Clone, Serialize)]
664 #[serde(rename_all = "camelCase")]
665 struct CommandAcknowledgement {
666 command_seq: u64,
667 command_type: String,
668 status: String,
669 #[serde(skip_serializing_if = "Option::is_none")]
670 turn_id: Option<String>,
671 #[serde(skip_serializing_if = "Option::is_none")]
672 error: Option<String>,
673 }
674
675 #[derive(Clone, Deserialize, Serialize)]
676 #[serde(rename_all = "camelCase", deny_unknown_fields)]
677 struct PersistedEnrollment {
678 schema_version: u64,
679 control_plane_base: String,
680 runner_enrollment_id: String,
681 account_ref: String,
682 device_id: String,
683 target_ref: String,
684 target_grant_ref: String,
685 runtime_version: String,
686 runtime_commit: String,
687 bootstrap_secret: String,
688 }
689
690 /// The machine-stable device identity persisted independently of any
691 /// enrollment. Enrollments are deleted and re-created when the target or
692 /// runtime changes; this record is only ever created once per keychain.
693 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
694 #[serde(rename_all = "camelCase", deny_unknown_fields)]
695 struct PersistedDeviceIdentity {
696 schema_version: u64,
697 device_id: String,
698 }
699
700 impl PersistedDeviceIdentity {
701 fn valid(&self) -> bool {
702 self.schema_version == 1 && valid_opaque_ref(&self.device_id)
703 }
704 }
705
706 /// Pick the device id this terminal presents to the control plane. A valid
707 /// saved identity always wins; otherwise the device id of an existing
708 /// enrollment is adopted (so upgrading terminals keep their computer row);
709 /// otherwise a fresh id is minted. Returns the id and whether it must be
710 /// saved.
711 fn resolve_device_identity(
712 saved: Option<PersistedDeviceIdentity>,
713 enrollment_device_id: Option<&str>,
714 ) -> (String, bool) {
715 if let Some(saved) = saved.filter(PersistedDeviceIdentity::valid) {
716 return (saved.device_id, false);
717 }
718 if let Some(existing) = enrollment_device_id.filter(|value| valid_opaque_ref(value)) {
719 return (existing.to_string(), true);
720 }
721 (format!("device_{}", uuid::Uuid::new_v4().simple()), true)
722 }
723
724 #[derive(Clone)]
725 struct LiveEnrollment {
726 persisted: PersistedEnrollment,
727 access_token: String,
728 }
729
730 #[derive(Debug, Clone)]
731 struct ActiveRelayRun {
732 run_id: String,
733 turn_id: String,
734 }
735
736 #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
737 #[serde(rename_all = "camelCase", deny_unknown_fields)]
738 struct ClassicRunLease {
739 run_id: String,
740 turn_id: Option<String>,
741 lease_id: String,
742 seq_floor: u64,
743 }
744
745 impl ClassicRunLease {
746 fn valid(&self) -> bool {
747 valid_opaque_ref(&self.run_id)
748 && self.turn_id.as_deref().is_none_or(valid_opaque_ref)
749 && valid_opaque_ref(&self.lease_id)
750 && self.seq_floor <= JS_MAX_SAFE_INTEGER
751 }
752 }
753
754 #[derive(Debug, Clone)]
755 pub struct PendingRemoteApproval {
756 pub tool_id: String,
757 }
758
759 #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
760 enum Status {
761 #[default]
762 Off,
763 Connecting,
764 Connected,
765 Stopping,
766 Failed,
767 }
768
769 /// UI-thread owner for remote-control state and typed transport channels.
770 pub struct RemoteControlController {
771 status: Status,
772 status_detail: String,
773 account_ref: Option<String>,
774 target_ref: Option<String>,
775 links: RemoteLinks,
776 /// Latest server-confirmed run attachment. Kept separately from
777 /// `active_run`: an attachment can exist while the session is idle, and a
778 /// mid-turn `/rc` can bind the already-running local turn to it without
779 /// inventing a second prompt.
780 attached_run_id: Option<String>,
781 attached_workspace_id: Option<String>,
782 active_run: Option<ActiveRelayRun>,
783 /// A local dispatch that was already in flight when the web attachment
784 /// became ready, but whose typed `TurnStarted` event has not landed yet.
785 /// The first such event promotes this exact run into `active_run`.
786 pending_local_turn_run: Option<String>,
787 event_seq: HashMap<String, u64>,
788 uploaded_snapshots: HashSet<String>,
789 pending_runtime_events: HashMap<String, BTreeMap<u64, PendingRuntimeEnvelope>>,
790 pending_approvals: HashMap<String, PendingRemoteApproval>,
791 command_fingerprints: HashMap<(String, u64), String>,
792 worker_tx: Option<mpsc::UnboundedSender<WorkerCommand>>,
793 event_rx: Option<mpsc::UnboundedReceiver<RemoteEvent>>,
794 worker: Option<tokio::task::JoinHandle<()>>,
795 applying_remote_command: bool,
796 ownership_blocked_until: Option<Instant>,
797 journal: Option<RuntimeEventJournal>,
798 /// At most one deferred (unsent, still coalescible) delta seq per run.
799 deferred_delta: HashMap<String, u64>,
800 /// Runs whose deltas were shed under pressure; truth is restored with a
801 /// bounded snapshot at the next terminal boundary.
802 resync_required: HashSet<String>,
803 /// Runs that crossed their terminal boundary with `resync_required` set;
804 /// the UI drains these via `take_pending_resync`.
805 resync_ready: Vec<String>,
806 pending_event_count: usize,
807 pending_encoded_bytes: usize,
808 /// Separate native Runtime host for account Chat. It is configured by the
809 /// TUI composition root and never points at the current interactive turn.
810 runtime_chat: Option<RuntimeChatRelayHost>,
811 /// A same-scope reconnect may need the old immutable host long enough to
812 /// drain terminal/catalog rows behind the server cursor. New prompts stay
813 /// fail-closed while this fresh configuration waits for an acknowledged
814 /// boundary and a worker/controller host handoff.
815 pending_runtime_chat_configuration: Option<PendingRuntimeChatConfiguration>,
816 runtime_chat_refresh_release_requested: bool,
817 runtime_chat_attachment: Option<RemoteAttachment>,
818 uploaded_runtime_catalog_receipt: HashMap<String, String>,
819 }
820
821 impl Default for RemoteControlController {
822 fn default() -> Self {
823 Self {
824 status: Status::Off,
825 status_detail: "off".to_string(),
826 account_ref: None,
827 target_ref: None,
828 links: RemoteLinks::default(),
829 attached_run_id: None,
830 attached_workspace_id: None,
831 active_run: None,
832 pending_local_turn_run: None,
833 event_seq: HashMap::new(),
834 uploaded_snapshots: HashSet::new(),
835 pending_runtime_events: HashMap::new(),
836 pending_approvals: HashMap::new(),
837 command_fingerprints: HashMap::new(),
838 worker_tx: None,
839 event_rx: None,
840 worker: None,
841 applying_remote_command: false,
842 ownership_blocked_until: None,
843 journal: None,
844 deferred_delta: HashMap::new(),
845 resync_required: HashSet::new(),
846 resync_ready: Vec::new(),
847 pending_event_count: 0,
848 pending_encoded_bytes: 0,
849 runtime_chat: None,
850 pending_runtime_chat_configuration: None,
851 runtime_chat_refresh_release_requested: false,
852 runtime_chat_attachment: None,
853 uploaded_runtime_catalog_receipt: HashMap::new(),
854 }
855 }
856 }
857
858 impl RemoteControlController {
859 /// Acquires and validates the saved-session authority before any Runtime
860 /// Chat host/provider configuration is opened.
861 ///
862 /// The session-wide lifetime lock is intentionally acquired first. This
863 /// fixes the lock order across processes: a competing workspace cannot
864 /// make us release an existing Runtime store owner and only later discover
865 /// that the saved session is permanently bound elsewhere.
866 pub(crate) fn prepare_remote_control_session_journal(
867 &mut self,
868 dir: &Path,
869 target_ref: &str,
870 session_id: &str,
871 ) -> Result<(), String> {
872 let requested_scope = runtime_journal_scope_tag(target_ref, session_id);
873 let requested_session_path = dir.join(format!(
874 "active_classic_{}.json",
875 runtime_journal_session_tag(session_id)
876 ));
877 if let Some(current) = self.journal.as_ref() {
878 if current.active_index_path == requested_session_path {
879 if current.scope_tag != requested_scope {
880 return Err(CLASSIC_LEASE_SCOPE_ERROR.to_string());
881 }
882 return Ok(());
883 }
884 if self.has_active_run() || self.has_unacknowledged_integrity_events() {
885 return Err(
886 "The previous saved session still owns account delivery; reconnect it before opening another local session."
887 .to_string(),
888 );
889 }
890 }
891 drop(self.journal.take());
892 let journal = RuntimeEventJournal::open(dir, target_ref, session_id)?;
893 match journal.load() {
894 Ok(restored) => {
895 self.reset_pending_from(restored);
896 self.journal = Some(journal);
897 Ok(())
898 }
899 Err(error) => {
900 if error == JOURNAL_LEGACY_SCOPE_ERROR {
901 journal.quarantine_legacy();
902 } else {
903 journal.quarantine_current();
904 }
905 Err(error)
906 }
907 }
908 }
909
910 pub(crate) fn configure_runtime_chat(
911 &mut self,
912 config: crate::config::Config,
913 plugin_registry: std::sync::Arc<crate::plugins::PluginRegistry>,
914 private_root: PathBuf,
915 target_ref: String,
916 session_id: String,
917 ) -> Result<(), String> {
918 if matches!(
919 self.status,
920 Status::Connecting | Status::Connected | Status::Stopping
921 ) {
922 return Err("Remote control is already active.".to_string());
923 }
924 let requested = PendingRuntimeChatConfiguration {
925 config,
926 plugin_registry,
927 private_root,
928 target_ref,
929 session_id,
930 };
931 if let Some(host) = self.runtime_chat.as_ref() {
932 if host.scope_matches(&requested.target_ref, &requested.session_id)
933 && (host.has_any_unsettled_turns() || self.has_unacknowledged_integrity_events())
934 {
935 // Reuse the existing lifetime owner and durable manager only
936 // while recovery still needs it. The fresh config is retained
937 // separately and every new prompt is rejected until the
938 // terminal/catalog backlog is server-acked and the connected
939 // worker has explicitly dropped its clone.
940 self.pending_runtime_chat_configuration = Some(requested);
941 self.runtime_chat_refresh_release_requested = false;
942 return Ok(());
943 }
944 if host.has_any_unsettled_turns() {
945 return Err(
946 "An isolated Runtime Chat turn is still active; reconnect its original remote-control session before opening another."
947 .to_string(),
948 );
949 }
950 }
951 if self.has_unacknowledged_integrity_events() {
952 return Err(
953 "The current remote-control session still has unacknowledged terminal events; reconnect it before opening another session."
954 .to_string(),
955 );
956 }
957 // A completed `/rc` session deliberately keeps its durable host until
958 // the next start. Release that lifetime owner lock before reopening the
959 // same account/session namespace with a fresh provider configuration.
960 self.install_runtime_chat_configuration(requested)
961 }
962
963 fn install_runtime_chat_configuration(
964 &mut self,
965 requested: PendingRuntimeChatConfiguration,
966 ) -> Result<(), String> {
967 drop(self.runtime_chat.take());
968 let host = RuntimeChatRelayHost::open(
969 requested.config,
970 requested.plugin_registry,
971 requested.private_root,
972 requested.target_ref,
973 requested.session_id,
974 )?;
975 self.runtime_chat = Some(host);
976 self.pending_runtime_chat_configuration = None;
977 self.runtime_chat_refresh_release_requested = false;
978 self.uploaded_runtime_catalog_receipt.clear();
979 Ok(())
980 }
981
982 fn schedule_runtime_chat_refresh_if_ready(&mut self) -> Result<(), String> {
983 if self.pending_runtime_chat_configuration.is_none()
984 || self.runtime_chat_refresh_release_requested
985 || self.has_unacknowledged_integrity_events()
986 || self
987 .runtime_chat
988 .as_ref()
989 .is_some_and(RuntimeChatRelayHost::has_any_unsettled_turns)
990 {
991 return Ok(());
992 }
993 if self.status != Status::Connected {
994 return Ok(());
995 }
996 let tx = self.worker_tx.as_ref().ok_or_else(|| {
997 "Runtime Chat could not refresh its provider configuration safely.".to_string()
998 })?;
999 tx.send(WorkerCommand::ReleaseRuntimeChatHost)
1000 .map_err(|_| {
1001 "Runtime Chat could not refresh its provider configuration safely.".to_string()
1002 })?;
1003 self.runtime_chat_refresh_release_requested = true;
1004 self.status_detail = "refreshing the local Runtime model configuration".to_string();
1005 Ok(())
1006 }
1007
1008 fn complete_runtime_chat_refresh(&mut self) -> Result<(), String> {
1009 if !self.runtime_chat_refresh_release_requested
1010 || self.has_unacknowledged_integrity_events()
1011 || self
1012 .runtime_chat
1013 .as_ref()
1014 .is_some_and(RuntimeChatRelayHost::has_any_unsettled_turns)
1015 {
1016 return Err("Runtime Chat received an invalid provider-refresh handoff.".to_string());
1017 }
1018 let requested = self
1019 .pending_runtime_chat_configuration
1020 .take()
1021 .ok_or_else(|| {
1022 "Runtime Chat received an invalid provider-refresh handoff.".to_string()
1023 })?;
1024 // The worker has already removed its clone. Dropping the controller's
1025 // final old host now releases both the relay scope and native store
1026 // owner locks before the fresh immutable configuration is opened.
1027 drop(self.runtime_chat.take());
1028 let host = RuntimeChatRelayHost::open(
1029 requested.config,
1030 requested.plugin_registry,
1031 requested.private_root,
1032 requested.target_ref,
1033 requested.session_id,
1034 )?;
1035 if let (Some(account_ref), Some(target_ref)) =
1036 (self.account_ref.as_deref(), self.target_ref.as_deref())
1037 {
1038 host.bind_account(account_ref, target_ref)?;
1039 }
1040 if let Some(run_id) = self.attached_run_id.as_deref() {
1041 host.authorize_run(run_id)?;
1042 }
1043 self.runtime_chat = Some(host.clone());
1044 self.runtime_chat_refresh_release_requested = false;
1045 self.uploaded_runtime_catalog_receipt.clear();
1046 self.worker_tx
1047 .as_ref()
1048 .ok_or_else(|| {
1049 "Runtime Chat could not install its refreshed provider configuration.".to_string()
1050 })?
1051 .send(WorkerCommand::InstallRuntimeChatHost(host))
1052 .map_err(|_| {
1053 "Runtime Chat could not install its refreshed provider configuration.".to_string()
1054 })?;
1055 if let Some(attachment) = self.runtime_chat_attachment.clone() {
1056 self.upload_runtime_chat_catalog(&attachment)?;
1057 }
1058 self.status_detail = "web mirror connected".to_string();
1059 Ok(())
1060 }
1061
1062 pub fn start(&mut self, start: RemoteStart) -> Result<(), String> {
1063 if matches!(
1064 self.status,
1065 Status::Connecting | Status::Connected | Status::Stopping
1066 ) {
1067 return Err("Remote control is already active.".to_string());
1068 }
1069 if self.status == Status::Failed
1070 && self
1071 .ownership_blocked_until
1072 .is_some_and(|deadline| Instant::now() < deadline)
1073 {
1074 return Err(
1075 "The previous remote lease may still be active; wait for ownership to return before reconnecting."
1076 .to_string(),
1077 );
1078 }
1079 if self.active_run.is_some() || self.pending_local_turn_run.is_some() {
1080 return Err(
1081 "The local Work turn is still running; wait for its terminal receipt before reconnecting remote control."
1082 .to_string(),
1083 );
1084 }
1085 if self.has_unacknowledged_integrity_events()
1086 && self
1087 .runtime_chat
1088 .as_ref()
1089 .is_none_or(|host| !host.scope_matches(&start.target_ref, &start.session_id))
1090 {
1091 return Err(
1092 "The previous session still has unacknowledged terminal events; reconnect that exact session first."
1093 .to_string(),
1094 );
1095 }
1096 if !valid_runtime_version(&start.runtime_version)
1097 || !valid_runtime_commit(&start.runtime_commit)
1098 || !valid_opaque_ref(&start.target_ref)
1099 || !valid_session_ref(&start.session_id)
1100 {
1101 return Err("This build or session does not have an enrollable identity.".to_string());
1102 }
1103 if !self.pending_runtime_events.is_empty()
1104 && let Some(journal) = &self.journal
1105 {
1106 // A prior send-first fault is impossible now, but a disk failure
1107 // can leave a classic Work terminal retained only in live memory.
1108 // Repair that exact pending set before loading/replacing the same
1109 // session journal; never let reconnect clear the sole copy.
1110 journal.persist(&self.pending_runtime_events)?;
1111 }
1112 match &start.journal_dir {
1113 Some(dir) => self.prepare_remote_control_session_journal(
1114 dir,
1115 &start.target_ref,
1116 &start.session_id,
1117 )?,
1118 None => self.journal = None,
1119 }
1120 self.recover_classic_lease_before_worker()?;
1121 self.recover_runtime_chat_ownership_before_worker()?;
1122 let (worker_tx, worker_rx) = mpsc::unbounded_channel();
1123 let (event_tx, event_rx) = mpsc::unbounded_channel();
1124 self.stop_worker();
1125 self.status = Status::Connecting;
1126 self.status_detail = "waiting for account authorization".to_string();
1127 self.target_ref = Some(start.target_ref.clone());
1128 self.attached_run_id = None;
1129 self.attached_workspace_id = None;
1130 self.runtime_chat_attachment = None;
1131 self.uploaded_runtime_catalog_receipt.clear();
1132 self.active_run = None;
1133 self.pending_local_turn_run = None;
1134 self.worker_tx = Some(worker_tx);
1135 self.event_rx = Some(event_rx);
1136 let runtime_chat = self.runtime_chat.clone();
1137 self.worker = Some(tokio::spawn(async move {
1138 let mut phase = RelayPhase::Enrolling;
1139 if let Err(error) =
1140 relay_worker(start, runtime_chat, worker_rx, event_tx.clone(), &mut phase).await
1141 {
1142 let _ = if phase.lease_confirmed() {
1143 event_tx.send(RemoteEvent::Failed(error))
1144 } else {
1145 event_tx.send(RemoteEvent::FailedPreLease(error))
1146 };
1147 }
1148 }));
1149 Ok(())
1150 }
1151
1152 fn recover_runtime_chat_ownership_before_worker(&self) -> Result<(), String> {
1153 let Some(host) = &self.runtime_chat else {
1154 return Ok(());
1155 };
1156 if host.has_any_unsettled_turns() || self.has_unacknowledged_integrity_events() {
1157 // A prior process may have stopped either while native inference
1158 // was still running or after terminal projection was durable but
1159 // before CWC acknowledged its account-delivery envelope. Reclaim
1160 // the exclusive attached-run writer synchronously before worker
1161 // enrollment (and therefore before any autonomous provider work)
1162 // can begin.
1163 host.recover_inference_ownership_for_pending_delivery()?;
1164 }
1165 Ok(())
1166 }
1167
1168 fn recover_classic_lease_before_worker(&mut self) -> Result<(), String> {
1169 let Some(lease) = self
1170 .journal
1171 .as_ref()
1172 .and_then(RuntimeEventJournal::classic_lease)
1173 else {
1174 return Ok(());
1175 };
1176 self.event_seq
1177 .entry(lease.run_id.clone())
1178 .and_modify(|known| *known = (*known).max(lease.seq_floor))
1179 .or_insert(lease.seq_floor);
1180 let recovery_turn_id = lease
1181 .turn_id
1182 .clone()
1183 .unwrap_or_else(|| classic_recovery_turn_id(&lease.run_id, &lease.lease_id));
1184 let already_terminal =
1185 self.pending_runtime_events
1186 .get(&lease.run_id)
1187 .is_some_and(|events| {
1188 events.iter().any(|(seq, entry)| {
1189 *seq > lease.seq_floor
1190 && runtime_envelope_event(&entry.envelope) == Some("turn.completed")
1191 && entry.envelope.get("turn_id").and_then(Value::as_str)
1192 == Some(recovery_turn_id.as_str())
1193 })
1194 });
1195 if !already_terminal {
1196 let seq = self.next_runtime_seq(&lease.run_id);
1197 let envelope = runtime_envelope(
1198 seq,
1199 "turn.completed",
1200 Some(&recovery_turn_id),
1201 chrono::Utc::now().to_rfc3339(),
1202 json!({
1203 "turn": {
1204 "status": "failed",
1205 "usage": {},
1206 "error": "The local Runtime restarted before terminal delivery."
1207 }
1208 }),
1209 );
1210 if !self.queue_runtime_envelope(&lease.run_id, envelope) {
1211 return Err(
1212 "Remote control could not recover its interrupted account turn.".to_string(),
1213 );
1214 }
1215 }
1216 self.journal
1217 .as_ref()
1218 .ok_or_else(|| "Remote control lost its delivery journal during recovery.".to_string())?
1219 .set_classic_lease(None, &self.pending_runtime_events)?;
1220 Ok(())
1221 }
1222
1223 /// Why `/rc stop` must currently be refused, if any reason exists.
1224 ///
1225 /// Stopping is only safe once no remote turn is active and every
1226 /// integrity-critical envelope (terminal turn state, approvals, failures,
1227 /// resynchronization snapshots) is behind the server-confirmed cursor.
1228 pub fn stop_refusal(&self) -> Option<String> {
1229 if self.has_active_run() {
1230 return Some(
1231 "Finish or interrupt the active remote turn before stopping remote control."
1232 .to_string(),
1233 );
1234 }
1235 if self
1236 .runtime_chat
1237 .as_ref()
1238 .is_some_and(RuntimeChatRelayHost::has_any_unsettled_turns)
1239 {
1240 return Some(
1241 "Finish or interrupt the active Runtime Chat turn before stopping remote control."
1242 .to_string(),
1243 );
1244 }
1245 if self.has_unacknowledged_integrity_events() {
1246 return Some(
1247 "The server has not yet acknowledged this session's terminal or approval events; try /rc stop again in a moment."
1248 .to_string(),
1249 );
1250 }
1251 None
1252 }
1253
1254 fn has_unacknowledged_integrity_events(&self) -> bool {
1255 self.pending_runtime_events
1256 .values()
1257 .flat_map(BTreeMap::values)
1258 .any(|entry| entry.integrity)
1259 }
1260
1261 pub(crate) fn runtime_chat_blocks_local_dispatch(&self) -> bool {
1262 self.has_durable_classic_lease()
1263 || self.pending_runtime_chat_configuration.is_some()
1264 || self.runtime_chat.as_ref().is_some_and(|host| {
1265 host.has_any_unsettled_turns() || self.has_unacknowledged_integrity_events()
1266 })
1267 }
1268
1269 #[cfg(test)]
1270 pub(crate) fn block_runtime_chat_dispatch_for_tests(&mut self) {
1271 self.pending_runtime_chat_configuration = Some(PendingRuntimeChatConfiguration {
1272 config: crate::config::Config::default(),
1273 plugin_registry: std::sync::Arc::new(crate::plugins::PluginRegistry::empty(Path::new(
1274 ".",
1275 ))),
1276 private_root: PathBuf::from("runtime-chat-test-block"),
1277 target_ref: "target_test_block".to_string(),
1278 session_id: "session_test_block".to_string(),
1279 });
1280 }
1281
1282 fn attachment_switch_refusal(&self, next_run_id: &str) -> Option<String> {
1283 let current_run_id = self.attached_run_id.as_deref()?;
1284 if current_run_id == next_run_id {
1285 return None;
1286 }
1287 if self.has_active_run() {
1288 return Some(
1289 "Finish the active Work turn before attaching another account run.".to_string(),
1290 );
1291 }
1292 let has_unacknowledged_terminal = self
1293 .pending_runtime_events
1294 .get(current_run_id)
1295 .is_some_and(|events| events.values().any(|entry| entry.integrity));
1296 has_unacknowledged_terminal.then(|| {
1297 "The current Runtime Chat turn must be acknowledged before attaching another run."
1298 .to_string()
1299 })
1300 }
1301
1302 pub fn stop(&mut self) {
1303 if let Some(refusal) = self.stop_refusal() {
1304 self.status_detail = refusal;
1305 return;
1306 }
1307 if self.status == Status::Connecting {
1308 // The worker may have completed its server-side connect just before
1309 // the UI consumed RemoteEvent::Connected. Aborting it cannot prove
1310 // that no lease exists, so retain the ownership lock through the
1311 // server expiry instead of returning local input immediately.
1312 self.stop_worker();
1313 self.status = Status::Failed;
1314 self.status_detail =
1315 "authorization cancelled; waiting for any server lease to expire safely"
1316 .to_string();
1317 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1318 } else if self.status == Status::Connected {
1319 // Hand every deferred delta to the transport first so the worker's
1320 // pre-heartbeat drain covers the complete unacknowledged set.
1321 self.hand_off_all_deferred();
1322 let queued = self
1323 .worker_tx
1324 .as_ref()
1325 .is_some_and(|tx| tx.send(WorkerCommand::Stop).is_ok());
1326 self.worker_tx = None;
1327 if queued {
1328 self.status = Status::Stopping;
1329 self.status_detail = "confirming the runner is offline".to_string();
1330 } else {
1331 self.status = Status::Failed;
1332 self.status_detail =
1333 "waiting for the last server lease to expire safely".to_string();
1334 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1335 }
1336 }
1337 if self.status == Status::Failed && self.ownership_blocked_until.is_none() {
1338 // A pre-lease failure holds no lease; stopping is an ordinary
1339 // reset, not a drain confirmation.
1340 self.status = Status::Off;
1341 }
1342 if self.status == Status::Off {
1343 self.account_ref = None;
1344 self.links = RemoteLinks::default();
1345 self.attached_run_id = None;
1346 self.attached_workspace_id = None;
1347 self.runtime_chat_attachment = None;
1348 self.active_run = None;
1349 self.pending_local_turn_run = None;
1350 self.pending_approvals.clear();
1351 self.command_fingerprints.clear();
1352 self.ownership_blocked_until = None;
1353 }
1354 }
1355
1356 fn stop_worker(&mut self) {
1357 if let Some(worker) = self.worker.take() {
1358 worker.abort();
1359 }
1360 if let Some(host) = &self.runtime_chat {
1361 // Projection claims are only an in-process handoff lease between
1362 // the worker and this controller. Aborting the worker or dropping
1363 // its receiver must make every unjournaled native event eligible
1364 // for replay on the same durable host.
1365 host.release_all_projection_claims();
1366 }
1367 self.worker_tx = None;
1368 self.event_rx = None;
1369 self.runtime_chat_refresh_release_requested = false;
1370 }
1371
1372 fn quarantine_rejected_authority(&mut self, error: String) -> RemoteEvent {
1373 // Once an attachment fails its account/run/catalog authority checks,
1374 // no later event from that worker can be trusted. In particular, the
1375 // classic Prompt and Approval command paths predate Runtime Chat and
1376 // must not keep consuming commands from a rejected re-enrollment.
1377 self.stop_worker();
1378 self.status = Status::Failed;
1379 self.status_detail = error.clone();
1380 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1381 RemoteEvent::Failed(error)
1382 }
1383
1384 pub fn try_next_event(&mut self) -> Option<RemoteEvent> {
1385 // Coalescing ends at the next UI poll: hand any deferred delta to the
1386 // transport so live viewers never wait more than one tick.
1387 self.hand_off_all_deferred();
1388 if self.status == Status::Failed
1389 && self
1390 .ownership_blocked_until
1391 .is_some_and(|deadline| Instant::now() >= deadline)
1392 {
1393 if self.has_active_run()
1394 || self.has_unacknowledged_integrity_events()
1395 || self
1396 .runtime_chat
1397 .as_ref()
1398 .is_some_and(RuntimeChatRelayHost::has_any_unsettled_turns)
1399 {
1400 // The server lease may have expired, but provider-backed work
1401 // still owns this private Runtime store. Keep the host/scope
1402 // lock and allow only a same-session reconnect to resume
1403 // projection/drain; never transition Off and reopen a second
1404 // manager over the active store.
1405 self.stop_worker();
1406 self.status_detail =
1407 "this session still has unsettled or unacknowledged Runtime Chat work; reconnect it to drain"
1408 .to_string();
1409 self.ownership_blocked_until = None;
1410 return None;
1411 }
1412 let approvals = self
1413 .pending_approvals
1414 .drain()
1415 .map(|(_, value)| value)
1416 .collect();
1417 self.stop_worker();
1418 self.status = Status::Off;
1419 self.status_detail = "off".to_string();
1420 self.ownership_blocked_until = None;
1421 return Some(RemoteEvent::OwnershipRestored { approvals });
1422 }
1423 let event = self.event_rx.as_mut()?.try_recv().ok()?;
1424 match &event {
1425 RemoteEvent::Connected {
1426 account_ref,
1427 target_ref,
1428 attachment,
1429 links,
1430 ..
1431 } => {
1432 if let Some(error) = self.attachment_switch_refusal(&attachment.run_id) {
1433 return Some(self.quarantine_rejected_authority(error));
1434 }
1435 if let Some(host) = &self.runtime_chat
1436 && let Err(error) = host
1437 .bind_account(account_ref, target_ref)
1438 .and_then(|()| host.authorize_run(&attachment.run_id))
1439 {
1440 return Some(self.quarantine_rejected_authority(error));
1441 }
1442 self.apply_attachment(attachment);
1443 if self.pending_runtime_chat_configuration.is_none()
1444 && self.runtime_chat.is_some()
1445 && let Err(error) = self.upload_runtime_chat_catalog(attachment)
1446 {
1447 return Some(self.quarantine_rejected_authority(error));
1448 }
1449 self.links = links.clone();
1450 // Journal recovery may hold unacknowledged envelopes for runs
1451 // beyond this attachment; resend every pending run now.
1452 self.flush_all_pending();
1453 self.status = Status::Connected;
1454 self.status_detail = "web mirror connected".to_string();
1455 self.ownership_blocked_until = None;
1456 self.account_ref = Some(account_ref.clone());
1457 self.target_ref = Some(target_ref.clone());
1458 if let Err(error) = self.schedule_runtime_chat_refresh_if_ready() {
1459 return Some(self.quarantine_rejected_authority(error));
1460 }
1461 }
1462 RemoteEvent::Attachment {
1463 account_ref,
1464 target_ref,
1465 attachment,
1466 links,
1467 } => {
1468 if let Some(error) = self.attachment_switch_refusal(&attachment.run_id) {
1469 return Some(self.quarantine_rejected_authority(error));
1470 }
1471 if let Some(host) = &self.runtime_chat
1472 && let Err(error) = host
1473 .bind_account(account_ref, target_ref)
1474 .and_then(|()| host.authorize_run(&attachment.run_id))
1475 {
1476 return Some(self.quarantine_rejected_authority(error));
1477 }
1478 self.apply_attachment(attachment);
1479 if self.pending_runtime_chat_configuration.is_none()
1480 && self.runtime_chat.is_some()
1481 && let Err(error) = self.upload_runtime_chat_catalog(attachment)
1482 {
1483 return Some(self.quarantine_rejected_authority(error));
1484 }
1485 self.links = links.clone();
1486 self.account_ref = Some(account_ref.clone());
1487 self.target_ref = Some(target_ref.clone());
1488 if let Err(error) = self.schedule_runtime_chat_refresh_if_ready() {
1489 return Some(self.quarantine_rejected_authority(error));
1490 }
1491 }
1492 RemoteEvent::RuntimeCursor { run_id, cursor } => {
1493 self.reconcile_runtime_cursor(run_id, *cursor);
1494 if let Err(error) = self.schedule_runtime_chat_refresh_if_ready() {
1495 return Some(self.quarantine_rejected_authority(error));
1496 }
1497 }
1498 RemoteEvent::RuntimeChatHostReleased => {
1499 if let Err(error) = self.complete_runtime_chat_refresh() {
1500 return Some(self.quarantine_rejected_authority(error));
1501 }
1502 }
1503 RemoteEvent::RuntimeChatProjection(projection) => {
1504 if let Err(error) = self.upload_runtime_chat_projection(projection) {
1505 if let Some(host) = &self.runtime_chat {
1506 host.release_projection(
1507 &projection.native_thread_id,
1508 projection.native_seq,
1509 );
1510 }
1511 self.stop_worker();
1512 self.status = Status::Failed;
1513 self.status_detail = error.clone();
1514 self.ownership_blocked_until =
1515 Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1516 return Some(RemoteEvent::Failed(error));
1517 }
1518 }
1519 RemoteEvent::FailedPreLease(reason) => {
1520 // No server-confirmed lease ever existed, so nothing is
1521 // locked: no reconnect blackout, no approval handoff to
1522 // undo, and `/rc` can retry immediately.
1523 self.status = Status::Failed;
1524 self.status_detail = reason.clone();
1525 self.ownership_blocked_until = None;
1526 if !self.has_durable_classic_lease() {
1527 self.active_run = None;
1528 self.pending_local_turn_run = None;
1529 }
1530 }
1531 RemoteEvent::Failed(reason) => {
1532 self.status = Status::Failed;
1533 self.status_detail =
1534 format!("{reason}; waiting for the last server lease to expire safely");
1535 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1536 // Exact unacknowledged runtime envelopes remain owned by this
1537 // controller (and its journal). Keep any classic provider-turn
1538 // association live as well: the engine can still deliver its
1539 // exact terminal status/usage after the transport fails.
1540 }
1541 RemoteEvent::Stopped => {
1542 self.status = Status::Off;
1543 self.status_detail = "off".to_string();
1544 self.active_run = None;
1545 self.pending_local_turn_run = None;
1546 self.attached_run_id = None;
1547 self.attached_workspace_id = None;
1548 self.runtime_chat_attachment = None;
1549 self.links = RemoteLinks::default();
1550 self.ownership_blocked_until = None;
1551 // The worker only reports Stopped after draining through the
1552 // server-confirmed cursor and posting the offline heartbeat,
1553 // so an empty pending set means the journal is spent.
1554 if self.pending_runtime_events.values().all(BTreeMap::is_empty)
1555 && let Some(journal) = &self.journal
1556 {
1557 journal.remove();
1558 }
1559 if !self.pending_approvals.is_empty() {
1560 let approvals = self
1561 .pending_approvals
1562 .drain()
1563 .map(|(_, value)| value)
1564 .collect();
1565 return Some(RemoteEvent::OwnershipRestored { approvals });
1566 }
1567 }
1568 RemoteEvent::Notice(_)
1569 | RemoteEvent::Command { .. }
1570 | RemoteEvent::OwnershipRestored { .. } => {}
1571 }
1572 Some(event)
1573 }
1574
1575 /// The validated web link for the live session, once the control plane
1576 /// has advertised one.
1577 pub fn run_url(&self) -> Option<&str> {
1578 if matches!(self.status, Status::Connected | Status::Stopping) {
1579 self.links.run_url.as_deref()
1580 } else {
1581 None
1582 }
1583 }
1584
1585 /// The validated web link for this computer's settings row, if advertised.
1586 pub fn computer_url(&self) -> Option<&str> {
1587 if matches!(self.status, Status::Connected | Status::Stopping) {
1588 self.links.computer_url.as_deref()
1589 } else {
1590 None
1591 }
1592 }
1593
1594 /// One word for the posture bar's right slot, `None` while remote
1595 /// control is off so the bar paints nothing rather than "off".
1596 #[must_use]
1597 pub fn status_word(&self) -> Option<&'static str> {
1598 match self.status {
1599 Status::Off => None,
1600 Status::Connecting => Some("connecting"),
1601 Status::Connected => Some("connected"),
1602 Status::Stopping => Some("stopping"),
1603 Status::Failed => Some("failed"),
1604 }
1605 }
1606
1607 /// Test-only: advertise a validated live session link without a control
1608 /// plane. Mirrors the shape the runner-lease parser installs (`Connected`
1609 /// plus validated links) so `/rc link`/`/rc open` routing can be exercised
1610 /// deterministically offline.
1611 #[cfg(test)]
1612 pub(crate) fn install_live_link_for_test(&mut self, run_url: &str, computer_url: Option<&str>) {
1613 self.status = Status::Connected;
1614 self.status_detail = "test link".to_string();
1615 self.links = RemoteLinks {
1616 run_url: Some(run_url.to_string()),
1617 computer_url: computer_url.map(str::to_string),
1618 };
1619 }
1620
1621 pub fn status_line(&self) -> String {
1622 match self.status {
1623 Status::Off => "Remote control: off".to_string(),
1624 Status::Connecting => format!("Remote control: connecting · {}", self.status_detail),
1625 Status::Connected => match self.links.run_url.as_deref() {
1626 Some(url) => format!(
1627 "Remote control: connected · account {} · {} · open {url}",
1628 self.account_ref.as_deref().unwrap_or("account"),
1629 self.status_detail
1630 ),
1631 None => format!(
1632 "Remote control: connected · account {} · {}",
1633 self.account_ref.as_deref().unwrap_or("account"),
1634 self.status_detail
1635 ),
1636 },
1637 Status::Stopping => {
1638 "Remote control: stopping · confirming the runner is offline".to_string()
1639 }
1640 Status::Failed => {
1641 if self
1642 .ownership_blocked_until
1643 .is_some_and(|deadline| Instant::now() < deadline)
1644 {
1645 format!(
1646 "Remote control: lost after connecting · {} · reconnect waits for the server lease to drain",
1647 self.status_detail
1648 )
1649 } else {
1650 format!(
1651 "Remote control: failed before connecting · {} · /rc to retry",
1652 self.status_detail
1653 )
1654 }
1655 }
1656 }
1657 }
1658
1659 /// Whether the web mirror can carry an approval decision right now.
1660 ///
1661 /// `Connecting` deliberately does not qualify: there is not yet an
1662 /// attachment/run cursor able to carry a typed approval, so the local
1663 /// card stays the only actionable surface until `Connected`. A
1664 /// transport failure also disqualifies — a dead relay cannot deliver a
1665 /// decision, and the local card remains the source of truth either way.
1666 ///
1667 /// Mirror semantics: this never gates *local* input. It only decides
1668 /// whether the approval card is *also* shared with the web.
1669 pub fn can_share_approval_with_web(&self) -> bool {
1670 let attached = match self.status {
1671 // A connection alone is not enough. Until a concrete typed turn
1672 // id is bound, `record_remote_approval` has nowhere safe to send
1673 // a decision, so the card stays local-only.
1674 Status::Connected | Status::Stopping => self.active_run.is_some(),
1675 Status::Off | Status::Connecting | Status::Failed => false,
1676 };
1677 attached && !self.applying_remote_command
1678 }
1679
1680 /// Record that the *local* surface answered an approval, so a late web
1681 /// decision for the same tool is acknowledged as "no longer pending"
1682 /// instead of double-answering the engine. First decision wins; the
1683 /// other surface is told.
1684 pub fn resolve_pending_approval(&mut self, tool_id: &str, approved: bool) -> bool {
1685 let gate = projected_approval_id(tool_id);
1686 if self.pending_approvals.remove(&gate).is_none() {
1687 return false;
1688 }
1689 if let Some(active) = self.active_run.clone() {
1690 self.upload_envelope(
1691 &active.run_id,
1692 "approval.resolved",
1693 Some(&active.turn_id),
1694 json!({
1695 "id": gate,
1696 "approval_id": gate,
1697 "decision": if approved { "approved" } else { "denied" },
1698 "decided_by": "terminal",
1699 }),
1700 );
1701 }
1702 true
1703 }
1704
1705 pub fn set_applying_remote_command(&mut self, value: bool) {
1706 self.applying_remote_command = value;
1707 }
1708
1709 /// Test-only: put the controller into the exact state a live connected
1710 /// mirror with an attached run would be in, without a relay worker.
1711 #[cfg(test)]
1712 pub(crate) fn force_mirror_connected_for_tests(&mut self, run_id: &str, turn_id: &str) {
1713 self.status = Status::Connected;
1714 self.status_detail = "web mirror connected".to_string();
1715 self.attached_run_id = Some(run_id.to_string());
1716 self.active_run = Some(ActiveRelayRun {
1717 run_id: run_id.to_string(),
1718 turn_id: turn_id.to_string(),
1719 });
1720 }
1721
1722 #[cfg(test)]
1723 pub(crate) fn queue_remote_event_for_tests(&mut self, event: RemoteEvent) {
1724 let (event_tx, event_rx) = mpsc::unbounded_channel();
1725 event_tx.send(event).expect("test event receiver");
1726 self.event_rx = Some(event_rx);
1727 if self.worker_tx.is_none() {
1728 let (worker_tx, _worker_rx) = mpsc::unbounded_channel();
1729 self.worker_tx = Some(worker_tx);
1730 }
1731 }
1732
1733 pub fn claim_command(
1734 &mut self,
1735 run_id: &str,
1736 seq: u64,
1737 command: &RemoteCommand,
1738 ) -> Result<bool, String> {
1739 if self.status != Status::Connected || self.attached_run_id.as_deref() != Some(run_id) {
1740 return Err(
1741 "The remote command does not belong to the current authorized attachment."
1742 .to_string(),
1743 );
1744 }
1745 let fingerprint = command_fingerprint(command);
1746 let key = (run_id.to_string(), seq);
1747 if let Some(existing) = self.command_fingerprints.get(&key) {
1748 if existing == &fingerprint {
1749 // Runtime Chat's native operation key is idempotent. The UI
1750 // bridge deliberately re-enters that path for an exact replay
1751 // so a failed acknowledgement POST is issued again; admission
1752 // gates below describe *new* work and must not false-fail it.
1753 return Ok(false);
1754 }
1755 return Err(
1756 "The control plane reused a command sequence with different content.".to_string(),
1757 );
1758 }
1759 if let RemoteCommand::RuntimeChatPrompt(prompt) = command
1760 && let Some(host) = &self.runtime_chat
1761 && host.is_exact_prompt_replay(prompt)?
1762 {
1763 // The controller's command-sequence cache is intentionally
1764 // process-local, while the native operation/turn binding is
1765 // durable. After a crash or deferred provider-config refresh,
1766 // recognize that durable exact replay before new-work gates and
1767 // reissue its acknowledgement through the idempotent host path.
1768 self.command_fingerprints.insert(key, fingerprint);
1769 return Ok(false);
1770 }
1771 match command {
1772 RemoteCommand::Prompt { .. } | RemoteCommand::RuntimeChatPrompt(_)
1773 if self.pending_runtime_chat_configuration.is_some() =>
1774 {
1775 return Err(
1776 "Wait for Runtime Chat to finish refreshing its local model configuration."
1777 .to_string(),
1778 );
1779 }
1780 RemoteCommand::RuntimeChatPrompt(_) if self.has_active_run() => {
1781 return Err("Finish the active Work turn before starting Runtime Chat.".to_string());
1782 }
1783 RemoteCommand::RuntimeChatPrompt(_) if self.has_unacknowledged_integrity_events() => {
1784 return Err(
1785 "Wait for the current Runtime Chat catalog or terminal event to be acknowledged before starting another turn."
1786 .to_string(),
1787 );
1788 }
1789 RemoteCommand::Prompt { .. }
1790 if self
1791 .runtime_chat
1792 .as_ref()
1793 .is_some_and(RuntimeChatRelayHost::has_any_unsettled_turns)
1794 || self
1795 .pending_runtime_events
1796 .get(run_id)
1797 .is_some_and(|events| events.values().any(|entry| entry.integrity)) =>
1798 {
1799 return Err(
1800 "Finish and acknowledge the active Runtime Chat turn before starting Work."
1801 .to_string(),
1802 );
1803 }
1804 _ => {}
1805 }
1806 self.command_fingerprints.insert(key, fingerprint);
1807 Ok(true)
1808 }
1809
1810 pub fn activate_prompt(&mut self, run_id: &str, turn_id: &str) -> Result<(), String> {
1811 self.activate_prompt_with_lease_id(run_id, turn_id, String::new())
1812 }
1813
1814 fn activate_prompt_with_lease_id(
1815 &mut self,
1816 run_id: &str,
1817 turn_id: &str,
1818 lease_id: String,
1819 ) -> Result<(), String> {
1820 let active = ActiveRelayRun {
1821 run_id: run_id.to_string(),
1822 turn_id: turn_id.to_string(),
1823 };
1824 self.begin_classic_lease(ClassicRunLease {
1825 run_id: active.run_id.clone(),
1826 turn_id: Some(active.turn_id.clone()),
1827 lease_id,
1828 seq_floor: self.runtime_seq_floor(&active.run_id),
1829 })?;
1830 self.active_run = Some(active);
1831 Ok(())
1832 }
1833
1834 fn promote_pending_local_turn(&mut self, run_id: &str, turn_id: &str) -> Result<(), String> {
1835 let lease_id = self
1836 .journal
1837 .as_ref()
1838 .and_then(RuntimeEventJournal::classic_lease)
1839 .filter(|lease| lease.run_id == run_id && lease.turn_id.is_none())
1840 .map(|lease| lease.lease_id)
1841 .ok_or_else(|| {
1842 "Remote control lost its durable pre-dispatch lease generation.".to_string()
1843 })?;
1844 self.activate_prompt_with_lease_id(run_id, turn_id, lease_id)
1845 }
1846
1847 #[cfg(test)]
1848 pub fn active_run_matches(&self, run_id: &str) -> bool {
1849 self.active_run
1850 .as_ref()
1851 .is_some_and(|active| active.run_id == run_id)
1852 || self.pending_local_turn_run.as_deref() == Some(run_id)
1853 }
1854
1855 /// Legacy Work cancellation is exact-turn scoped. A delayed command for
1856 /// an older turn in the same run must never cancel the current provider
1857 /// lifecycle.
1858 pub fn active_turn_matches(&self, run_id: &str, turn_id: &str) -> bool {
1859 self.active_run
1860 .as_ref()
1861 .is_some_and(|active| active.run_id == run_id && active.turn_id == turn_id)
1862 || self
1863 .journal
1864 .as_ref()
1865 .and_then(RuntimeEventJournal::classic_lease)
1866 .is_some_and(|lease| {
1867 lease.run_id == run_id && lease.turn_id.as_deref() == Some(turn_id)
1868 })
1869 }
1870
1871 /// A remotely-owned turn must reach a terminal engine event before the
1872 /// user can release the relay lease. Dropping the worker earlier would
1873 /// discard the run binding and strand the control-plane ledger while the
1874 /// local engine continued producing results.
1875 pub fn has_active_run(&self) -> bool {
1876 self.active_run.is_some()
1877 || self.pending_local_turn_run.is_some()
1878 || self.has_durable_classic_lease()
1879 }
1880
1881 fn has_durable_classic_lease(&self) -> bool {
1882 self.journal
1883 .as_ref()
1884 .and_then(RuntimeEventJournal::classic_lease)
1885 .is_some()
1886 }
1887
1888 /// Bind the server-confirmed attachment to the local turn that was
1889 /// already running when `/rc` was invoked.
1890 ///
1891 /// With a typed runtime turn id the binding is immediate. During the
1892 /// narrow dispatch-before-`TurnStarted` window, the run is parked and the
1893 /// first typed start event completes the binding. Replays are idempotent:
1894 /// an existing binding is never replaced and no runtime envelope is
1895 /// emitted by this method itself.
1896 pub fn attach_current_local_turn(&mut self, turn_id: Option<&str>) -> bool {
1897 if self.status != Status::Connected || self.has_active_run() {
1898 return false;
1899 }
1900 let Some(run_id) = self.attached_run_id.clone() else {
1901 return false;
1902 };
1903 match turn_id.map(str::trim).filter(|turn_id| !turn_id.is_empty()) {
1904 Some(turn_id) => {
1905 if self.activate_prompt(&run_id, turn_id).is_err() {
1906 return false;
1907 }
1908 }
1909 None => {
1910 if self
1911 .begin_classic_lease(ClassicRunLease {
1912 run_id: run_id.clone(),
1913 turn_id: None,
1914 lease_id: String::new(),
1915 seq_floor: self.runtime_seq_floor(&run_id),
1916 })
1917 .is_err()
1918 {
1919 return false;
1920 }
1921 self.pending_local_turn_run = Some(run_id);
1922 }
1923 }
1924 true
1925 }
1926
1927 /// Release a dispatch-window binding when the local dispatcher becomes
1928 /// idle without ever producing a typed `TurnStarted`. The account-owned
1929 /// attachment remains connected and can accept a later web prompt; only
1930 /// the nonexistent turn lease is removed.
1931 pub fn release_unstarted_local_turn(&mut self) -> bool {
1932 if self.pending_local_turn_run.is_none() {
1933 return false;
1934 }
1935 if self.finish_classic_lease().is_err() {
1936 return false;
1937 }
1938 self.pending_local_turn_run = None;
1939 true
1940 }
1941
1942 fn begin_classic_lease(&mut self, lease: ClassicRunLease) -> Result<(), String> {
1943 let mut lease = lease;
1944 lease.seq_floor = lease.seq_floor.max(self.runtime_seq_floor(&lease.run_id));
1945 if let Some(previous) = self
1946 .journal
1947 .as_ref()
1948 .and_then(RuntimeEventJournal::classic_lease)
1949 .filter(|previous| previous.run_id == lease.run_id)
1950 {
1951 lease.seq_floor = lease.seq_floor.max(previous.seq_floor);
1952 }
1953 // A blank id always starts a fresh generation. The dispatch-window
1954 // promotion above is the only valid reuse and supplies its durable id
1955 // explicitly so a stale pre-dispatch lease cannot be inherited here.
1956 if lease.lease_id.is_empty() {
1957 lease.lease_id = format!("classic_lease_{}", uuid::Uuid::new_v4().simple());
1958 }
1959 if !lease.valid() {
1960 return Err("The remote Work turn identity is invalid.".to_string());
1961 }
1962 let Some(journal) = &self.journal else {
1963 #[cfg(test)]
1964 return Ok(());
1965 #[cfg(not(test))]
1966 {
1967 self.status = Status::Failed;
1968 self.status_detail =
1969 "Remote control requires a durable account-delivery journal.".to_string();
1970 return Err(self.status_detail.clone());
1971 }
1972 };
1973 if let Err(error) = journal.set_classic_lease(Some(lease), &self.pending_runtime_events) {
1974 self.status = Status::Failed;
1975 self.status_detail = error.clone();
1976 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1977 return Err(error);
1978 }
1979 Ok(())
1980 }
1981
1982 fn finish_classic_lease(&mut self) -> Result<(), String> {
1983 let Some(journal) = &self.journal else {
1984 #[cfg(test)]
1985 return Ok(());
1986 #[cfg(not(test))]
1987 return Err("Remote control requires a durable account-delivery journal.".to_string());
1988 };
1989 if let Err(error) = journal.set_classic_lease(None, &self.pending_runtime_events) {
1990 self.status = Status::Failed;
1991 self.status_detail = error.clone();
1992 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
1993 return Err(error);
1994 }
1995 Ok(())
1996 }
1997
1998 /// A remote prompt can fail during local route preparation before the
1999 /// engine owns a turn and therefore before it can emit `EngineEvent::Error`.
2000 /// That failure is still terminal for the account-owned run.
2001 pub fn fail_active_dispatch(&mut self, error: &str) {
2002 self.fail_active_run("dispatch_failed", error);
2003 }
2004
2005 fn apply_attachment(&mut self, attachment: &RemoteAttachment) {
2006 self.attached_run_id = Some(attachment.run_id.clone());
2007 self.attached_workspace_id = Some(attachment.workspace_id.clone());
2008 self.runtime_chat_attachment = Some(attachment.clone());
2009 self.reconcile_runtime_cursor(&attachment.run_id, attachment.runtime_cursor);
2010 let local_cursor = self
2011 .pending_runtime_events
2012 .get(&attachment.run_id)
2013 .and_then(|events| events.last_key_value().map(|(seq, _)| *seq))
2014 .unwrap_or(0);
2015 let cursor = self.event_seq.entry(attachment.run_id.clone()).or_insert(0);
2016 *cursor = (*cursor).max(attachment.runtime_cursor).max(local_cursor);
2017 self.flush_pending_runtime_events(&attachment.run_id);
2018 // `snapshot_present` is server history, not proof that this freshly
2019 // loaded TUI process has uploaded its current saved history. The local
2020 // marker below prevents ordinary same-process reconnect duplication.
2021 }
2022
2023 fn upload_runtime_chat_catalog(&mut self, attachment: &RemoteAttachment) -> Result<(), String> {
2024 if self.pending_runtime_chat_configuration.is_some() {
2025 return Err(
2026 "Runtime Chat must refresh its local model configuration before advertising a catalog."
2027 .to_string(),
2028 );
2029 }
2030 let host = self
2031 .runtime_chat
2032 .clone()
2033 .ok_or_else(|| "This Runtime cannot advertise an isolated Chat relay.".to_string())?;
2034 let payload = host.catalog(&attachment.runtime_chat_relay_challenge)?;
2035 let payload_fingerprint = RuntimeChatRelayHost::catalog_payload_fingerprint(&payload)?;
2036 let receipt_key = format!(
2037 "{}:{}",
2038 attachment.runtime_chat_relay_challenge, payload_fingerprint
2039 );
2040 if self
2041 .uploaded_runtime_catalog_receipt
2042 .get(&attachment.run_id)
2043 .is_some_and(|receipt| receipt == &receipt_key)
2044 {
2045 return Ok(());
2046 }
2047 let source_event_id =
2048 runtime_chat_catalog_source_event_id(&attachment.run_id, &payload_fingerprint);
2049 if self.pending_runtime_chat_catalog_matches(&attachment.run_id, &source_event_id) {
2050 self.uploaded_runtime_catalog_receipt
2051 .insert(attachment.run_id.clone(), receipt_key);
2052 return Ok(());
2053 }
2054 // A local receipt proves only that the catalog was journaled, not that
2055 // the server cursor acknowledged it. After restart, resend the stable
2056 // semantic source id whenever no matching pending entry remains. CWC
2057 // deduplicates an exact replay and rejects conflicting reuse, while a
2058 // changed safe catalog gets a different payload fingerprint/source id.
2059 let seq = self.next_runtime_seq(&attachment.run_id);
2060 let envelope = runtime_chat_envelope(
2061 seq,
2062 "runtime.catalog",
2063 None,
2064 None,
2065 Some(&source_event_id),
2066 RUNTIME_CHAT_CATALOG_TIMESTAMP.to_string(),
2067 payload,
2068 );
2069 if !self.queue_runtime_chat_envelope(&attachment.run_id, envelope) {
2070 return Err("Runtime Chat could not journal its challenge-bound catalog.".to_string());
2071 }
2072 self.uploaded_runtime_catalog_receipt
2073 .insert(attachment.run_id.clone(), receipt_key);
2074 Ok(())
2075 }
2076
2077 fn pending_runtime_chat_catalog_matches(&self, run_id: &str, source_event_id: &str) -> bool {
2078 self.pending_runtime_events
2079 .get(run_id)
2080 .is_some_and(|events| {
2081 events.values().any(|event| {
2082 event.envelope.get("event").and_then(Value::as_str) == Some("runtime.catalog")
2083 && event
2084 .envelope
2085 .get("source_event_id")
2086 .and_then(Value::as_str)
2087 == Some(source_event_id)
2088 })
2089 })
2090 }
2091
2092 fn upload_runtime_chat_projection(
2093 &mut self,
2094 projection: &RuntimeChatProjection,
2095 ) -> Result<(), String> {
2096 if self.attached_run_id.as_deref() != Some(projection.run_id.as_str()) {
2097 // A reconnect can change the single attached run after the worker
2098 // claimed an event from the previous run. Leave that durable
2099 // backlog with its rightful run instead of delivering it across
2100 // authority or poisoning the new attachment.
2101 self.runtime_chat
2102 .as_ref()
2103 .ok_or_else(|| "This Runtime Chat relay is not configured.".to_string())?
2104 .release_projection(&projection.native_thread_id, projection.native_seq);
2105 return Ok(());
2106 }
2107 let seq = self.next_runtime_seq(&projection.run_id);
2108 let envelope = runtime_chat_envelope(
2109 seq,
2110 projection.event,
2111 Some(&projection.virtual_thread_id),
2112 Some(&projection.virtual_turn_id),
2113 Some(&projection.source_event_id),
2114 projection.timestamp.clone(),
2115 projection.payload.clone(),
2116 );
2117 if !self.queue_runtime_chat_envelope(&projection.run_id, envelope) {
2118 return Err("Runtime Chat could not journal its native turn event.".to_string());
2119 }
2120 self.runtime_chat
2121 .as_ref()
2122 .ok_or_else(|| "This Runtime Chat relay is not configured.".to_string())?
2123 .mark_projected(
2124 &projection.native_thread_id,
2125 projection.native_seq,
2126 &projection.virtual_turn_id,
2127 projection.event,
2128 )
2129 }
2130
2131 pub fn upload_snapshot(&mut self, run_id: &str, messages: &[Message]) {
2132 if self.uploaded_snapshots.contains(run_id) {
2133 return;
2134 }
2135 let seq = self.next_runtime_seq(run_id);
2136 let envelope = bounded_session_snapshot_envelope(seq, messages);
2137 if self.queue_runtime_envelope(run_id, envelope) {
2138 self.uploaded_snapshots.insert(run_id.to_string());
2139 }
2140 }
2141
2142 pub fn acknowledge(
2143 &self,
2144 run_id: &str,
2145 seq: u64,
2146 command: &RemoteCommand,
2147 status: &str,
2148 error: Option<String>,
2149 ) {
2150 let Some(tx) = &self.worker_tx else {
2151 return;
2152 };
2153 let _ = tx.send(WorkerCommand::Upload {
2154 run_id: run_id.to_string(),
2155 acknowledgements: vec![CommandAcknowledgement {
2156 command_seq: seq,
2157 command_type: command.kind().to_string(),
2158 status: status.to_string(),
2159 turn_id: command.turn_id().map(ToString::to_string),
2160 error: error.map(|value| value.chars().take(800).collect()),
2161 }],
2162 envelopes: Vec::new(),
2163 });
2164 }
2165
2166 pub(crate) async fn apply_runtime_chat_prompt(
2167 &self,
2168 command: &RuntimeChatPrompt,
2169 ) -> Result<(), String> {
2170 if self.status != Status::Connected
2171 || self.attached_run_id.as_deref() != Some(command.run_id.as_str())
2172 || self.attached_workspace_id.as_deref() != Some(command.workspace.id.as_str())
2173 || self.target_ref.as_deref() != Some(command.workspace.target_ref.as_str())
2174 {
2175 return Err("The Runtime Chat command does not match the attached run.".to_string());
2176 }
2177 let host = self
2178 .runtime_chat
2179 .clone()
2180 .ok_or_else(|| "This Runtime Chat relay is not configured.".to_string())?;
2181 if self.pending_runtime_chat_configuration.is_some()
2182 && !host.is_exact_prompt_replay(command)?
2183 {
2184 return Err("Runtime Chat is refreshing its local model configuration.".to_string());
2185 }
2186 host.apply_prompt(command).await
2187 }
2188
2189 pub(crate) async fn interrupt_runtime_chat(
2190 &self,
2191 run_id: &str,
2192 scope: &RuntimeChatControlScope,
2193 virtual_turn_id: &str,
2194 ) -> Result<(), String> {
2195 if self.status != Status::Connected || self.attached_run_id.as_deref() != Some(run_id) {
2196 return Err("The Runtime Chat interrupt does not match the attached run.".to_string());
2197 }
2198 let host = self
2199 .runtime_chat
2200 .clone()
2201 .ok_or_else(|| "This Runtime Chat relay is not configured.".to_string())?;
2202 host.interrupt(run_id, scope, virtual_turn_id).await
2203 }
2204
2205 pub fn record_remote_approval(
2206 &mut self,
2207 tool_id: &str,
2208 tool_name: &str,
2209 description: &str,
2210 _input: &Value,
2211 _approval_key: &str,
2212 _intent_summary: Option<&str>,
2213 ) -> String {
2214 let gate = projected_approval_id(tool_id);
2215 self.pending_approvals.insert(
2216 gate.clone(),
2217 PendingRemoteApproval {
2218 tool_id: tool_id.to_string(),
2219 },
2220 );
2221 if let Some(active) = self.active_run.clone() {
2222 self.upload_envelope(
2223 &active.run_id,
2224 "approval.required",
2225 Some(&active.turn_id),
2226 json!({
2227 "id": gate,
2228 "approval_id": gate,
2229 "tool_name": tool_name,
2230 "description": description,
2231 }),
2232 );
2233 }
2234 gate
2235 }
2236
2237 pub fn take_pending_approval(&mut self, gate: &str) -> Option<String> {
2238 self.pending_approvals
2239 .remove(gate)
2240 .map(|approval| approval.tool_id)
2241 }
2242
2243 pub fn observe_engine_event(&mut self, event: &EngineEvent) {
2244 // `/rc` can attach while the host is still preparing a turn. The
2245 // server run is known first; `TurnStarted` supplies the authoritative
2246 // runtime turn id later. Promote exactly once, before the ordinary
2247 // projection below observes the event.
2248 if let EngineEvent::TurnStarted { turn_id, .. } = event
2249 && self.active_run.is_none()
2250 && let Some(run_id) = self.pending_local_turn_run.take()
2251 && self.promote_pending_local_turn(&run_id, turn_id).is_err()
2252 {
2253 self.pending_local_turn_run = Some(run_id);
2254 return;
2255 }
2256 let Some(active) = self.active_run.clone() else {
2257 return;
2258 };
2259 match event {
2260 EngineEvent::MessageDelta { content, .. } => {
2261 self.upload_delta(&active.run_id, &active.turn_id, content);
2262 }
2263 EngineEvent::ToolCallStarted { id, name, .. } => {
2264 self.upload_envelope(
2265 &active.run_id,
2266 "item.started",
2267 Some(&active.turn_id),
2268 json!({ "tool": { "id": id, "name": name, "input": {} } }),
2269 );
2270 }
2271 EngineEvent::ToolCallComplete { id, result, .. } => {
2272 let (event_name, status) = if result.is_ok() {
2273 ("item.completed", "completed")
2274 } else {
2275 ("item.failed", "failed")
2276 };
2277 self.upload_envelope(
2278 &active.run_id,
2279 event_name,
2280 Some(&active.turn_id),
2281 json!({
2282 "item": {
2283 "id": id,
2284 "kind": "tool_call",
2285 "status": status,
2286 "summary": "",
2287 "detail": "",
2288 }
2289 }),
2290 );
2291 }
2292 EngineEvent::TurnStarted { turn_id, route, .. } => {
2293 self.active_run = Some(ActiveRelayRun {
2294 run_id: active.run_id.clone(),
2295 turn_id: turn_id.clone(),
2296 });
2297 self.upload_envelope(
2298 &active.run_id,
2299 "turn.started",
2300 Some(turn_id),
2301 json!({
2302 "turn": {
2303 "model": route.as_ref().map(|value| value.model.as_str()).unwrap_or(""),
2304 "mode": "",
2305 }
2306 }),
2307 );
2308 }
2309 EngineEvent::TurnComplete { usage, status, .. } => {
2310 let status = match status {
2311 TurnOutcomeStatus::Completed => "completed",
2312 TurnOutcomeStatus::Interrupted => "interrupted",
2313 TurnOutcomeStatus::Failed => "failed",
2314 };
2315 let terminal_durable = self.upload_envelope(
2316 &active.run_id,
2317 "turn.completed",
2318 Some(&active.turn_id),
2319 json!({ "turn": { "status": status, "usage": usage } }),
2320 );
2321 if terminal_durable && self.finish_classic_lease().is_ok() {
2322 if self.resync_required.remove(&active.run_id) {
2323 // Deltas were shed under pressure during this turn; the UI
2324 // must now upload a bounded current snapshot so account
2325 // truth is restored at the terminal boundary.
2326 self.resync_ready.push(active.run_id.clone());
2327 }
2328 self.active_run = None;
2329 self.pending_local_turn_run = None;
2330 }
2331 }
2332 EngineEvent::Error {
2333 envelope,
2334 recoverable,
2335 } if !recoverable => {
2336 self.fail_active_run(&envelope.code, &envelope.message);
2337 }
2338 _ => {}
2339 }
2340 }
2341
2342 fn fail_active_run(&mut self, code: &str, error: &str) {
2343 let Some(active) = self.active_run.clone() else {
2344 return;
2345 };
2346 let message = bounded_remote_error_message(error);
2347 let item_id = projected_error_item_id(&active.run_id, &active.turn_id, code);
2348 let item_durable = self.upload_envelope(
2349 &active.run_id,
2350 "item.failed",
2351 Some(&active.turn_id),
2352 json!({
2353 "item": {
2354 "id": item_id,
2355 "kind": "error",
2356 "status": "failed",
2357 "summary": message,
2358 "detail": message,
2359 }
2360 }),
2361 );
2362 let terminal_durable = self.upload_envelope(
2363 &active.run_id,
2364 "turn.completed",
2365 Some(&active.turn_id),
2366 json!({ "turn": { "status": "failed", "usage": {} } }),
2367 );
2368 if item_durable && terminal_durable && self.finish_classic_lease().is_ok() {
2369 self.active_run = None;
2370 self.pending_local_turn_run = None;
2371 }
2372 }
2373
2374 fn upload_envelope(
2375 &mut self,
2376 run_id: &str,
2377 event: &str,
2378 turn_id: Option<&str>,
2379 payload: Value,
2380 ) -> bool {
2381 let prior_status = self.status;
2382 let prior_status_detail = self.status_detail.clone();
2383 let prior_ownership_blocked_until = self.ownership_blocked_until;
2384 let seq = self.next_runtime_seq(run_id);
2385 let envelope = runtime_envelope(
2386 seq,
2387 event,
2388 turn_id,
2389 chrono::Utc::now().to_rfc3339(),
2390 payload,
2391 );
2392 if self.queue_runtime_envelope(run_id, envelope) {
2393 return true;
2394 }
2395 if integrity_critical_event(event)
2396 && self
2397 .journal
2398 .as_ref()
2399 .is_some_and(|journal| journal.persist(&self.pending_runtime_events).is_ok())
2400 {
2401 // Atomic replacement can fail transiently (sharing/AV/power-loss
2402 // boundary). The exact envelope is still retained in memory;
2403 // make one immediate durable retry before returning to UI code
2404 // that may clear an approval or terminal lease. Only after that
2405 // retry succeeds may transport handoff occur.
2406 if self.status == Status::Failed
2407 && self.status_detail
2408 == "Remote control could not durably journal its account event."
2409 {
2410 self.status = prior_status;
2411 self.status_detail = prior_status_detail;
2412 self.ownership_blocked_until = prior_ownership_blocked_until;
2413 }
2414 self.flush_pending_runtime_events(run_id);
2415 return true;
2416 }
2417 false
2418 }
2419
2420 fn next_runtime_seq(&self, run_id: &str) -> u64 {
2421 let acknowledged = self.event_seq.get(run_id).copied().unwrap_or(0);
2422 let pending = self
2423 .pending_runtime_events
2424 .get(run_id)
2425 .and_then(|events| events.last_key_value().map(|(seq, _)| *seq))
2426 .unwrap_or(0);
2427 acknowledged.max(pending).saturating_add(1)
2428 }
2429
2430 fn runtime_seq_floor(&self, run_id: &str) -> u64 {
2431 self.next_runtime_seq(run_id).saturating_sub(1)
2432 }
2433
2434 fn queue_runtime_envelope(&mut self, run_id: &str, envelope: Value) -> bool {
2435 // Per-run sequence order must reach the transport in order, so any
2436 // deferred delta is handed off before a later envelope is queued.
2437 // Persist the new sequence before transport handoff. This gives the
2438 // ordinary mirrored Work path the same crash boundary as Runtime Chat:
2439 // a send may be replayed after an ambiguous crash, but an accepted
2440 // terminal/approval/failure row is never send-first and then lost.
2441 self.hand_off_deferred(run_id);
2442 self.queue_runtime_envelope_inner(run_id, envelope, false, true, false)
2443 }
2444
2445 fn queue_runtime_chat_envelope(&mut self, run_id: &str, envelope: Value) -> bool {
2446 // Native Chat advances its own durable event cursor only after this
2447 // account-delivery journal is durable. Persist before handing the
2448 // envelope to transport; a resend after an ambiguous send is safe via
2449 // the stable source_event_id contract.
2450 self.hand_off_deferred(run_id);
2451 self.queue_runtime_envelope_inner(run_id, envelope, false, true, true)
2452 }
2453
2454 fn queue_runtime_envelope_inner(
2455 &mut self,
2456 run_id: &str,
2457 envelope: Value,
2458 defer: bool,
2459 require_durable_journal: bool,
2460 rollback_on_journal_failure: bool,
2461 ) -> bool {
2462 if require_durable_journal && self.journal.is_none() {
2463 self.status_detail =
2464 "Remote control requires a durable account-delivery journal.".to_string();
2465 self.status = Status::Failed;
2466 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
2467 return false;
2468 }
2469 let Some(seq) = runtime_envelope_seq(&envelope) else {
2470 self.status_detail = "a local runtime event had no valid sequence".to_string();
2471 return false;
2472 };
2473 let encoded_len = serde_json::to_vec(&envelope)
2474 .map(|body| body.len())
2475 .unwrap_or(usize::MAX);
2476 if encoded_len > MAX_RUNTIME_ENVELOPE_BYTES {
2477 self.status_detail = "a local runtime event exceeded the safe relay limit".to_string();
2478 return false;
2479 }
2480 let integrity = runtime_envelope_event(&envelope).is_some_and(integrity_critical_event);
2481 let already_pending = self
2482 .pending_runtime_events
2483 .get(run_id)
2484 .and_then(|events| events.get(&seq))
2485 .is_some();
2486 let previous_event_seq = self.event_seq.get(run_id).copied();
2487 if already_pending {
2488 let entry = self
2489 .pending_runtime_events
2490 .get(run_id)
2491 .and_then(|events| events.get(&seq))
2492 .expect("checked above");
2493 if entry.envelope != envelope {
2494 self.status_detail =
2495 "a local runtime sequence changed before acknowledgement".to_string();
2496 return false;
2497 }
2498 } else {
2499 if !self.reserve_capacity(run_id, encoded_len, integrity) {
2500 return false;
2501 }
2502 self.pending_runtime_events
2503 .entry(run_id.to_string())
2504 .or_default()
2505 .insert(
2506 seq,
2507 PendingRuntimeEnvelope {
2508 envelope: envelope.clone(),
2509 encoded_len,
2510 integrity,
2511 handed_off: false,
2512 },
2513 );
2514 self.pending_event_count += 1;
2515 self.pending_encoded_bytes = self.pending_encoded_bytes.saturating_add(encoded_len);
2516 }
2517 self.event_seq
2518 .entry(run_id.to_string())
2519 .and_modify(|cursor| *cursor = (*cursor).max(seq))
2520 .or_insert(seq);
2521 if require_durable_journal
2522 && self
2523 .journal
2524 .as_ref()
2525 .expect("checked above")
2526 .persist(&self.pending_runtime_events)
2527 .is_err()
2528 {
2529 if rollback_on_journal_failure && !already_pending {
2530 let remove_run = if let Some(events) = self.pending_runtime_events.get_mut(run_id) {
2531 events.remove(&seq);
2532 events.is_empty()
2533 } else {
2534 false
2535 };
2536 if remove_run {
2537 self.pending_runtime_events.remove(run_id);
2538 }
2539 self.pending_event_count = self.pending_event_count.saturating_sub(1);
2540 self.pending_encoded_bytes = self.pending_encoded_bytes.saturating_sub(encoded_len);
2541 }
2542 if rollback_on_journal_failure {
2543 match previous_event_seq {
2544 Some(cursor) => {
2545 self.event_seq.insert(run_id.to_string(), cursor);
2546 }
2547 None => {
2548 self.event_seq.remove(run_id);
2549 }
2550 }
2551 }
2552 self.status_detail =
2553 "Remote control could not durably journal its account event.".to_string();
2554 self.status = Status::Failed;
2555 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
2556 return false;
2557 }
2558 if defer {
2559 self.deferred_delta.insert(run_id.to_string(), seq);
2560 } else {
2561 if let Some(entry) = self
2562 .pending_runtime_events
2563 .get_mut(run_id)
2564 .and_then(|events| events.get_mut(&seq))
2565 {
2566 entry.handed_off = true;
2567 }
2568 self.send_runtime_envelope(run_id, envelope);
2569 if !require_durable_journal {
2570 self.persist_journal();
2571 }
2572 }
2573 true
2574 }
2575
2576 /// Bounded-journal admission control.
2577 ///
2578 /// Integrity-critical envelopes may use the full budget, ordinary deltas
2579 /// only the unreserved share. A shed delta marks the run for terminal-
2580 /// boundary resynchronization; a shed integrity envelope can never happen
2581 /// silently — the relay fails closed and local input stays locked through
2582 /// the server lease expiry.
2583 fn reserve_capacity(&mut self, run_id: &str, encoded_len: usize, integrity: bool) -> bool {
2584 let (event_budget, byte_budget) = if integrity {
2585 (MAX_JOURNAL_EVENTS, MAX_JOURNAL_ENCODED_BYTES)
2586 } else {
2587 (
2588 MAX_JOURNAL_EVENTS - JOURNAL_RESERVED_INTEGRITY_EVENTS,
2589 MAX_JOURNAL_ENCODED_BYTES - JOURNAL_RESERVED_INTEGRITY_BYTES,
2590 )
2591 };
2592 if self.pending_event_count < event_budget
2593 && self.pending_encoded_bytes.saturating_add(encoded_len) <= byte_budget
2594 {
2595 return true;
2596 }
2597 if integrity {
2598 self.status = Status::Failed;
2599 self.status_detail =
2600 "the runtime delivery buffer overflowed; waiting for the last server lease to expire safely"
2601 .to_string();
2602 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
2603 } else {
2604 self.resync_required.insert(run_id.to_string());
2605 }
2606 false
2607 }
2608
2609 /// Streams a message delta, coalescing into the run's deferred envelope
2610 /// while that envelope has provably never been handed to the transport.
2611 fn upload_delta(&mut self, run_id: &str, turn_id: &str, content: &str) {
2612 if let Some(seq) = self.deferred_delta.get(run_id).copied() {
2613 if self.try_coalesce_delta(run_id, seq, turn_id, content) {
2614 return;
2615 }
2616 self.hand_off_deferred(run_id);
2617 }
2618 let seq = self.next_runtime_seq(run_id);
2619 let envelope = runtime_envelope(
2620 seq,
2621 "item.delta",
2622 Some(turn_id),
2623 chrono::Utc::now().to_rfc3339(),
2624 json!({ "kind": "agent_message", "delta": content }),
2625 );
2626 self.queue_runtime_envelope_inner(run_id, envelope, true, false, false);
2627 }
2628
2629 fn try_coalesce_delta(&mut self, run_id: &str, seq: u64, turn_id: &str, content: &str) -> bool {
2630 let Some(entry) = self
2631 .pending_runtime_events
2632 .get_mut(run_id)
2633 .and_then(|events| events.get_mut(&seq))
2634 else {
2635 return false;
2636 };
2637 if entry.handed_off
2638 || entry.envelope.get("turn_id").and_then(Value::as_str) != Some(turn_id)
2639 {
2640 return false;
2641 }
2642 let Some(existing) = entry
2643 .envelope
2644 .pointer("/payload/delta")
2645 .and_then(Value::as_str)
2646 else {
2647 return false;
2648 };
2649 let merged = format!("{existing}{content}");
2650 let mut candidate = entry.envelope.clone();
2651 candidate["payload"]["delta"] = Value::String(merged);
2652 let encoded_len = serde_json::to_vec(&candidate)
2653 .map(|body| body.len())
2654 .unwrap_or(usize::MAX);
2655 if encoded_len > DELTA_COALESCE_BYTE_CAP {
2656 return false;
2657 }
2658 let old_len = entry.encoded_len;
2659 entry.envelope = candidate;
2660 entry.encoded_len = encoded_len;
2661 self.pending_encoded_bytes = self
2662 .pending_encoded_bytes
2663 .saturating_sub(old_len)
2664 .saturating_add(encoded_len);
2665 true
2666 }
2667
2668 /// Hands the run's deferred delta to the transport. From this point the
2669 /// envelope may have reached the server and becomes immutable.
2670 fn hand_off_deferred(&mut self, run_id: &str) {
2671 let Some(seq) = self.deferred_delta.get(run_id).copied() else {
2672 return;
2673 };
2674 let Some(journal) = &self.journal else {
2675 self.status = Status::Failed;
2676 self.status_detail =
2677 "Remote control requires a durable account-delivery journal.".to_string();
2678 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
2679 return;
2680 };
2681 if journal.persist(&self.pending_runtime_events).is_err() {
2682 self.status = Status::Failed;
2683 self.status_detail =
2684 "Remote control could not durably journal its account event.".to_string();
2685 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
2686 return;
2687 }
2688 self.deferred_delta.remove(run_id);
2689 let Some(envelope) = self
2690 .pending_runtime_events
2691 .get_mut(run_id)
2692 .and_then(|events| events.get_mut(&seq))
2693 .map(|entry| {
2694 entry.handed_off = true;
2695 entry.envelope.clone()
2696 })
2697 else {
2698 return;
2699 };
2700 self.send_runtime_envelope(run_id, envelope);
2701 }
2702
2703 fn hand_off_all_deferred(&mut self) {
2704 let runs: Vec<String> = self.deferred_delta.keys().cloned().collect();
2705 for run_id in runs {
2706 self.hand_off_deferred(&run_id);
2707 }
2708 }
2709
2710 /// The UI drains this after each engine event batch and answers with
2711 /// `upload_resync_snapshot` for the returned run.
2712 pub fn take_pending_resync(&mut self) -> Option<String> {
2713 self.resync_ready.pop()
2714 }
2715
2716 /// Uploads a bounded current-history snapshot to repair account truth
2717 /// after deltas were shed under pressure.
2718 pub fn upload_resync_snapshot(&mut self, run_id: &str, messages: &[Message]) {
2719 let seq = self.next_runtime_seq(run_id);
2720 let envelope = bounded_session_snapshot_envelope(seq, messages);
2721 self.queue_runtime_envelope(run_id, envelope);
2722 }
2723
2724 fn persist_journal(&mut self) {
2725 let Some(journal) = &self.journal else {
2726 return;
2727 };
2728 if journal.persist(&self.pending_runtime_events).is_err() {
2729 // Crash durability is degraded, but nothing is lost silently: the
2730 // live relay keeps every envelope in memory and `/rc stop` still
2731 // requires the server-confirmed drain.
2732 self.status_detail =
2733 "the delivery journal could not be written; stop waits for server confirmation"
2734 .to_string();
2735 }
2736 }
2737
2738 /// Replaces the in-memory pending set from a verified journal load.
2739 fn reset_pending_from(&mut self, restored: HashMap<String, BTreeMap<u64, Value>>) {
2740 self.pending_runtime_events.clear();
2741 self.deferred_delta.clear();
2742 self.pending_event_count = 0;
2743 self.pending_encoded_bytes = 0;
2744 for (run_id, events) in restored {
2745 let mut pending = BTreeMap::new();
2746 for (seq, envelope) in events {
2747 let encoded_len = serde_json::to_vec(&envelope)
2748 .map(|body| body.len())
2749 .unwrap_or(usize::MAX);
2750 let integrity =
2751 runtime_envelope_event(&envelope).is_some_and(integrity_critical_event);
2752 self.pending_event_count += 1;
2753 self.pending_encoded_bytes = self.pending_encoded_bytes.saturating_add(encoded_len);
2754 pending.insert(
2755 seq,
2756 PendingRuntimeEnvelope {
2757 envelope,
2758 encoded_len,
2759 integrity,
2760 handed_off: false,
2761 },
2762 );
2763 }
2764 if let Some((top, _)) = pending.last_key_value() {
2765 let top = *top;
2766 self.event_seq
2767 .entry(run_id.clone())
2768 .and_modify(|cursor| *cursor = (*cursor).max(top))
2769 .or_insert(top);
2770 }
2771 if !pending.is_empty() {
2772 self.pending_runtime_events.insert(run_id, pending);
2773 }
2774 }
2775 }
2776
2777 fn send_runtime_envelope(&self, run_id: &str, envelope: Value) {
2778 let Some(tx) = &self.worker_tx else {
2779 return;
2780 };
2781 let _ = tx.send(WorkerCommand::Upload {
2782 run_id: run_id.to_string(),
2783 acknowledgements: Vec::new(),
2784 envelopes: vec![envelope],
2785 });
2786 }
2787
2788 fn flush_pending_runtime_events(&mut self, run_id: &str) {
2789 // A reconnect resend covers everything, deferred deltas included;
2790 // after this every envelope may have reached the server.
2791 self.deferred_delta.remove(run_id);
2792 let mut to_send = Vec::new();
2793 if let Some(events) = self.pending_runtime_events.get_mut(run_id) {
2794 for entry in events.values_mut() {
2795 entry.handed_off = true;
2796 to_send.push(entry.envelope.clone());
2797 }
2798 }
2799 if to_send.is_empty() {
2800 return;
2801 }
2802 for envelope in to_send {
2803 self.send_runtime_envelope(run_id, envelope);
2804 }
2805 self.persist_journal();
2806 }
2807
2808 fn flush_all_pending(&mut self) {
2809 let runs: Vec<String> = self.pending_runtime_events.keys().cloned().collect();
2810 for run_id in runs {
2811 self.flush_pending_runtime_events(&run_id);
2812 }
2813 }
2814
2815 fn reconcile_runtime_cursor(&mut self, run_id: &str, cursor: u64) {
2816 if cursor > JS_MAX_SAFE_INTEGER {
2817 self.status_detail = "the server returned an unsafe runtime cursor".to_string();
2818 return;
2819 }
2820 let covered_classic_terminal = self
2821 .journal
2822 .as_ref()
2823 .and_then(RuntimeEventJournal::classic_lease)
2824 .filter(|lease| lease.run_id == run_id)
2825 .is_some_and(|lease| {
2826 let turn_id = lease
2827 .turn_id
2828 .clone()
2829 .unwrap_or_else(|| classic_recovery_turn_id(&lease.run_id, &lease.lease_id));
2830 self.pending_runtime_events
2831 .get(run_id)
2832 .is_some_and(|events| {
2833 events.range(..=cursor).any(|(seq, entry)| {
2834 *seq > lease.seq_floor
2835 && runtime_envelope_event(&entry.envelope) == Some("turn.completed")
2836 && entry.envelope.get("turn_id").and_then(Value::as_str)
2837 == Some(turn_id.as_str())
2838 })
2839 })
2840 });
2841 let classic_state_result = if covered_classic_terminal {
2842 self.journal
2843 .as_ref()
2844 .expect("lease came from this journal")
2845 .set_classic_lease(None, &self.pending_runtime_events)
2846 } else if let Some(journal) = &self.journal {
2847 journal.advance_classic_seq_floor(run_id, cursor)
2848 } else {
2849 Ok(())
2850 };
2851 if let Err(error) = classic_state_result {
2852 // Never compact the only durable evidence of the run's sequence
2853 // history unless the canonical active lease first records the
2854 // acknowledged floor. A reconnect can retry this exact cursor.
2855 self.status = Status::Failed;
2856 self.status_detail = error;
2857 self.ownership_blocked_until = Some(Instant::now() + OWNERSHIP_LOCK_AFTER_FAILURE);
2858 return;
2859 }
2860 if covered_classic_terminal {
2861 self.active_run = None;
2862 self.pending_local_turn_run = None;
2863 }
2864 let mut empty = false;
2865 let mut retired_any = false;
2866 if let Some(events) = self.pending_runtime_events.get_mut(run_id) {
2867 let retired: Vec<u64> = events.range(..=cursor).map(|(seq, _)| *seq).collect();
2868 for seq in retired {
2869 if let Some(entry) = events.remove(&seq) {
2870 retired_any = true;
2871 self.pending_event_count = self.pending_event_count.saturating_sub(1);
2872 self.pending_encoded_bytes =
2873 self.pending_encoded_bytes.saturating_sub(entry.encoded_len);
2874 }
2875 }
2876 empty = events.is_empty();
2877 }
2878 if empty {
2879 self.pending_runtime_events.remove(run_id);
2880 }
2881 if self
2882 .deferred_delta
2883 .get(run_id)
2884 .is_some_and(|seq| *seq <= cursor)
2885 {
2886 self.deferred_delta.remove(run_id);
2887 }
2888 self.event_seq
2889 .entry(run_id.to_string())
2890 .and_modify(|known| *known = (*known).max(cursor))
2891 .or_insert(cursor);
2892 if retired_any {
2893 // Compact the acknowledged prefix out of the journal promptly.
2894 self.persist_journal();
2895 }
2896 // Runtime Chat retains the exclusive provider-request lease through
2897 // local terminal projection *and* this server-confirmed cursor. Only
2898 // then may interactive/autonomous provider work resume for the
2899 // attached CWC run. Catalog/approval integrity rows conservatively
2900 // keep the same gate closed until their own acknowledgement as well.
2901 if !self.has_unacknowledged_integrity_events()
2902 && let Some(host) = &self.runtime_chat
2903 {
2904 host.release_inference_ownership_if_settled();
2905 }
2906 }
2907 }
2908
2909 impl Drop for RemoteControlController {
2910 fn drop(&mut self) {
2911 self.stop_worker();
2912 }
2913 }
2914
2915 impl RemoteCommand {
2916 fn kind(&self) -> &'static str {
2917 match self {
2918 Self::Prompt { .. } | Self::RuntimeChatPrompt(_) => "prompt.request",
2919 Self::Approval { .. } => "approval.decision",
2920 Self::Control { .. } => "run.control",
2921 }
2922 }
2923
2924 fn turn_id(&self) -> Option<&str> {
2925 match self {
2926 Self::Prompt { turn_id, .. } => Some(turn_id),
2927 Self::RuntimeChatPrompt(prompt) => Some(&prompt.turn_id),
2928 Self::Control { turn_id, .. } => turn_id.as_deref(),
2929 Self::Approval { .. } => None,
2930 }
2931 }
2932
2933 fn is_runtime_chat(&self) -> bool {
2934 matches!(
2935 self,
2936 Self::RuntimeChatPrompt(_)
2937 | Self::Control {
2938 runtime_chat: Some(_),
2939 ..
2940 }
2941 )
2942 }
2943 }
2944
2945 /// Opaque identity for the enrolled folder. It is a hash of the workspace
2946 /// path only: every session opened in the same folder shares one target, so
2947 /// the control plane sees one grant per folder rather than one per session
2948 /// (the session itself travels separately as `sessionRef`).
2949 pub fn target_ref(workspace: &Path) -> String {
2950 let mut hasher = Sha256::new();
2951 hasher.update(b"codewhale-remote-target:v2\0");
2952 hasher.update(workspace.to_string_lossy().as_bytes());
2953 format!("target_{}", &bytes_to_hex(&hasher.finalize())[..32])
2954 }
2955
2956 /// Status-bar banner shown while the web owns this session. When the control
2957 /// plane advertised a session link the banner leads with it; otherwise it
2958 /// falls back to the opaque account and runner receipts.
2959 pub fn remote_control_banner(account_ref: &str, runner_id: &str, run_url: Option<&str>) -> String {
2960 match run_url {
2961 Some(url) => format!("WEB MIRROR · {url} · /rc stop"),
2962 None => format!("WEB MIRROR · account {account_ref} · runner {runner_id} · /rc stop"),
2963 }
2964 }
2965
2966 /// Transcript note announcing where the live session can be followed.
2967 pub fn remote_control_link_notice(run_url: &str) -> String {
2968 format!(
2969 "Remote control is live at {run_url} — run /rc open to open it in your browser, or /rc link to print it. Both surfaces stay usable; one turn runs at a time."
2970 )
2971 }
2972
2973 fn runtime_envelope(
2974 seq: u64,
2975 event: &str,
2976 turn_id: Option<&str>,
2977 timestamp: String,
2978 payload: Value,
2979 ) -> Value {
2980 json!({
2981 "schema_version": 1,
2982 "seq": seq,
2983 "event": event,
2984 "kind": event,
2985 "turn_id": turn_id,
2986 "timestamp": timestamp,
2987 "payload": payload,
2988 })
2989 }
2990
2991 fn runtime_chat_envelope(
2992 seq: u64,
2993 event: &str,
2994 thread_id: Option<&str>,
2995 turn_id: Option<&str>,
2996 source_event_id: Option<&str>,
2997 timestamp: String,
2998 payload: Value,
2999 ) -> Value {
3000 let mut envelope = json!({
3001 "schema_version": 2,
3002 "seq": seq,
3003 "event": event,
3004 "kind": event,
3005 "timestamp": timestamp,
3006 "payload": payload,
3007 });
3008 if let Some(thread_id) = thread_id {
3009 envelope["thread_id"] = json!(thread_id);
3010 }
3011 if let Some(turn_id) = turn_id {
3012 envelope["turn_id"] = json!(turn_id);
3013 }
3014 if let Some(source_event_id) = source_event_id {
3015 envelope["source_event_id"] = json!(source_event_id);
3016 }
3017 envelope
3018 }
3019
3020 fn runtime_chat_catalog_source_event_id(run_id: &str, payload_fingerprint: &str) -> String {
3021 let mut hasher = Sha256::new();
3022 hasher.update(b"codewhale.runtime-chat-catalog.v1\0");
3023 hasher.update(run_id.as_bytes());
3024 hasher.update(b"\0");
3025 hasher.update(payload_fingerprint.as_bytes());
3026 format!("catalog_{}", bytes_to_hex(&hasher.finalize()))
3027 }
3028
3029 fn runtime_envelope_seq(envelope: &Value) -> Option<u64> {
3030 envelope
3031 .get("seq")
3032 .and_then(Value::as_u64)
3033 .filter(|seq| (1..=JS_MAX_SAFE_INTEGER).contains(seq))
3034 }
3035
3036 fn bounded_session_snapshot_envelope(seq: u64, messages: &[Message]) -> Value {
3037 let timestamp = chrono::Utc::now().to_rfc3339();
3038 let candidates = messages
3039 .iter()
3040 .rev()
3041 .filter_map(project_session_message)
3042 .take(MAX_SNAPSHOT_MESSAGES)
3043 .collect::<Vec<_>>();
3044 let mut kept = Vec::<Value>::new();
3045 for (role, text) in candidates {
3046 let full = json!({ "role": role, "text": text });
3047 kept.insert(0, full);
3048 if snapshot_envelope_len(seq, &timestamp, &kept) <= SNAPSHOT_ENVELOPE_BYTE_BUDGET {
3049 continue;
3050 }
3051 kept.remove(0);
3052 let max_chars = text.chars().count();
3053 let mut low = 0usize;
3054 let mut high = max_chars;
3055 while low < high {
3056 let mid = low + (high - low).div_ceil(2);
3057 let prefix = unicode_prefix(&text, mid);
3058 kept.insert(0, json!({ "role": role, "text": prefix }));
3059 let fits =
3060 snapshot_envelope_len(seq, &timestamp, &kept) <= SNAPSHOT_ENVELOPE_BYTE_BUDGET;
3061 kept.remove(0);
3062 if fits {
3063 low = mid;
3064 } else {
3065 high = mid - 1;
3066 }
3067 }
3068 if low >= MIN_TRUNCATED_MESSAGE_CHARS || (kept.is_empty() && low > 0) {
3069 kept.insert(
3070 0,
3071 json!({ "role": role, "text": unicode_prefix(&text, low) }),
3072 );
3073 }
3074 break;
3075 }
3076 let envelope = runtime_envelope(
3077 seq,
3078 "session.snapshot",
3079 None,
3080 timestamp,
3081 json!({ "messages": kept }),
3082 );
3083 debug_assert!(
3084 serde_json::to_vec(&envelope).is_ok_and(|body| body.len() <= SNAPSHOT_ENVELOPE_BYTE_BUDGET)
3085 );
3086 envelope
3087 }
3088
3089 fn project_session_message(message: &Message) -> Option<(String, String)> {
3090 let role = match message.role.as_str() {
3091 "user" => "user",
3092 "assistant" => "assistant",
3093 _ => return None,
3094 };
3095 let text = message
3096 .content
3097 .iter()
3098 .filter_map(|block| match block {
3099 ContentBlock::Text { text, .. } => Some(text.as_str()),
3100 _ => None,
3101 })
3102 .collect::<Vec<_>>()
3103 .join("\n");
3104 if text.trim().is_empty() {
3105 return None;
3106 }
3107 Some((
3108 role.to_string(),
3109 text.chars()
3110 .take(MAX_SNAPSHOT_MESSAGE_CHARS)
3111 .collect::<String>(),
3112 ))
3113 }
3114
3115 fn snapshot_envelope_len(seq: u64, timestamp: &str, messages: &[Value]) -> usize {
3116 serde_json::to_vec(&runtime_envelope(
3117 seq,
3118 "session.snapshot",
3119 None,
3120 timestamp.to_string(),
3121 json!({ "messages": messages }),
3122 ))
3123 .map(|body| body.len())
3124 .unwrap_or(usize::MAX)
3125 }
3126
3127 fn unicode_prefix(value: &str, chars: usize) -> String {
3128 value.chars().take(chars).collect()
3129 }
3130
3131 fn projected_approval_id(raw: &str) -> String {
3132 let mut hasher = Sha256::new();
3133 hasher.update(b"local-runtime:approval\0");
3134 hasher.update(raw.as_bytes());
3135 format!("local_approval_{}", &bytes_to_hex(&hasher.finalize())[..24])
3136 }
3137
3138 /// Whether this view is the approval card for exactly `gate` (the projected
3139 /// approval id). Used by the web mirror to dismiss the matching card — never
3140 /// an unrelated approval that happens to be on top.
3141 pub(crate) fn view_is_approval_for_gate(
3142 view: &dyn crate::tui::views::ModalView,
3143 gate: &str,
3144 ) -> bool {
3145 view.kind() == crate::tui::views::ModalKind::Approval
3146 && view
3147 .approval_request_id()
3148 .is_some_and(|tool_id| projected_approval_id(tool_id) == gate)
3149 }
3150
3151 fn projected_error_item_id(run_id: &str, turn_id: &str, code: &str) -> String {
3152 let mut hasher = Sha256::new();
3153 hasher.update(b"local-runtime:error\0");
3154 hasher.update(run_id.as_bytes());
3155 hasher.update(b"\0");
3156 hasher.update(turn_id.as_bytes());
3157 hasher.update(b"\0");
3158 hasher.update(code.as_bytes());
3159 format!("local_item_{}", &bytes_to_hex(&hasher.finalize())[..24])
3160 }
3161
3162 fn bounded_remote_error_message(error: &str) -> String {
3163 let without_nul = error.replace('\0', " ");
3164 let redacted = codewhale_config::persistence::redact_secrets(&without_nul);
3165 let message = redacted.trim();
3166 if message.is_empty() {
3167 return "The local model turn failed.".to_string();
3168 }
3169 crate::utils::truncate_with_ellipsis(message, MAX_REMOTE_ERROR_MESSAGE_BYTES, "…")
3170 }
3171
3172 fn command_fingerprint(command: &RemoteCommand) -> String {
3173 let canonical = format!("{command:?}");
3174 bytes_to_hex(&Sha256::digest(canonical.as_bytes()))
3175 }
3176
3177 fn bytes_to_hex(bytes: &[u8]) -> String {
3178 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
3179 }
3180
3181 async fn relay_worker(
3182 start: RemoteStart,
3183 mut runtime_chat: Option<RuntimeChatRelayHost>,
3184 mut worker_rx: mpsc::UnboundedReceiver<WorkerCommand>,
3185 event_tx: mpsc::UnboundedSender<RemoteEvent>,
3186 phase: &mut RelayPhase,
3187 ) -> Result<(), String> {
3188 let base = runner_control_plane_base()?;
3189 let client = crate::tls::reqwest_client_builder()
3190 .https_only(!cfg!(debug_assertions))
3191 .redirect(reqwest::redirect::Policy::none())
3192 .timeout(Duration::from_secs(20))
3193 .build()
3194 .map_err(|_| "Remote control could not initialize secure networking.".to_string())?;
3195
3196 let saved_enrollment = load_persisted_enrollment()?;
3197 // The device id is stable across enrollments: it is what lets the control
3198 // plane fold every folder enrolled from this terminal into one computer.
3199 let device_id = stable_device_id(
3200 saved_enrollment
3201 .as_ref()
3202 .map(|saved| saved.device_id.as_str()),
3203 )?;
3204 let mut enrollment = match saved_enrollment {
3205 Some(saved) if saved.matches(&start, &base) => {
3206 match refresh_enrollment(&client, saved).await {
3207 Ok(enrollment) => enrollment,
3208 Err(error) if error == "runner_enrollment_revoked" => {
3209 delete_persisted_enrollment();
3210 enroll_device(&client, &base, &start, &device_id, &event_tx).await?
3211 }
3212 Err(error) => return Err(error),
3213 }
3214 }
3215 Some(_) => {
3216 delete_persisted_enrollment();
3217 enroll_device(&client, &base, &start, &device_id, &event_tx).await?
3218 }
3219 None => enroll_device(&client, &base, &start, &device_id, &event_tx).await?,
3220 };
3221
3222 let connection = connect_runner(&client, &enrollment, &start).await?;
3223 // connect_runner answered with a server-confirmed attachment: a lease
3224 // exists from here on, and every later failure is a lost-after-lease
3225 // disconnect that must stay fail-closed.
3226 *phase = RelayPhase::Leased;
3227 let mut runner_id = connection.runner_id.clone();
3228 event_tx
3229 .send(RemoteEvent::Connected {
3230 account_ref: enrollment.persisted.account_ref.clone(),
3231 runner_id: runner_id.clone(),
3232 target_ref: start.target_ref.clone(),
3233 attachment: connection.attachment,
3234 links: connection.links,
3235 })
3236 .map_err(|_| "The terminal remote-control owner stopped.".to_string())?;
3237 let mut last_heartbeat = Instant::now() - HEARTBEAT_INTERVAL;
3238 let mut command_cursor: HashMap<String, u64> = HashMap::new();
3239 let mut delivered: HashMap<(String, u64), String> = HashMap::new();
3240 let mut runtime_outbox = RuntimeTransportOutbox::default();
3241 let mut runtime_upload_tick = tokio::time::interval(RUNTIME_UPLOAD_RETRY_INTERVAL);
3242 runtime_upload_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
3243 let mut runtime_chat_tick = tokio::time::interval(RUNTIME_UPLOAD_RETRY_INTERVAL);
3244 runtime_chat_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
3245 // One deadline across loop iterations. Reconstructing `sleep(SYNC_INTERVAL)`
3246 // lets the 250ms Runtime Chat tick reset poll/heartbeat/token refresh.
3247 let mut sync_tick =
3248 tokio::time::interval_at(tokio::time::Instant::now() + SYNC_INTERVAL, SYNC_INTERVAL);
3249 sync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
3250 let mut runtime_retry_delay = RUNTIME_UPLOAD_RETRY_INTERVAL;
3251 let mut runtime_retry_not_before = Instant::now();
3252
3253 loop {
3254 tokio::select! {
3255 command = worker_rx.recv() => {
3256 match command {
3257 Some(WorkerCommand::Upload { run_id, acknowledgements, envelopes }) => {
3258 if !envelopes.is_empty() {
3259 if !acknowledgements.is_empty() || envelopes.len() != 1 {
3260 return Err("The local runtime queued an invalid event batch.".to_string());
3261 }
3262 runtime_outbox.enqueue(&run_id, envelopes[0].clone())?;
3263 continue;
3264 }
3265 let body = Some(json!({ "acknowledgements": acknowledgements, "envelopes": envelopes }));
3266 let result = runner_request(
3267 &client,
3268 &enrollment,
3269 Method::POST,
3270 &["api", "local-runners", &runner_id, "runs", &run_id, "events"],
3271 &[],
3272 body.clone(),
3273 )
3274 .await;
3275 if let Err(err) = result {
3276 if err == "runner_access_token_expired" {
3277 refresh_enrollment_and_reconnect(
3278 &client,
3279 &mut enrollment,
3280 &mut runner_id,
3281 &start,
3282 &event_tx,
3283 )
3284 .await?;
3285 runner_request(
3286 &client,
3287 &enrollment,
3288 Method::POST,
3289 &["api", "local-runners", &runner_id, "runs", &run_id, "events"],
3290 &[],
3291 body,
3292 )
3293 .await?;
3294 } else {
3295 return Err(err);
3296 }
3297 }
3298 }
3299 Some(WorkerCommand::ReleaseRuntimeChatHost) => {
3300 runtime_chat.take();
3301 event_tx
3302 .send(RemoteEvent::RuntimeChatHostReleased)
3303 .map_err(|_| "The terminal remote-control owner stopped.".to_string())?;
3304 }
3305 Some(WorkerCommand::InstallRuntimeChatHost(host)) => {
3306 if runtime_chat.is_some() {
3307 return Err(
3308 "Runtime Chat received an invalid provider-refresh install."
3309 .to_string(),
3310 );
3311 }
3312 runtime_chat = Some(host);
3313 }
3314 Some(WorkerCommand::Stop) | None => {
3315 // Do not return local input until the control plane has
3316 // durably released this lease. Every queued runtime
3317 // envelope must first drain behind the server-confirmed
3318 // cursor; only then may the offline heartbeat be
3319 // posted. If either cannot be confirmed, this worker
3320 // errors out and the UI keeps ownership locked through
3321 // the server-side lease expiry instead.
3322 drain_runtime_outbox_for_stop(
3323 &client,
3324 &mut enrollment,
3325 &mut runner_id,
3326 &start,
3327 &event_tx,
3328 &mut runtime_outbox,
3329 Instant::now() + STOP_DRAIN_DEADLINE,
3330 )
3331 .await?;
3332 let hb = post_heartbeat(&client, &enrollment, &runner_id, &start, "offline").await;
3333 if let Err(err) = hb {
3334 if err == "runner_access_token_expired" {
3335 refresh_enrollment_and_reconnect(
3336 &client,
3337 &mut enrollment,
3338 &mut runner_id,
3339 &start,
3340 &event_tx,
3341 )
3342 .await?;
3343 post_heartbeat(&client, &enrollment, &runner_id, &start, "offline").await?;
3344 } else {
3345 return Err(err);
3346 }
3347 }
3348 let _ = event_tx.send(RemoteEvent::Stopped);
3349 return Ok(());
3350 }
3351 }
3352 }
3353 _ = runtime_upload_tick.tick(), if !runtime_outbox.events.is_empty() => {
3354 if Instant::now() < runtime_retry_not_before {
3355 continue;
3356 }
3357 match runtime_outbox
3358 .try_flush_one(&client, &enrollment, &runner_id)
3359 .await?
3360 {
3361 RuntimeFlushOutcome::Idle => {
3362 runtime_retry_delay = RUNTIME_UPLOAD_RETRY_INTERVAL;
3363 runtime_retry_not_before = Instant::now();
3364 }
3365 RuntimeFlushOutcome::Retryable => {
3366 runtime_retry_not_before = Instant::now() + runtime_retry_delay;
3367 runtime_retry_delay = runtime_retry_delay
3368 .saturating_mul(2)
3369 .min(RUNTIME_UPLOAD_MAX_BACKOFF);
3370 }
3371 RuntimeFlushOutcome::Accepted { run_id, cursor } => {
3372 runtime_retry_delay = RUNTIME_UPLOAD_RETRY_INTERVAL;
3373 runtime_retry_not_before = Instant::now();
3374 event_tx
3375 .send(RemoteEvent::RuntimeCursor { run_id, cursor })
3376 .map_err(|_| "The terminal remote-control owner stopped.".to_string())?;
3377 }
3378 RuntimeFlushOutcome::AccessTokenExpired => {
3379 refresh_enrollment_and_reconnect(
3380 &client,
3381 &mut enrollment,
3382 &mut runner_id,
3383 &start,
3384 &event_tx,
3385 )
3386 .await?;
3387 runtime_retry_delay = RUNTIME_UPLOAD_RETRY_INTERVAL;
3388 runtime_retry_not_before = Instant::now();
3389 }
3390 }
3391 }
3392 _ = runtime_chat_tick.tick(), if runtime_chat.is_some() => {
3393 let host = runtime_chat.as_ref().expect("select guard requires Runtime Chat host");
3394 for projection in host.pending_projections().await? {
3395 event_tx
3396 .send(RemoteEvent::RuntimeChatProjection(projection))
3397 .map_err(|_| "The terminal remote-control owner stopped.".to_string())?;
3398 }
3399 }
3400 _ = sync_tick.tick() => {
3401 if enrollment_needs_refresh(&enrollment) {
3402 // Proactive refresh before expiry; reconnect to keep runner lease valid.
3403 match refresh_enrollment(&client, enrollment.persisted.clone()).await {
3404 Ok(new_enrollment) => {
3405 install_reconnected_enrollment(
3406 &mut enrollment,
3407 new_enrollment,
3408 &start,
3409 )?;
3410 reconnect_runner(
3411 &client,
3412 &enrollment,
3413 &mut runner_id,
3414 &start,
3415 &event_tx,
3416 )
3417 .await?;
3418 }
3419 Err(err) if err == "runner_enrollment_revoked" => {
3420 delete_persisted_enrollment();
3421 let device_id = enrollment.persisted.device_id.clone();
3422 let candidate =
3423 enroll_device(&client, &base, &start, &device_id, &event_tx).await?;
3424 if let Err(error) = install_reconnected_enrollment(
3425 &mut enrollment,
3426 candidate,
3427 &start,
3428 ) {
3429 // enroll_device persists its candidate. Never retain a
3430 // credential for authority the live worker rejected.
3431 delete_persisted_enrollment();
3432 return Err(error);
3433 }
3434 reconnect_runner(
3435 &client,
3436 &enrollment,
3437 &mut runner_id,
3438 &start,
3439 &event_tx,
3440 )
3441 .await?;
3442 }
3443 Err(err) => return Err(err),
3444 }
3445 }
3446 if last_heartbeat.elapsed() >= HEARTBEAT_INTERVAL {
3447 let hb = post_heartbeat(&client, &enrollment, &runner_id, &start, "active").await;
3448 if let Err(err) = hb {
3449 if err == "runner_access_token_expired" {
3450 refresh_enrollment_and_reconnect(
3451 &client,
3452 &mut enrollment,
3453 &mut runner_id,
3454 &start,
3455 &event_tx,
3456 )
3457 .await?;
3458 post_heartbeat(&client, &enrollment, &runner_id, &start, "active").await?;
3459 } else {
3460 return Err(err);
3461 }
3462 }
3463 last_heartbeat = Instant::now();
3464 }
3465 let runs = match list_runs(&client, &enrollment, &runner_id).await {
3466 Ok(v) => v,
3467 Err(err) if err == "runner_access_token_expired" => {
3468 refresh_enrollment_and_reconnect(
3469 &client,
3470 &mut enrollment,
3471 &mut runner_id,
3472 &start,
3473 &event_tx,
3474 )
3475 .await?;
3476 list_runs(&client, &enrollment, &runner_id).await?
3477 }
3478 Err(err) => return Err(err),
3479 };
3480 for run_id in runs {
3481 let since = command_cursor.get(&run_id).copied().unwrap_or(0);
3482 let listed_commands = match list_commands(
3483 &client,
3484 &enrollment,
3485 &runner_id,
3486 &run_id,
3487 since,
3488 )
3489 .await
3490 {
3491 Ok(v) => v,
3492 Err(err) if err == "runner_access_token_expired" => {
3493 refresh_enrollment_and_reconnect(
3494 &client,
3495 &mut enrollment,
3496 &mut runner_id,
3497 &start,
3498 &event_tx,
3499 )
3500 .await?;
3501 list_commands(&client, &enrollment, &runner_id, &run_id, since).await?
3502 }
3503 Err(err) => return Err(err),
3504 };
3505 for listed in listed_commands {
3506 let seq = listed.seq;
3507 if !listed.ack_status.is_empty() {
3508 if listed.ack_status == "accepted" {
3509 let rr = recover_run(
3510 &client,
3511 &enrollment,
3512 &runner_id,
3513 &run_id,
3514 "accepted command has no terminal acknowledgement after runner restart",
3515 )
3516 .await;
3517 if let Err(err) = rr {
3518 if err == "runner_access_token_expired" {
3519 refresh_enrollment_and_reconnect(
3520 &client,
3521 &mut enrollment,
3522 &mut runner_id,
3523 &start,
3524 &event_tx,
3525 )
3526 .await?;
3527 recover_run(
3528 &client,
3529 &enrollment,
3530 &runner_id,
3531 &run_id,
3532 "accepted command has no terminal acknowledgement after runner restart",
3533 )
3534 .await?;
3535 } else {
3536 return Err(err);
3537 }
3538 }
3539 }
3540 command_cursor.insert(run_id.clone(), seq);
3541 continue;
3542 }
3543 let command = parse_remote_command(&listed.command, &run_id)?;
3544 let fingerprint = command_fingerprint(&command);
3545 let key = (run_id.clone(), seq);
3546 if let Some(existing) = delivered.get(&key) {
3547 if existing != &fingerprint {
3548 return Err("The control plane replayed a changed command sequence.".to_string());
3549 }
3550 } else {
3551 delivered.insert(key, fingerprint);
3552 // Runtime Chat reports `applied` only after the
3553 // durable native thread manager has accepted the
3554 // exact operation key. Leaving the command
3555 // unacknowledged until then makes crash replay
3556 // naturally re-enter the idempotent native path.
3557 if !command.is_runtime_chat() {
3558 let up = upload_command_accepted(
3559 &client,
3560 &enrollment,
3561 &runner_id,
3562 &run_id,
3563 seq,
3564 &command,
3565 )
3566 .await;
3567 if let Err(err) = up {
3568 if err == "runner_access_token_expired" {
3569 refresh_enrollment_and_reconnect(
3570 &client,
3571 &mut enrollment,
3572 &mut runner_id,
3573 &start,
3574 &event_tx,
3575 )
3576 .await?;
3577 upload_command_accepted(
3578 &client,
3579 &enrollment,
3580 &runner_id,
3581 &run_id,
3582 seq,
3583 &command,
3584 )
3585 .await?;
3586 } else {
3587 return Err(err);
3588 }
3589 }
3590 }
3591 event_tx.send(RemoteEvent::Command {
3592 run_id: run_id.clone(),
3593 seq,
3594 command,
3595 }).map_err(|_| "The terminal remote-control owner stopped.".to_string())?;
3596 }
3597 command_cursor.insert(run_id.clone(), seq.max(since));
3598 }
3599 }
3600 }
3601 }
3602 }
3603 }
3604
3605 impl RuntimeTransportOutbox {
3606 fn enqueue(&mut self, run_id: &str, envelope: Value) -> Result<(), String> {
3607 if !valid_opaque_ref(run_id) {
3608 return Err("The local runtime queued an invalid run id.".to_string());
3609 }
3610 let seq = runtime_envelope_seq(&envelope)
3611 .ok_or_else(|| "The local runtime queued an invalid event sequence.".to_string())?;
3612 let encoded = serde_json::to_vec(&envelope)
3613 .map_err(|_| "The local runtime could not encode an event.".to_string())?;
3614 if encoded.len() > MAX_RUNTIME_ENVELOPE_BYTES {
3615 return Err("The local runtime queued an oversized event.".to_string());
3616 }
3617 let key = (run_id.to_string(), seq);
3618 if let Some(existing) = self.events.get(&key) {
3619 if existing != &envelope {
3620 return Err(
3621 "The local runtime changed an unacknowledged event sequence.".to_string(),
3622 );
3623 }
3624 return Ok(());
3625 }
3626 self.events.insert(key, envelope);
3627 Ok(())
3628 }
3629
3630 async fn try_flush_one(
3631 &mut self,
3632 client: &Client,
3633 enrollment: &LiveEnrollment,
3634 runner_id: &str,
3635 ) -> Result<RuntimeFlushOutcome, String> {
3636 let Some(((run_id, seq), envelope)) = self
3637 .events
3638 .first_key_value()
3639 .map(|(key, value)| (key.clone(), value.clone()))
3640 else {
3641 return Ok(RuntimeFlushOutcome::Idle);
3642 };
3643 match post_runtime_event(client, enrollment, runner_id, &run_id, seq, &envelope).await? {
3644 RuntimePostOutcome::Retryable => Ok(RuntimeFlushOutcome::Retryable),
3645 RuntimePostOutcome::AccessTokenExpired => Ok(RuntimeFlushOutcome::AccessTokenExpired),
3646 RuntimePostOutcome::Accepted(cursor) => {
3647 self.events.retain(|(pending_run, pending_seq), _| {
3648 pending_run != &run_id || *pending_seq > cursor
3649 });
3650 Ok(RuntimeFlushOutcome::Accepted { run_id, cursor })
3651 }
3652 }
3653 }
3654 }
3655
3656 /// Flushes every queued runtime envelope through the server-confirmed cursor
3657 /// before a stop may be acknowledged. Emits `RuntimeCursor` events so the
3658 /// controller compacts its journal as acknowledgements land. Failing to drain
3659 /// by `deadline` is a hard error: the stop is *not* confirmed and the caller
3660 /// must leave ownership locked.
3661 #[allow(clippy::too_many_arguments)]
3662 async fn drain_runtime_outbox_for_stop(
3663 client: &Client,
3664 enrollment: &mut LiveEnrollment,
3665 runner_id: &mut String,
3666 start: &RemoteStart,
3667 event_tx: &mpsc::UnboundedSender<RemoteEvent>,
3668 outbox: &mut RuntimeTransportOutbox,
3669 deadline: Instant,
3670 ) -> Result<(), String> {
3671 let mut delay = RUNTIME_UPLOAD_RETRY_INTERVAL;
3672 while !outbox.events.is_empty() {
3673 if Instant::now() >= deadline {
3674 return Err(
3675 "queued runtime events were not server-acknowledged in time; the stop was not confirmed"
3676 .to_string(),
3677 );
3678 }
3679 match outbox.try_flush_one(client, enrollment, runner_id).await? {
3680 RuntimeFlushOutcome::Idle => break,
3681 RuntimeFlushOutcome::Accepted { run_id, cursor } => {
3682 delay = RUNTIME_UPLOAD_RETRY_INTERVAL;
3683 let _ = event_tx.send(RemoteEvent::RuntimeCursor { run_id, cursor });
3684 }
3685 RuntimeFlushOutcome::Retryable => {
3686 tokio::time::sleep(delay).await;
3687 delay = delay.saturating_mul(2).min(RUNTIME_UPLOAD_MAX_BACKOFF);
3688 }
3689 RuntimeFlushOutcome::AccessTokenExpired => {
3690 refresh_enrollment_and_reconnect(client, enrollment, runner_id, start, event_tx)
3691 .await?;
3692 }
3693 }
3694 }
3695 Ok(())
3696 }
3697
3698 async fn post_runtime_event(
3699 client: &Client,
3700 enrollment: &LiveEnrollment,
3701 runner_id: &str,
3702 run_id: &str,
3703 seq: u64,
3704 envelope: &Value,
3705 ) -> Result<RuntimePostOutcome, String> {
3706 let url = control_plane_url(
3707 &enrollment.persisted.control_plane_base,
3708 &["api", "local-runners", runner_id, "runs", run_id, "events"],
3709 &[],
3710 )?;
3711 let response = match client
3712 .post(url)
3713 .bearer_auth(&enrollment.access_token)
3714 .json(&json!({
3715 "acknowledgements": [],
3716 "envelopes": [envelope],
3717 }))
3718 .send()
3719 .await
3720 {
3721 Ok(response) => response,
3722 Err(_) => return Ok(RuntimePostOutcome::Retryable),
3723 };
3724 let status = response.status();
3725 if matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN) {
3726 return Ok(RuntimePostOutcome::AccessTokenExpired);
3727 }
3728 if matches!(
3729 status,
3730 StatusCode::REQUEST_TIMEOUT | StatusCode::TOO_EARLY | StatusCode::TOO_MANY_REQUESTS
3731 ) || status.is_server_error()
3732 {
3733 return Ok(RuntimePostOutcome::Retryable);
3734 }
3735 if !status.is_success() {
3736 return Err(format!(
3737 "The remote-control server rejected runtime event {seq} ({status})."
3738 ));
3739 }
3740 let value = match read_bounded_json(response).await {
3741 Ok(value) => value,
3742 Err(_) => return Ok(RuntimePostOutcome::Retryable),
3743 };
3744 let Some(cursor) = value
3745 .get("cursor")
3746 .and_then(Value::as_u64)
3747 .filter(|cursor| *cursor >= seq && *cursor <= JS_MAX_SAFE_INTEGER)
3748 else {
3749 // A success without a durable cursor is indistinguishable from a lost
3750 // response. Retain and retry the exact same event body.
3751 return Ok(RuntimePostOutcome::Retryable);
3752 };
3753 Ok(RuntimePostOutcome::Accepted(cursor))
3754 }
3755
3756 impl PersistedEnrollment {
3757 fn matches(&self, start: &RemoteStart, base: &str) -> bool {
3758 self.schema_version == 1
3759 && self.control_plane_base == base
3760 && self.target_ref == start.target_ref
3761 && self.runtime_version == start.runtime_version
3762 && self.runtime_commit == start.runtime_commit
3763 && valid_opaque_ref(&self.runner_enrollment_id)
3764 && valid_opaque_ref(&self.account_ref)
3765 && valid_opaque_ref(&self.device_id)
3766 && valid_opaque_ref(&self.target_grant_ref)
3767 && valid_secret(&self.bootstrap_secret)
3768 }
3769 }
3770
3771 async fn enroll_device(
3772 client: &Client,
3773 base: &str,
3774 start: &RemoteStart,
3775 device_id: &str,
3776 event_tx: &mpsc::UnboundedSender<RemoteEvent>,
3777 ) -> Result<LiveEnrollment, String> {
3778 let value = public_request(
3779 client,
3780 Method::POST,
3781 control_plane_url(base, &["api", "runner", "device", "start"], &[])?,
3782 json!({
3783 "deviceId": device_id,
3784 "deviceLabel": "Codewhale terminal",
3785 "targetRef": start.target_ref,
3786 "targetLabel": start.workspace_label,
3787 "runtimeVersion": start.runtime_version,
3788 "runtimeCommit": start.runtime_commit,
3789 "capabilities": CAPABILITIES,
3790 }),
3791 )
3792 .await?;
3793 let device_code = secret_field(&value, "deviceCode")?;
3794 let user_code = string_field(&value, "userCode")?;
3795 let verification_uri = string_field(&value, "verificationUriComplete")?;
3796 let interval = value
3797 .get("interval")
3798 .and_then(Value::as_u64)
3799 .filter(|value| (1..=30).contains(value))
3800 .ok_or_else(|| {
3801 "Codewhale returned an invalid device authorization interval.".to_string()
3802 })?;
3803 let expires_in = value
3804 .get("expiresIn")
3805 .and_then(Value::as_u64)
3806 .filter(|value| (60..=1800).contains(value))
3807 .ok_or_else(|| "Codewhale returned an invalid device authorization expiry.".to_string())?;
3808 validate_authorization_url(&verification_uri, &user_code)?;
3809 let _ = event_tx.send(RemoteEvent::Notice(format!(
3810 "Authorize this terminal at {verification_uri} (code {user_code})."
3811 )));
3812 let _ = webbrowser::open(&verification_uri);
3813 let deadline = Instant::now() + Duration::from_secs(expires_in);
3814 loop {
3815 if Instant::now() >= deadline {
3816 return Err("Remote-control authorization expired; run /rc again.".to_string());
3817 }
3818 tokio::time::sleep(Duration::from_secs(interval)).await;
3819 let response = client
3820 .post(control_plane_url(
3821 base,
3822 &["api", "runner", "device", "token"],
3823 &[],
3824 )?)
3825 .json(&json!({ "deviceCode": device_code }))
3826 .send()
3827 .await
3828 .map_err(|_| "Remote-control authorization could not reach Codewhale.".to_string())?;
3829 if response.status() == StatusCode::ACCEPTED {
3830 continue;
3831 }
3832 if !response.status().is_success() {
3833 return Err("Remote-control authorization was rejected.".to_string());
3834 }
3835 let exchange = read_bounded_json(response).await?;
3836 let enrollment = enrollment_from_exchange(exchange, base, device_id, start)?;
3837 save_persisted_enrollment(&enrollment.persisted)?;
3838 return Ok(enrollment);
3839 }
3840 }
3841
3842 fn enrollment_from_exchange(
3843 value: Value,
3844 base: &str,
3845 device_id: &str,
3846 start: &RemoteStart,
3847 ) -> Result<LiveEnrollment, String> {
3848 if value.get("status").and_then(Value::as_str) != Some("approved") {
3849 return Err("Codewhale returned an invalid runner credential.".to_string());
3850 }
3851 let record = value
3852 .get("enrollment")
3853 .filter(|value| value.is_object())
3854 .ok_or_else(|| "Codewhale returned an invalid runner credential.".to_string())?;
3855 let enrollment_id = opaque_field(record, "id")?;
3856 let account_ref = opaque_field(record, "userId")?;
3857 let returned_device = opaque_field(record, "deviceId")?;
3858 if returned_device != device_id
3859 || record.get("runtimeVersion").and_then(Value::as_str)
3860 != Some(start.runtime_version.as_str())
3861 || record.get("runtimeCommit").and_then(Value::as_str)
3862 != Some(start.runtime_commit.as_str())
3863 || !exact_capabilities(record.get("capabilities"))
3864 {
3865 return Err("The runner credential does not match this terminal.".to_string());
3866 }
3867 let target_grant_ref = record
3868 .get("targetGrants")
3869 .and_then(Value::as_array)
3870 .and_then(|grants| {
3871 grants.iter().find(|grant| {
3872 grant.get("targetRef").and_then(Value::as_str) == Some(start.target_ref.as_str())
3873 && grant
3874 .get("revokedAt")
3875 .and_then(Value::as_str)
3876 .unwrap_or_default()
3877 .is_empty()
3878 })
3879 })
3880 .and_then(|grant| grant.get("grantId"))
3881 .and_then(Value::as_str)
3882 .filter(|value| valid_opaque_ref(value))
3883 .ok_or_else(|| "Codewhale returned no grant for this session.".to_string())?
3884 .to_string();
3885 Ok(LiveEnrollment {
3886 persisted: PersistedEnrollment {
3887 schema_version: 1,
3888 control_plane_base: base.to_string(),
3889 runner_enrollment_id: enrollment_id,
3890 account_ref,
3891 device_id: returned_device,
3892 target_ref: start.target_ref.clone(),
3893 target_grant_ref,
3894 runtime_version: start.runtime_version.clone(),
3895 runtime_commit: start.runtime_commit.clone(),
3896 bootstrap_secret: secret_field(&value, "bootstrapSecret")?,
3897 },
3898 access_token: access_token(&value)?,
3899 })
3900 }
3901
3902 async fn refresh_enrollment(
3903 client: &Client,
3904 persisted: PersistedEnrollment,
3905 ) -> Result<LiveEnrollment, String> {
3906 let url = control_plane_url(
3907 &persisted.control_plane_base,
3908 &["api", "runner", "enrollments", "token"],
3909 &[],
3910 )?;
3911 let response = client
3912 .post(url)
3913 .json(&json!({
3914 "enrollmentId": persisted.runner_enrollment_id,
3915 "bootstrapSecret": persisted.bootstrap_secret,
3916 }))
3917 .send()
3918 .await
3919 .map_err(|_| "Remote-control credential refresh could not reach Codewhale.".to_string())?;
3920 if matches!(
3921 response.status(),
3922 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
3923 ) {
3924 return Err("runner_enrollment_revoked".to_string());
3925 }
3926 if !response.status().is_success() {
3927 return Err("Remote-control credential refresh was rejected.".to_string());
3928 }
3929 let value = read_bounded_json(response).await?;
3930 let record = value
3931 .get("enrollment")
3932 .filter(|value| value.is_object())
3933 .ok_or_else(|| "Codewhale returned an invalid refreshed credential.".to_string())?;
3934 if record.get("id").and_then(Value::as_str) != Some(persisted.runner_enrollment_id.as_str())
3935 || record.get("userId").and_then(Value::as_str) != Some(persisted.account_ref.as_str())
3936 || record.get("deviceId").and_then(Value::as_str) != Some(persisted.device_id.as_str())
3937 || record.get("runtimeVersion").and_then(Value::as_str)
3938 != Some(persisted.runtime_version.as_str())
3939 || record.get("runtimeCommit").and_then(Value::as_str)
3940 != Some(persisted.runtime_commit.as_str())
3941 || !exact_capabilities(record.get("capabilities"))
3942 {
3943 return Err("Codewhale returned a mismatched refreshed credential.".to_string());
3944 }
3945 Ok(LiveEnrollment {
3946 persisted,
3947 access_token: access_token(&value)?,
3948 })
3949 }
3950
3951 async fn connect_runner(
3952 client: &Client,
3953 enrollment: &LiveEnrollment,
3954 start: &RemoteStart,
3955 ) -> Result<RunnerConnection, String> {
3956 let value = runner_request(
3957 client,
3958 enrollment,
3959 Method::POST,
3960 &["api", "local-runners", "connect"],
3961 &[],
3962 Some(connect_runner_body(enrollment, start)),
3963 )
3964 .await?;
3965 parse_runner_connection(&value, enrollment, start)
3966 }
3967
3968 fn connect_runner_body(enrollment: &LiveEnrollment, start: &RemoteStart) -> Value {
3969 let mut body = json!({
3970 "deviceId": enrollment.persisted.device_id,
3971 "targetRef": start.target_ref,
3972 "displayLabel": start.workspace_label,
3973 "runtimeVersion": start.runtime_version,
3974 "runtimeCommit": start.runtime_commit,
3975 "capabilities": CAPABILITIES,
3976 "status": "active",
3977 // This is the only session attachment input. It is an opaque runtime
3978 // id, never a workspace path, prompt, environment, or credential.
3979 "sessionRef": start.session_id,
3980 "runtimeChatRelayProtocol": RUNTIME_CHAT_RELAY_PROTOCOL,
3981 });
3982 if let Some(repo) = start
3983 .git_remote
3984 .as_deref()
3985 .and_then(normalize_observed_git_repo)
3986 {
3987 body["gitRemote"] = json!(repo);
3988 }
3989 body
3990 }
3991
3992 /// Collapse a git remote to `owner/name`. Paths, credentials, and unknown
3993 /// hosts are dropped so the control plane never receives a folder identity.
3994 pub fn normalize_observed_git_repo(input: &str) -> Option<String> {
3995 let raw = input.trim();
3996 if raw.is_empty() {
3997 return None;
3998 }
3999 let allowed_host = |host: &str| {
4000 matches!(
4001 host.to_ascii_lowercase().as_str(),
4002 "github.com" | "www.github.com" | "gitee.com" | "cnb.cool"
4003 )
4004 };
4005 let path = if let Some((authority, path)) = raw.split_once(':')
4006 && !raw.contains("://")
4007 && authority.starts_with("git@")
4008 && allowed_host(authority.trim_start_matches("git@"))
4009 {
4010 path.to_string()
4011 } else {
4012 let url = Url::parse(raw).ok()?;
4013 if url.scheme() != "https"
4014 || !url.username().is_empty()
4015 || url.password().is_some()
4016 || url.port().is_some()
4017 || url.query().is_some()
4018 || url.fragment().is_some()
4019 || !allowed_host(url.host_str()?)
4020 {
4021 return None;
4022 }
4023 url.path().trim_start_matches('/').to_string()
4024 };
4025 let path = path.trim_end_matches('/').trim_end_matches(".git");
4026 let mut parts = path.split('/');
4027 let owner = parts.next()?;
4028 let name = parts.next()?;
4029 if parts.next().is_some() {
4030 return None;
4031 }
4032 if owner.len() > 80 || name.len() > 80 {
4033 return None;
4034 }
4035 if !owner
4036 .chars()
4037 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
4038 || !name
4039 .chars()
4040 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-'))
4041 {
4042 return None;
4043 }
4044 if matches!(owner, "." | "..") || matches!(name, "." | "..") {
4045 return None;
4046 }
4047 Some(format!("{owner}/{name}"))
4048 }
4049
4050 pub fn observed_git_repo(workspace: &Path) -> Option<String> {
4051 let output = std::process::Command::new("git")
4052 .arg("-C")
4053 .arg(workspace)
4054 .args(["remote", "get-url", "origin"])
4055 .output()
4056 .ok()?;
4057 if !output.status.success() {
4058 return None;
4059 }
4060 normalize_observed_git_repo(std::str::from_utf8(&output.stdout).ok()?)
4061 }
4062
4063 fn parse_runner_connection(
4064 value: &Value,
4065 enrollment: &LiveEnrollment,
4066 start: &RemoteStart,
4067 ) -> Result<RunnerConnection, String> {
4068 let response = value
4069 .as_object()
4070 .filter(|record| {
4071 record.len() == 2 && record.contains_key("runner") && record.contains_key("attachment")
4072 })
4073 .ok_or_else(|| "Codewhale returned an invalid runner attachment response.".to_string())?;
4074 let runner = response
4075 .get("runner")
4076 .and_then(Value::as_object)
4077 .ok_or_else(|| "Codewhale returned an invalid runner lease.".to_string())?;
4078 let runner_id = runner
4079 .get("id")
4080 .and_then(Value::as_str)
4081 .filter(|value| valid_opaque_ref(value))
4082 .map(ToString::to_string)
4083 .ok_or_else(|| "Codewhale returned an invalid runner lease.".to_string())?;
4084 let runner_binding_matches = runner.get("userId").and_then(Value::as_str)
4085 == Some(enrollment.persisted.account_ref.as_str())
4086 && runner.get("deviceId").and_then(Value::as_str)
4087 == Some(enrollment.persisted.device_id.as_str())
4088 && runner.get("targetRef").and_then(Value::as_str) == Some(start.target_ref.as_str())
4089 && runner.get("runtimeVersion").and_then(Value::as_str)
4090 == Some(start.runtime_version.as_str())
4091 && runner.get("runtimeCommit").and_then(Value::as_str)
4092 == Some(start.runtime_commit.as_str())
4093 && runner.get("controlPath").and_then(Value::as_str) == Some("outbound_relay")
4094 && runner.get("status").and_then(Value::as_str) == Some("active")
4095 && runner.get("active").and_then(Value::as_bool) == Some(true)
4096 && exact_capabilities(runner.get("capabilities"));
4097 if !runner_binding_matches {
4098 return Err("Codewhale returned a runner lease for a different session.".to_string());
4099 }
4100
4101 let attachment = response
4102 .get("attachment")
4103 .and_then(Value::as_object)
4104 .filter(|record| {
4105 record.len() == 6
4106 && record.contains_key("runId")
4107 && record.contains_key("workspaceId")
4108 && record.contains_key("runtimeCursor")
4109 && record.contains_key("snapshotPresent")
4110 && record.contains_key("runtimeChatRelayProtocol")
4111 && record.contains_key("runtimeChatRelayChallenge")
4112 })
4113 .ok_or_else(|| "Codewhale returned an invalid session attachment.".to_string())?;
4114 let run_id = attachment
4115 .get("runId")
4116 .and_then(Value::as_str)
4117 .filter(|value| valid_opaque_ref(value))
4118 .map(ToString::to_string)
4119 .ok_or_else(|| "Codewhale returned an invalid attached run.".to_string())?;
4120 let workspace_id = attachment
4121 .get("workspaceId")
4122 .and_then(Value::as_str)
4123 .filter(|value| valid_opaque_ref(value))
4124 .map(ToString::to_string)
4125 .ok_or_else(|| "Codewhale returned an invalid attached workspace.".to_string())?;
4126 let runtime_cursor = attachment
4127 .get("runtimeCursor")
4128 .and_then(Value::as_u64)
4129 .filter(|value| *value <= JS_MAX_SAFE_INTEGER)
4130 .ok_or_else(|| "Codewhale returned an invalid runtime event cursor.".to_string())?;
4131 let snapshot_present = attachment
4132 .get("snapshotPresent")
4133 .and_then(Value::as_bool)
4134 .ok_or_else(|| "Codewhale returned an invalid snapshot receipt.".to_string())?;
4135 let runtime_chat_relay_protocol = attachment
4136 .get("runtimeChatRelayProtocol")
4137 .and_then(Value::as_str)
4138 .filter(|value| *value == RUNTIME_CHAT_RELAY_PROTOCOL)
4139 .map(ToString::to_string)
4140 .ok_or_else(|| {
4141 "Codewhale returned an unsupported Runtime Chat relay protocol.".to_string()
4142 })?;
4143 let runtime_chat_relay_challenge = attachment
4144 .get("runtimeChatRelayChallenge")
4145 .and_then(Value::as_str)
4146 .filter(|value| {
4147 (32..=128).contains(&value.len())
4148 && value
4149 .bytes()
4150 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
4151 })
4152 .map(ToString::to_string)
4153 .ok_or_else(|| "Codewhale returned an invalid Runtime Chat relay challenge.".to_string())?;
4154
4155 let links = parse_remote_links(runner, &run_id);
4156
4157 Ok(RunnerConnection {
4158 runner_id,
4159 attachment: RemoteAttachment {
4160 run_id,
4161 workspace_id,
4162 runtime_cursor,
4163 snapshot_present,
4164 runtime_chat_relay_protocol,
4165 runtime_chat_relay_challenge,
4166 },
4167 links,
4168 })
4169 }
4170
4171 /// Read the optional `runUrl` / `computerUrl` advertised on the runner lease.
4172 /// Absent fields yield `None`; present-but-invalid values are dropped (never
4173 /// displayed, never opened) rather than failing the attachment, because a
4174 /// link is a convenience receipt and not part of the ownership contract.
4175 fn parse_remote_links(runner: &serde_json::Map<String, Value>, run_id: &str) -> RemoteLinks {
4176 let run_url = runner
4177 .get("runUrl")
4178 .and_then(Value::as_str)
4179 .and_then(|value| validate_run_url(value, run_id));
4180 let computer_url = runner
4181 .get("computerUrl")
4182 .and_then(Value::as_str)
4183 .and_then(validate_computer_url);
4184 RemoteLinks {
4185 run_url,
4186 computer_url,
4187 }
4188 }
4189
4190 /// Parse a web link and accept it only on the Codewhale app origin (or, in
4191 /// debug builds only, a loopback development origin) with no credentials,
4192 /// port, or fragment — the same shape `validate_authorization_url` enforces.
4193 fn app_link_url(value: &str) -> Option<Url> {
4194 let trimmed = value.trim();
4195 if trimmed.is_empty() || trimmed.len() > 2048 {
4196 return None;
4197 }
4198 let url = Url::parse(trimmed).ok()?;
4199 let production_origin =
4200 url.scheme() == "https" && url.host_str() == Some(APP_ORIGIN_HOST) && url.port().is_none();
4201 let debug_loopback = cfg!(debug_assertions)
4202 && url.scheme() == "http"
4203 && matches!(url.host_str(), Some("127.0.0.1" | "localhost"));
4204 if !(production_origin || debug_loopback)
4205 || !url.username().is_empty()
4206 || url.password().is_some()
4207 || url.fragment().is_some()
4208 {
4209 return None;
4210 }
4211 Some(url)
4212 }
4213
4214 /// `/session?run=<runId>` for exactly the attached run; anything else is
4215 /// dropped so the terminal can never send the user to a different session.
4216 fn validate_run_url(value: &str, run_id: &str) -> Option<String> {
4217 let url = app_link_url(value)?;
4218 let pairs = url.query_pairs().collect::<Vec<_>>();
4219 if url.path() != "/session" || pairs.len() != 1 || pairs[0].0 != "run" || pairs[0].1 != run_id {
4220 return None;
4221 }
4222 Some(url.to_string())
4223 }
4224
4225 /// `/settings` (optionally `?section=…`) on the app origin.
4226 fn validate_computer_url(value: &str) -> Option<String> {
4227 let url = app_link_url(value)?;
4228 let pairs = url.query_pairs().collect::<Vec<_>>();
4229 if url.path() != "/settings"
4230 || pairs.len() > 1
4231 || pairs.iter().any(|(key, _)| *key != "section")
4232 {
4233 return None;
4234 }
4235 Some(url.to_string())
4236 }
4237
4238 async fn post_heartbeat(
4239 client: &Client,
4240 enrollment: &LiveEnrollment,
4241 runner_id: &str,
4242 start: &RemoteStart,
4243 status: &str,
4244 ) -> Result<(), String> {
4245 runner_request(
4246 client,
4247 enrollment,
4248 Method::POST,
4249 &["api", "local-runners", runner_id, "heartbeat"],
4250 &[],
4251 Some(json!({
4252 "runtimeVersion": start.runtime_version,
4253 "runtimeCommit": start.runtime_commit,
4254 "capabilities": CAPABILITIES,
4255 "status": status,
4256 })),
4257 )
4258 .await
4259 .map(|_| ())
4260 }
4261
4262 async fn list_runs(
4263 client: &Client,
4264 enrollment: &LiveEnrollment,
4265 runner_id: &str,
4266 ) -> Result<Vec<String>, String> {
4267 let value = runner_request(
4268 client,
4269 enrollment,
4270 Method::GET,
4271 &["api", "local-runners", runner_id, "runs"],
4272 &[],
4273 None,
4274 )
4275 .await?;
4276 let runs = value
4277 .get("runs")
4278 .and_then(Value::as_array)
4279 .filter(|runs| runs.len() <= MAX_RUNS)
4280 .ok_or_else(|| "Codewhale returned an invalid runner run list.".to_string())?;
4281 runs.iter()
4282 .map(|run| {
4283 run.get("id")
4284 .and_then(Value::as_str)
4285 .filter(|value| valid_opaque_ref(value))
4286 .map(ToString::to_string)
4287 .ok_or_else(|| "Codewhale returned an invalid runner run.".to_string())
4288 })
4289 .collect()
4290 }
4291
4292 async fn list_commands(
4293 client: &Client,
4294 enrollment: &LiveEnrollment,
4295 runner_id: &str,
4296 run_id: &str,
4297 since: u64,
4298 ) -> Result<Vec<ListedCommand>, String> {
4299 let value = runner_request(
4300 client,
4301 enrollment,
4302 Method::GET,
4303 &[
4304 "api",
4305 "local-runners",
4306 runner_id,
4307 "runs",
4308 run_id,
4309 "commands",
4310 ],
4311 &[
4312 ("since_seq", since.to_string()),
4313 ("include_accepted", "1".to_string()),
4314 ],
4315 None,
4316 )
4317 .await?;
4318 let commands = value
4319 .get("commands")
4320 .and_then(Value::as_array)
4321 .filter(|commands| commands.len() <= MAX_COMMANDS)
4322 .ok_or_else(|| "Codewhale returned an invalid command list.".to_string())?;
4323 commands
4324 .iter()
4325 .map(|item| {
4326 let seq = item
4327 .get("seq")
4328 .and_then(Value::as_u64)
4329 .filter(|value| *value > since)
4330 .ok_or_else(|| "Codewhale returned an invalid command sequence.".to_string())?;
4331 let command = item
4332 .get("command")
4333 .filter(|value| value.is_object())
4334 .cloned()
4335 .ok_or_else(|| "Codewhale returned an invalid typed command.".to_string())?;
4336 Ok(ListedCommand {
4337 seq,
4338 command,
4339 ack_status: item
4340 .get("ackStatus")
4341 .and_then(Value::as_str)
4342 .unwrap_or_default()
4343 .to_string(),
4344 })
4345 })
4346 .collect()
4347 }
4348
4349 struct ListedCommand {
4350 seq: u64,
4351 command: Value,
4352 ack_status: String,
4353 }
4354
4355 async fn upload_command_accepted(
4356 client: &Client,
4357 enrollment: &LiveEnrollment,
4358 runner_id: &str,
4359 run_id: &str,
4360 seq: u64,
4361 command: &RemoteCommand,
4362 ) -> Result<(), String> {
4363 runner_request(
4364 client,
4365 enrollment,
4366 Method::POST,
4367 &["api", "local-runners", runner_id, "runs", run_id, "events"],
4368 &[],
4369 Some(json!({
4370 "acknowledgements": [{
4371 "commandSeq": seq,
4372 "commandType": command.kind(),
4373 "status": "accepted",
4374 "turnId": command.turn_id(),
4375 }],
4376 "envelopes": [],
4377 })),
4378 )
4379 .await
4380 .map(|_| ())
4381 }
4382
4383 async fn recover_run(
4384 client: &Client,
4385 enrollment: &LiveEnrollment,
4386 runner_id: &str,
4387 run_id: &str,
4388 reason: &str,
4389 ) -> Result<(), String> {
4390 runner_request(
4391 client,
4392 enrollment,
4393 Method::POST,
4394 &[
4395 "api",
4396 "local-runners",
4397 runner_id,
4398 "runs",
4399 run_id,
4400 "recovery",
4401 ],
4402 &[],
4403 Some(json!({ "reason": reason })),
4404 )
4405 .await
4406 .map(|_| ())
4407 }
4408
4409 fn parse_remote_command(value: &Value, expected_run_id: &str) -> Result<RemoteCommand, String> {
4410 if value.get("runId").and_then(Value::as_str) != Some(expected_run_id) {
4411 return Err("A remote command targeted a different run.".to_string());
4412 }
4413 match value.get("type").and_then(Value::as_str) {
4414 Some("prompt.request") => {
4415 if value.get("images").is_some() && value.get("runtimeBindingId").is_none() {
4416 return Err("Image input is unavailable for legacy remote Work; use native Runtime or Runtime Chat.".to_string());
4417 }
4418 let exact_legacy_prompt = value.as_object().is_some_and(|record| {
4419 record.len() == 4
4420 && ["type", "runId", "turnId", "prompt"]
4421 .iter()
4422 .all(|key| record.contains_key(*key))
4423 });
4424 if !exact_legacy_prompt {
4425 // A managed Chat prompt has a rich deny-unknown schema. Any
4426 // field beyond the exact legacy Work shape must satisfy that
4427 // schema in full; missing binding/version/route/tool fields can
4428 // never downgrade into the permissive Work parser.
4429 let prompt: RuntimeChatPrompt =
4430 serde_json::from_value(value.clone()).map_err(|_| {
4431 "Codewhale sent an invalid Runtime Chat prompt contract.".to_string()
4432 })?;
4433 prompt.validate_shape()?;
4434 if prompt.run_id != expected_run_id {
4435 return Err("A Runtime Chat prompt targeted a different run.".to_string());
4436 }
4437 return Ok(RemoteCommand::RuntimeChatPrompt(Box::new(prompt)));
4438 }
4439 let turn_id = value
4440 .get("turnId")
4441 .and_then(Value::as_str)
4442 .filter(|value| valid_opaque_ref(value))
4443 .ok_or_else(|| "A remote prompt had no valid turn id.".to_string())?;
4444 let prompt = value
4445 .get("prompt")
4446 .and_then(Value::as_str)
4447 .map(str::trim)
4448 .filter(|value| !value.is_empty() && value.len() <= 128 * 1024)
4449 .ok_or_else(|| "A remote prompt was empty or oversized.".to_string())?;
4450 Ok(RemoteCommand::Prompt {
4451 turn_id: turn_id.to_string(),
4452 prompt: prompt.to_string(),
4453 })
4454 }
4455 Some("approval.decision") => {
4456 let exact_approval = value.as_object().is_some_and(|record| {
4457 record.len() == 4
4458 && ["type", "runId", "gate", "decision"]
4459 .iter()
4460 .all(|key| record.contains_key(*key))
4461 });
4462 if !exact_approval {
4463 return Err("Codewhale sent an invalid approval contract.".to_string());
4464 }
4465 let gate = value
4466 .get("gate")
4467 .and_then(Value::as_str)
4468 .filter(|value| valid_opaque_ref(value))
4469 .ok_or_else(|| "A remote approval had no valid gate id.".to_string())?;
4470 let approved = match value.get("decision").and_then(Value::as_str) {
4471 Some("approved") => true,
4472 Some("denied") => false,
4473 _ => return Err("A remote approval had an invalid decision.".to_string()),
4474 };
4475 Ok(RemoteCommand::Approval {
4476 gate: gate.to_string(),
4477 approved,
4478 })
4479 }
4480 Some("run.control") => {
4481 let rich_runtime_control =
4482 value.get("runtimeBindingId").is_some() || value.get("runtimeThreadId").is_some();
4483 if rich_runtime_control {
4484 let record = value
4485 .as_object()
4486 .filter(|record| {
4487 record.len() == 7
4488 && record.contains_key("type")
4489 && record.contains_key("runId")
4490 && record.contains_key("action")
4491 && record.contains_key("reason")
4492 && record.contains_key("turnId")
4493 && record.contains_key("runtimeBindingId")
4494 && record.contains_key("runtimeThreadId")
4495 })
4496 .ok_or_else(|| {
4497 "Codewhale sent an invalid Runtime Chat interrupt contract.".to_string()
4498 })?;
4499 if record.get("action").and_then(Value::as_str) != Some("interrupt") {
4500 return Err("Runtime Chat supports only an exact turn interrupt.".to_string());
4501 }
4502 let reason = record
4503 .get("reason")
4504 .and_then(Value::as_str)
4505 .filter(|reason| {
4506 !reason.trim().is_empty() && reason.len() <= 800 && !reason.contains('\0')
4507 })
4508 .ok_or_else(|| "A Runtime Chat interrupt reason is invalid.".to_string())?;
4509 let _ = reason;
4510 let turn_id = record
4511 .get("turnId")
4512 .and_then(Value::as_str)
4513 .ok_or_else(|| "A Runtime Chat interrupt turn is invalid.".to_string())?
4514 .to_string();
4515 let scope = RuntimeChatControlScope {
4516 runtime_binding_id: record
4517 .get("runtimeBindingId")
4518 .and_then(Value::as_str)
4519 .ok_or_else(|| "A Runtime Chat interrupt binding is invalid.".to_string())?
4520 .to_string(),
4521 runtime_thread_id: record
4522 .get("runtimeThreadId")
4523 .and_then(Value::as_str)
4524 .ok_or_else(|| "A Runtime Chat interrupt thread is invalid.".to_string())?
4525 .to_string(),
4526 };
4527 scope.validate_for_turn(&turn_id)?;
4528 return Ok(RemoteCommand::Control {
4529 action: RemoteControlRequest::Interrupt,
4530 turn_id: Some(turn_id),
4531 runtime_chat: Some(scope),
4532 });
4533 }
4534 let exact_legacy_control = value.as_object().is_some_and(|record| {
4535 record.len() == 4
4536 && ["type", "runId", "action", "turnId"]
4537 .iter()
4538 .all(|key| record.contains_key(*key))
4539 });
4540 if !exact_legacy_control {
4541 return Err("Codewhale sent an invalid run-control contract.".to_string());
4542 }
4543 let action = match value.get("action").and_then(Value::as_str) {
4544 Some("interrupt") => RemoteControlRequest::Interrupt,
4545 Some("cancel") => RemoteControlRequest::Cancel,
4546 _ => return Err("A remote run-control command had an invalid action.".to_string()),
4547 };
4548 let turn_id = value
4549 .get("turnId")
4550 .and_then(Value::as_str)
4551 .filter(|turn_id| valid_opaque_ref(turn_id))
4552 .map(ToString::to_string)
4553 .ok_or_else(|| "A remote run-control command had an invalid turn.".to_string())?;
4554 Ok(RemoteCommand::Control {
4555 action,
4556 turn_id: Some(turn_id),
4557 runtime_chat: None,
4558 })
4559 }
4560 _ => Err("Codewhale sent an unsupported remote command.".to_string()),
4561 }
4562 }
4563
4564 async fn runner_request(
4565 client: &Client,
4566 enrollment: &LiveEnrollment,
4567 method: Method,
4568 segments: &[&str],
4569 query: &[(&str, String)],
4570 body: Option<Value>,
4571 ) -> Result<Value, String> {
4572 let url = control_plane_url(&enrollment.persisted.control_plane_base, segments, query)?;
4573 let mut request = client
4574 .request(method, url)
4575 .bearer_auth(&enrollment.access_token);
4576 if let Some(body) = body {
4577 request = request.json(&body);
4578 }
4579 let response = request
4580 .send()
4581 .await
4582 .map_err(|_| "Remote control lost its secure connection.".to_string())?;
4583 if matches!(
4584 response.status(),
4585 StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
4586 ) {
4587 return Err("runner_access_token_expired".to_string());
4588 }
4589 if !response.status().is_success() {
4590 let status = response.status();
4591 let excerpt = rejection_excerpt(response).await;
4592 return Err(match excerpt {
4593 Some(reason) => {
4594 format!("The remote-control server rejected a request ({status}): {reason}.")
4595 }
4596 None => format!("The remote-control server rejected a request ({status})."),
4597 });
4598 }
4599 let limit = if segments.last() == Some(&"commands") {
4600 codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES
4601 } else {
4602 MAX_RESPONSE_BYTES
4603 };
4604 read_bounded_json_with_limit(response, limit).await
4605 }
4606
4607 async fn public_request(
4608 client: &Client,
4609 method: Method,
4610 url: Url,
4611 body: Value,
4612 ) -> Result<Value, String> {
4613 let response = client
4614 .request(method, url)
4615 .json(&body)
4616 .send()
4617 .await
4618 .map_err(|_| "Remote control could not reach Codewhale.".to_string())?;
4619 if !response.status().is_success() {
4620 let status = response.status();
4621 let excerpt = rejection_excerpt(response).await;
4622 return Err(match excerpt {
4623 Some(reason) => {
4624 format!("Codewhale rejected remote-control enrollment ({status}): {reason}.")
4625 }
4626 None => format!("Codewhale rejected remote-control enrollment ({status})."),
4627 });
4628 }
4629 read_bounded_json(response).await
4630 }
4631
4632 /// Bounded error-body read: at most MAX_RESPONSE_BYTES, so a misbehaving
4633 /// control plane cannot force an unbounded in-memory read through the
4634 /// rejection-excerpt path.
4635 async fn rejection_excerpt(response: reqwest::Response) -> Option<String> {
4636 if response
4637 .content_length()
4638 .is_some_and(|length| length > MAX_RESPONSE_BYTES as u64)
4639 {
4640 return None;
4641 }
4642 let mut body = Vec::new();
4643 let mut response = response;
4644 while let Some(chunk) = response.chunk().await.ok()? {
4645 body.extend_from_slice(&chunk);
4646 if body.len() > MAX_RESPONSE_BYTES {
4647 return None;
4648 }
4649 }
4650 sanitized_rejection_excerpt(&body)
4651 }
4652
4653 /// Sanitized, bounded reason excerpt from a rejection body. Only the
4654 /// conventional error fields are read, control characters are stripped, and
4655 /// the excerpt is capped — raw server bytes are never echoed further.
4656 fn sanitized_rejection_excerpt(body: &[u8]) -> Option<String> {
4657 let parsed: Value = serde_json::from_slice(body).ok()?;
4658 for field in ["error", "message", "title"] {
4659 if let Some(text) = parsed.get(field).and_then(Value::as_str) {
4660 let cleaned: String = text
4661 .chars()
4662 .filter(|character| !character.is_control())
4663 .take(140)
4664 .collect();
4665 let trimmed = cleaned.trim();
4666 if !trimmed.is_empty() {
4667 return Some(trimmed.to_string());
4668 }
4669 }
4670 }
4671 None
4672 }
4673
4674 async fn read_bounded_json(response: reqwest::Response) -> Result<Value, String> {
4675 read_bounded_json_with_limit(response, MAX_RESPONSE_BYTES).await
4676 }
4677
4678 async fn read_bounded_json_with_limit(
4679 mut response: reqwest::Response,
4680 limit: usize,
4681 ) -> Result<Value, String> {
4682 if response
4683 .content_length()
4684 .is_some_and(|length| length > limit as u64)
4685 {
4686 return Err("Codewhale returned an oversized remote-control response.".to_string());
4687 }
4688 let mut bytes = Vec::new();
4689 while let Some(chunk) = response
4690 .chunk()
4691 .await
4692 .map_err(|_| "Codewhale returned an unreadable response.".to_string())?
4693 {
4694 if bytes.len().saturating_add(chunk.len()) > limit {
4695 return Err("Codewhale returned an oversized remote-control response.".to_string());
4696 }
4697 bytes.extend_from_slice(&chunk);
4698 }
4699 serde_json::from_slice(&bytes)
4700 .map_err(|_| "Codewhale returned an invalid remote-control response.".to_string())
4701 }
4702
4703 fn runner_control_plane_base() -> Result<String, String> {
4704 if cfg!(debug_assertions)
4705 && let Ok(value) = std::env::var("CWC_RUNNER_CONTROL_PLANE_BASE")
4706 {
4707 let parsed =
4708 Url::parse(&value).map_err(|_| "The runner control plane is invalid.".to_string())?;
4709 let loopback = parsed.scheme() == "http"
4710 && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
4711 && parsed.path() == "/"
4712 && parsed.query().is_none()
4713 && parsed.fragment().is_none();
4714 if loopback {
4715 return Ok(parsed.to_string());
4716 }
4717 return Err(
4718 "Debug remote control only accepts an explicit loopback control plane.".to_string(),
4719 );
4720 }
4721 Ok(PRODUCTION_CONTROL_PLANE.to_string())
4722 }
4723
4724 fn control_plane_url(
4725 base: &str,
4726 segments: &[&str],
4727 query: &[(&str, String)],
4728 ) -> Result<Url, String> {
4729 let mut url =
4730 Url::parse(base).map_err(|_| "The runner control plane is invalid.".to_string())?;
4731 {
4732 let mut path = url
4733 .path_segments_mut()
4734 .map_err(|_| "The runner control plane is invalid.".to_string())?;
4735 path.pop_if_empty();
4736 for segment in segments {
4737 path.push(segment);
4738 }
4739 }
4740 if !query.is_empty() {
4741 let mut pairs = url.query_pairs_mut();
4742 for (key, value) in query {
4743 pairs.append_pair(key, value);
4744 }
4745 }
4746 Ok(url)
4747 }
4748
4749 fn load_persisted_enrollment() -> Result<Option<PersistedEnrollment>, String> {
4750 let Some(raw) = remote_control_secrets()
4751 .get(ENROLLMENT_SECRET_SLOT)
4752 .map_err(|error| format!("Could not read the saved remote-control enrollment: {error}"))?
4753 else {
4754 return Ok(None);
4755 };
4756 serde_json::from_str(&raw)
4757 .map(Some)
4758 .map_err(|_| "The saved remote-control enrollment is invalid.".to_string())
4759 }
4760
4761 fn save_persisted_enrollment(enrollment: &PersistedEnrollment) -> Result<(), String> {
4762 let raw = serde_json::to_string(enrollment)
4763 .map_err(|_| "Could not encode the remote-control enrollment.".to_string())?;
4764 remote_control_secrets()
4765 .set(ENROLLMENT_SECRET_SLOT, &raw)
4766 .map_err(|error| format!("Could not securely save the remote-control enrollment: {error}"))
4767 }
4768
4769 fn load_persisted_device_identity() -> Result<Option<PersistedDeviceIdentity>, String> {
4770 let Some(raw) = remote_control_secrets()
4771 .get(DEVICE_IDENTITY_SECRET_SLOT)
4772 .map_err(|error| format!("Could not read the saved remote-control device id: {error}"))?
4773 else {
4774 return Ok(None);
4775 };
4776 // An unreadable identity is replaced rather than fatal: the worst case is
4777 // one extra computer row, never a lost session.
4778 Ok(serde_json::from_str(&raw).ok())
4779 }
4780
4781 fn save_persisted_device_identity(identity: &PersistedDeviceIdentity) -> Result<(), String> {
4782 let raw = serde_json::to_string(identity)
4783 .map_err(|_| "Could not encode the remote-control device id.".to_string())?;
4784 remote_control_secrets()
4785 .set(DEVICE_IDENTITY_SECRET_SLOT, &raw)
4786 .map_err(|error| format!("Could not securely save the remote-control device id: {error}"))
4787 }
4788
4789 /// Load (or mint and persist) the machine-stable device id.
4790 fn stable_device_id(enrollment_device_id: Option<&str>) -> Result<String, String> {
4791 let saved = load_persisted_device_identity()?;
4792 let (device_id, needs_save) = resolve_device_identity(saved, enrollment_device_id);
4793 if needs_save {
4794 save_persisted_device_identity(&PersistedDeviceIdentity {
4795 schema_version: 1,
4796 device_id: device_id.clone(),
4797 })?;
4798 }
4799 Ok(device_id)
4800 }
4801
4802 fn delete_persisted_enrollment() {
4803 if let Err(error) = remote_control_secrets().delete(ENROLLMENT_SECRET_SLOT) {
4804 tracing::warn!("could not delete revoked remote-control enrollment: {error}");
4805 }
4806 }
4807
4808 /// Remote-control enrollment/device receipts deliberately use Codewhale's
4809 /// permission-bounded file store even when the operator chose the OS keyring
4810 /// for model-provider credentials. This keeps `/rc` and desktop dogfood from
4811 /// triggering a Keychain access dialog while preserving provider credential
4812 /// custody and its explicit backend selection unchanged.
4813 fn remote_control_secrets() -> codewhale_secrets::Secrets {
4814 codewhale_secrets::Secrets::file_backed()
4815 }
4816
4817 fn install_reconnected_enrollment(
4818 current: &mut LiveEnrollment,
4819 candidate: LiveEnrollment,
4820 start: &RemoteStart,
4821 ) -> Result<(), String> {
4822 if candidate.persisted.account_ref != current.persisted.account_ref
4823 || candidate.persisted.target_ref != current.persisted.target_ref
4824 || candidate.persisted.target_ref != start.target_ref
4825 || candidate.persisted.device_id != current.persisted.device_id
4826 {
4827 return Err(
4828 "Remote control stopped because re-authorization selected a different account or folder. Start a new local session to switch authority."
4829 .to_string(),
4830 );
4831 }
4832 *current = candidate;
4833 Ok(())
4834 }
4835
4836 async fn refresh_enrollment_and_reconnect(
4837 client: &Client,
4838 enrollment: &mut LiveEnrollment,
4839 runner_id: &mut String,
4840 start: &RemoteStart,
4841 event_tx: &mpsc::UnboundedSender<RemoteEvent>,
4842 ) -> Result<(), String> {
4843 let base = enrollment.persisted.control_plane_base.clone();
4844 match refresh_enrollment(client, enrollment.persisted.clone()).await {
4845 Ok(new_enrollment) => {
4846 install_reconnected_enrollment(enrollment, new_enrollment, start)?;
4847 reconnect_runner(client, enrollment, runner_id, start, event_tx).await
4848 }
4849 Err(err) if err == "runner_enrollment_revoked" => {
4850 delete_persisted_enrollment();
4851 let device_id = enrollment.persisted.device_id.clone();
4852 let candidate = enroll_device(client, &base, start, &device_id, event_tx).await?;
4853 if let Err(error) = install_reconnected_enrollment(enrollment, candidate, start) {
4854 delete_persisted_enrollment();
4855 return Err(error);
4856 }
4857 reconnect_runner(client, enrollment, runner_id, start, event_tx).await
4858 }
4859 Err(err) => Err(err),
4860 }
4861 }
4862
4863 async fn reconnect_runner(
4864 client: &Client,
4865 enrollment: &LiveEnrollment,
4866 runner_id: &mut String,
4867 start: &RemoteStart,
4868 event_tx: &mpsc::UnboundedSender<RemoteEvent>,
4869 ) -> Result<(), String> {
4870 let connection = connect_runner(client, enrollment, start).await?;
4871 *runner_id = connection.runner_id;
4872 event_tx
4873 .send(RemoteEvent::Attachment {
4874 account_ref: enrollment.persisted.account_ref.clone(),
4875 target_ref: enrollment.persisted.target_ref.clone(),
4876 attachment: connection.attachment,
4877 links: connection.links,
4878 })
4879 .map_err(|_| "The terminal remote-control owner stopped.".to_string())
4880 }
4881
4882 fn enrollment_needs_refresh(enrollment: &LiveEnrollment) -> bool {
4883 jwt_expiry(&enrollment.access_token)
4884 .is_none_or(|expiry| expiry <= epoch_seconds().saturating_add(60))
4885 }
4886
4887 fn jwt_expiry(token: &str) -> Option<u64> {
4888 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4889 let payload = URL_SAFE_NO_PAD.decode(token.split('.').nth(1)?).ok()?;
4890 serde_json::from_slice::<Value>(&payload)
4891 .ok()?
4892 .get("exp")?
4893 .as_u64()
4894 }
4895
4896 fn access_token(value: &Value) -> Result<String, String> {
4897 let token = value
4898 .get("credential")
4899 .and_then(|value| value.get("accessToken"))
4900 .and_then(Value::as_str)
4901 .filter(|value| {
4902 (64..=8192).contains(&value.len()) && !value.chars().any(char::is_whitespace)
4903 })
4904 .ok_or_else(|| "Codewhale returned an invalid runner access token.".to_string())?
4905 .to_string();
4906 if jwt_expiry(&token).is_none_or(|expiry| expiry <= epoch_seconds()) {
4907 return Err("Codewhale returned an expired runner access token.".to_string());
4908 }
4909 Ok(token)
4910 }
4911
4912 fn exact_capabilities(value: Option<&Value>) -> bool {
4913 let Some(items) = value.and_then(Value::as_array) else {
4914 return false;
4915 };
4916 let mut actual = items.iter().filter_map(Value::as_str).collect::<Vec<_>>();
4917 actual.sort_unstable();
4918 actual == CAPABILITIES
4919 }
4920
4921 fn validate_authorization_url(value: &str, user_code: &str) -> Result<(), String> {
4922 let url = Url::parse(value)
4923 .map_err(|_| "Codewhale returned an invalid authorization URL.".to_string())?;
4924 let pairs = url.query_pairs().collect::<Vec<_>>();
4925 if url.scheme() != "https"
4926 || url.host_str() != Some("app.codewhale.net")
4927 || url.path() != "/runner/authorize"
4928 || url.port().is_some()
4929 || !url.username().is_empty()
4930 || url.password().is_some()
4931 || url.fragment().is_some()
4932 || pairs.len() != 1
4933 || pairs[0].0 != "user_code"
4934 || pairs[0].1 != user_code
4935 {
4936 return Err("Codewhale returned an invalid authorization URL.".to_string());
4937 }
4938 Ok(())
4939 }
4940
4941 fn string_field(value: &Value, field: &str) -> Result<String, String> {
4942 value
4943 .get(field)
4944 .and_then(Value::as_str)
4945 .map(str::trim)
4946 .filter(|value| !value.is_empty() && value.len() <= 2048)
4947 .map(ToString::to_string)
4948 .ok_or_else(|| format!("Codewhale returned an invalid {field}."))
4949 }
4950
4951 fn secret_field(value: &Value, field: &str) -> Result<String, String> {
4952 value
4953 .get(field)
4954 .and_then(Value::as_str)
4955 .filter(|value| valid_secret(value))
4956 .map(ToString::to_string)
4957 .ok_or_else(|| format!("Codewhale returned an invalid {field}."))
4958 }
4959
4960 fn opaque_field(value: &Value, field: &str) -> Result<String, String> {
4961 value
4962 .get(field)
4963 .and_then(Value::as_str)
4964 .filter(|value| valid_opaque_ref(value))
4965 .map(ToString::to_string)
4966 .ok_or_else(|| format!("Codewhale returned an invalid {field}."))
4967 }
4968
4969 fn valid_opaque_ref(value: &str) -> bool {
4970 (3..=160).contains(&value.len())
4971 && value
4972 .bytes()
4973 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
4974 }
4975
4976 fn valid_session_ref(value: &str) -> bool {
4977 (1..=160).contains(&value.len())
4978 && value
4979 .bytes()
4980 .next()
4981 .is_some_and(|byte| byte.is_ascii_alphanumeric())
4982 && value.bytes().all(|byte| {
4983 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':' | b'@')
4984 })
4985 && !value.contains("..")
4986 }
4987
4988 fn valid_secret(value: &str) -> bool {
4989 (32..=8192).contains(&value.len()) && !value.chars().any(char::is_whitespace)
4990 }
4991
4992 fn valid_runtime_version(value: &str) -> bool {
4993 semver::Version::parse(value).is_ok() && value.len() <= 64
4994 }
4995
4996 fn valid_runtime_commit(value: &str) -> bool {
4997 value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
4998 }
4999
5000 fn epoch_seconds() -> u64 {
5001 SystemTime::now()
5002 .duration_since(UNIX_EPOCH)
5003 .unwrap_or_default()
5004 .as_secs()
5005 }
5006
5007 #[cfg(test)]
5008 mod tests {
5009 use super::*;
5010 use codewhale_models::Role;
5011 use std::sync::{
5012 Arc, Mutex,
5013 atomic::{AtomicUsize, Ordering},
5014 };
5015 use wiremock::{
5016 Mock, MockServer, Request, Respond, ResponseTemplate,
5017 matchers::{body_json, method, path, query_param},
5018 };
5019
5020 #[derive(Clone, Default)]
5021 struct AmbiguousRuntimeResponder {
5022 bodies: Arc<Mutex<Vec<Value>>>,
5023 }
5024
5025 #[derive(Clone, Default)]
5026 struct ControlPollCounter {
5027 hits: Arc<AtomicUsize>,
5028 }
5029
5030 impl Respond for ControlPollCounter {
5031 fn respond(&self, _request: &Request) -> ResponseTemplate {
5032 self.hits.fetch_add(1, Ordering::SeqCst);
5033 ResponseTemplate::new(200).set_body_json(json!({ "runs": [] }))
5034 }
5035 }
5036
5037 impl Respond for AmbiguousRuntimeResponder {
5038 fn respond(&self, request: &Request) -> ResponseTemplate {
5039 let body: Value = serde_json::from_slice(&request.body).expect("runtime request JSON");
5040 let mut bodies = self.bodies.lock().expect("runtime request bodies");
5041 bodies.push(body);
5042 if bodies.len() == 1 {
5043 // Model a committed request whose response was truncated in
5044 // transit. The client must keep the exact body and retry.
5045 ResponseTemplate::new(200).set_body_raw("{", "application/json")
5046 } else {
5047 ResponseTemplate::new(200).set_body_json(json!({
5048 "accepted": [],
5049 "count": 0,
5050 "cursor": 1
5051 }))
5052 }
5053 }
5054 }
5055
5056 fn text_message(role: &str, text: impl Into<String>) -> Message {
5057 Message {
5058 role: Role::from(role),
5059 content: vec![ContentBlock::Text {
5060 text: text.into(),
5061 cache_control: None,
5062 }],
5063 }
5064 }
5065
5066 fn fixture_start() -> RemoteStart {
5067 RemoteStart {
5068 workspace_label: "private-project".to_string(),
5069 target_ref: "target_fixture".to_string(),
5070 session_id: "session:fixture@01".to_string(),
5071 runtime_version: "0.9.6".to_string(),
5072 runtime_commit: "a".repeat(40),
5073 journal_dir: None,
5074 git_remote: None,
5075 }
5076 }
5077
5078 fn fixture_enrollment(base: &str) -> LiveEnrollment {
5079 LiveEnrollment {
5080 persisted: PersistedEnrollment {
5081 schema_version: 1,
5082 control_plane_base: base.to_string(),
5083 runner_enrollment_id: "enrollment_fixture".to_string(),
5084 account_ref: "account_fixture".to_string(),
5085 device_id: "device_fixture".to_string(),
5086 target_ref: "target_fixture".to_string(),
5087 target_grant_ref: "grant_fixture".to_string(),
5088 runtime_version: "0.9.6".to_string(),
5089 runtime_commit: "a".repeat(40),
5090 bootstrap_secret: "b".repeat(43),
5091 },
5092 access_token: "fixture-runner-access-token".to_string(),
5093 }
5094 }
5095
5096 fn fixture_connection_response() -> Value {
5097 json!({
5098 "runner": {
5099 "id": "runner_fixture",
5100 "userId": "account_fixture",
5101 "deviceId": "device_fixture",
5102 "targetRef": "target_fixture",
5103 "displayLabel": "private-project",
5104 "runtimeVersion": "0.9.6",
5105 "runtimeCommit": "a".repeat(40),
5106 "capabilities": CAPABILITIES,
5107 "controlPath": "outbound_relay",
5108 "status": "active",
5109 "active": true,
5110 "capacity": 1,
5111 "lastHeartbeatAt": "2026-08-08T12:00:00.000Z",
5112 "expiresAt": "2026-08-08T12:01:30.000Z",
5113 "revokedAt": "",
5114 "createdAt": "2026-08-08T12:00:00.000Z",
5115 "updatedAt": "2026-08-08T12:00:00.000Z"
5116 },
5117 "attachment": {
5118 "runId": "run_fixture",
5119 "workspaceId": "workspace_fixture",
5120 "runtimeCursor": 41,
5121 "snapshotPresent": false,
5122 "runtimeChatRelayProtocol": RUNTIME_CHAT_RELAY_PROTOCOL,
5123 "runtimeChatRelayChallenge": "a".repeat(32)
5124 }
5125 })
5126 }
5127
5128 #[test]
5129 fn observed_git_repo_is_owner_name_not_a_path() {
5130 assert_eq!(
5131 normalize_observed_git_repo("git@github.com:Hmbown/CodeWhale.git").as_deref(),
5132 Some("Hmbown/CodeWhale")
5133 );
5134 assert_eq!(
5135 normalize_observed_git_repo("https://github.com/Hmbown/cwc.git").as_deref(),
5136 Some("Hmbown/cwc")
5137 );
5138 assert_eq!(
5139 normalize_observed_git_repo("/Volumes/VIXinSSD/CW/codewhale"),
5140 None
5141 );
5142 for private_or_untrusted in [
5143 "file:///Users/alice/PrivateProject.git",
5144 "https://internal.example/acme/secret-repo.git",
5145 "https://alice:password@github.com/acme/repo.git",
5146 "https://github.com/acme/repo.git?token=secret",
5147 "ssh://git@github.com/acme/repo.git",
5148 "acme/secret-repo",
5149 ] {
5150 assert_eq!(
5151 normalize_observed_git_repo(private_or_untrusted),
5152 None,
5153 "untrusted git identity must not cross: {private_or_untrusted}"
5154 );
5155 }
5156 }
5157
5158 #[test]
5159 fn connect_body_can_carry_an_observed_repo_without_a_path() {
5160 let enrollment = fixture_enrollment("https://api.codewhale.net/");
5161 let mut start = fixture_start();
5162 start.git_remote = Some("git@github.com:Hmbown/CodeWhale.git".to_string());
5163 let body = connect_runner_body(&enrollment, &start);
5164 assert_eq!(body["gitRemote"], "Hmbown/CodeWhale");
5165 assert!(body.get("workspacePath").is_none());
5166 assert!(body.get("path").is_none());
5167 }
5168
5169 #[test]
5170 fn live_worker_rejects_reenrollment_authority_switch_before_assignment() {
5171 let start = fixture_start();
5172 let mut current = fixture_enrollment("https://api.codewhale.net/");
5173 let original_access_token = current.access_token.clone();
5174 let mut other_account = current.clone();
5175 other_account.persisted.account_ref = "account_other".to_string();
5176 other_account.access_token = "other-account-access-token".to_string();
5177 let error = install_reconnected_enrollment(&mut current, other_account, &start)
5178 .expect_err("a live worker must never change tenant authority");
5179 assert!(error.contains("different account or folder"));
5180 assert_eq!(current.persisted.account_ref, "account_fixture");
5181 assert_eq!(current.persisted.target_ref, "target_fixture");
5182 assert_eq!(current.access_token, original_access_token);
5183
5184 let mut other_target = current.clone();
5185 other_target.persisted.target_ref = "target_other".to_string();
5186 assert!(install_reconnected_enrollment(&mut current, other_target, &start).is_err());
5187 assert_eq!(current.persisted.target_ref, "target_fixture");
5188
5189 let mut refreshed = current.clone();
5190 refreshed.access_token = "same-authority-refreshed-access-token".to_string();
5191 install_reconnected_enrollment(&mut current, refreshed, &start).unwrap();
5192 assert_eq!(
5193 current.access_token,
5194 "same-authority-refreshed-access-token"
5195 );
5196 }
5197
5198 #[test]
5199 fn target_identity_is_stable_without_exposing_the_path() {
5200 let target = target_ref(Path::new("/Users/alice/private/project"));
5201 assert!(target.starts_with("target_"));
5202 assert_eq!(target.len(), 39);
5203 assert!(!target.contains("alice"));
5204 // Every session opened in the same folder shares one target, so the
5205 // control plane keeps one grant per folder rather than one per `/rc`.
5206 assert_eq!(
5207 target,
5208 target_ref(Path::new("/Users/alice/private/project"))
5209 );
5210 assert_ne!(target, target_ref(Path::new("/Users/alice/private/other")));
5211 }
5212
5213 #[test]
5214 fn runtime_chat_same_session_reconfigure_releases_then_reacquires_owner_lock() {
5215 let root = tempfile::tempdir().unwrap();
5216 let registry = Arc::new(crate::plugins::PluginRegistry::empty(root.path()));
5217 let mut controller = RemoteControlController::default();
5218 let old_config = crate::config::Config {
5219 provider: Some("ollama".to_string()),
5220 default_text_model: Some("old-safe-model".to_string()),
5221 ..crate::config::Config::default()
5222 };
5223 controller
5224 .configure_runtime_chat(
5225 old_config,
5226 Arc::clone(&registry),
5227 root.path().join("runtime-chat"),
5228 "target_fixture".to_string(),
5229 "session_fixture".to_string(),
5230 )
5231 .unwrap();
5232 assert_eq!(
5233 controller
5234 .runtime_chat
5235 .as_ref()
5236 .unwrap()
5237 .configured_default_model_for_tests(),
5238 "old-safe-model"
5239 );
5240
5241 let mut competing = RemoteControlController::default();
5242 assert!(
5243 competing
5244 .configure_runtime_chat(
5245 crate::config::Config::default(),
5246 Arc::clone(&registry),
5247 root.path().join("runtime-chat"),
5248 "target_fixture".to_string(),
5249 "session_fixture".to_string(),
5250 )
5251 .is_err()
5252 );
5253
5254 let new_config = crate::config::Config {
5255 provider: Some("ollama".to_string()),
5256 default_text_model: Some("new-safe-model".to_string()),
5257 ..crate::config::Config::default()
5258 };
5259 controller
5260 .configure_runtime_chat(
5261 new_config,
5262 Arc::clone(&registry),
5263 root.path().join("runtime-chat"),
5264 "target_fixture".to_string(),
5265 "session_fixture".to_string(),
5266 )
5267 .unwrap();
5268 assert_eq!(
5269 controller
5270 .runtime_chat
5271 .as_ref()
5272 .unwrap()
5273 .configured_default_model_for_tests(),
5274 "new-safe-model",
5275 "a settled same-scope restart must refresh immutable provider configuration"
5276 );
5277 drop(controller);
5278 competing
5279 .configure_runtime_chat(
5280 crate::config::Config::default(),
5281 registry,
5282 root.path().join("runtime-chat"),
5283 "target_fixture".to_string(),
5284 "session_fixture".to_string(),
5285 )
5286 .unwrap();
5287 }
5288
5289 #[test]
5290 fn runtime_chat_backlog_refreshes_immutable_config_before_new_catalog_or_prompt() {
5291 let root = tempfile::tempdir().unwrap();
5292 let registry = Arc::new(crate::plugins::PluginRegistry::empty(root.path()));
5293 let mut controller = RemoteControlController::default();
5294 let old_config = crate::config::Config {
5295 provider: Some("ollama".to_string()),
5296 default_text_model: Some("old-safe-model".to_string()),
5297 ..crate::config::Config::default()
5298 };
5299 controller
5300 .configure_runtime_chat(
5301 old_config,
5302 Arc::clone(&registry),
5303 root.path().join("runtime-chat"),
5304 "target-1".to_string(),
5305 "session-1".to_string(),
5306 )
5307 .unwrap();
5308 controller
5309 .runtime_chat
5310 .as_ref()
5311 .unwrap()
5312 .bind_account("account-1", "target-1")
5313 .unwrap();
5314 controller.journal = Some(
5315 RuntimeEventJournal::open(root.path(), "target-1", "session-1")
5316 .expect("runtime chat delivery journal"),
5317 );
5318
5319 let pending_seq = controller.next_runtime_seq("run-1");
5320 assert!(controller.queue_runtime_chat_envelope(
5321 "run-1",
5322 runtime_chat_envelope(
5323 pending_seq,
5324 "runtime.catalog",
5325 None,
5326 None,
5327 Some("catalog_old_fixture"),
5328 RUNTIME_CHAT_CATALOG_TIMESTAMP.to_string(),
5329 json!({ "old": true }),
5330 ),
5331 ));
5332 let new_config = crate::config::Config {
5333 provider: Some("ollama".to_string()),
5334 default_text_model: Some("new-safe-model".to_string()),
5335 ..crate::config::Config::default()
5336 };
5337 controller
5338 .configure_runtime_chat(
5339 new_config,
5340 Arc::clone(&registry),
5341 root.path().join("runtime-chat"),
5342 "target-1".to_string(),
5343 "session-1".to_string(),
5344 )
5345 .unwrap();
5346 assert!(controller.pending_runtime_chat_configuration.is_some());
5347 assert_eq!(
5348 controller
5349 .runtime_chat
5350 .as_ref()
5351 .unwrap()
5352 .configured_default_model_for_tests(),
5353 "old-safe-model",
5354 "the old host remains only as the durable backlog owner"
5355 );
5356
5357 let (worker_tx, mut worker_rx) = mpsc::unbounded_channel();
5358 let (event_tx, event_rx) = mpsc::unbounded_channel();
5359 controller.worker_tx = Some(worker_tx);
5360 controller.event_rx = Some(event_rx);
5361 controller.status = Status::Connected;
5362 controller.account_ref = Some("account-1".to_string());
5363 controller.target_ref = Some("target-1".to_string());
5364 controller.attached_run_id = Some("run-1".to_string());
5365 controller.attached_workspace_id = Some("workspace-1".to_string());
5366 controller.runtime_chat_attachment = Some(RemoteAttachment {
5367 run_id: "run-1".to_string(),
5368 workspace_id: "workspace-1".to_string(),
5369 runtime_cursor: pending_seq,
5370 snapshot_present: false,
5371 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
5372 runtime_chat_relay_challenge: "c".repeat(32),
5373 });
5374 let command = parse_remote_command(&runtime_chat_prompt_fixture(), "run-1").unwrap();
5375 assert!(
5376 controller.claim_command("run-1", 1, &command).is_err(),
5377 "no newly configured prompt may reach the stale host while backlog drains"
5378 );
5379
5380 event_tx
5381 .send(RemoteEvent::RuntimeCursor {
5382 run_id: "run-1".to_string(),
5383 cursor: pending_seq,
5384 })
5385 .unwrap();
5386 assert!(matches!(
5387 controller.try_next_event(),
5388 Some(RemoteEvent::RuntimeCursor { .. })
5389 ));
5390 assert!(matches!(
5391 worker_rx.try_recv().unwrap(),
5392 WorkerCommand::ReleaseRuntimeChatHost
5393 ));
5394
5395 event_tx.send(RemoteEvent::RuntimeChatHostReleased).unwrap();
5396 assert!(matches!(
5397 controller.try_next_event(),
5398 Some(RemoteEvent::RuntimeChatHostReleased)
5399 ));
5400 assert_eq!(
5401 controller
5402 .runtime_chat
5403 .as_ref()
5404 .unwrap()
5405 .configured_default_model_for_tests(),
5406 "new-safe-model"
5407 );
5408 assert!(matches!(
5409 worker_rx.try_recv().unwrap(),
5410 WorkerCommand::InstallRuntimeChatHost(_)
5411 ));
5412 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
5413 panic!("the refreshed host must upload a new challenge-bound catalog");
5414 };
5415 assert_eq!(envelopes.len(), 1);
5416 assert_eq!(envelopes[0]["event"], "runtime.catalog");
5417 let serialized = envelopes[0].to_string();
5418 assert!(serialized.contains("new-safe-model"));
5419 assert!(!serialized.contains("old-safe-model"));
5420 assert!(controller.pending_runtime_chat_configuration.is_none());
5421 }
5422
5423 #[tokio::test]
5424 async fn runtime_chat_provider_ownership_releases_only_after_terminal_server_cursor() {
5425 let root = tempfile::tempdir().unwrap();
5426 let mut controller = RemoteControlController::default();
5427 controller
5428 .configure_runtime_chat(
5429 crate::config::Config::default(),
5430 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5431 root.path().join("runtime-chat"),
5432 "target_fixture".to_string(),
5433 "session_fixture".to_string(),
5434 )
5435 .unwrap();
5436 let host = controller.runtime_chat.as_ref().unwrap().clone();
5437 controller.journal = Some(
5438 RuntimeEventJournal::open(root.path(), "target_fixture", "session_fixture")
5439 .expect("runtime chat delivery journal"),
5440 );
5441 host.bind_account("account_fixture", "target_fixture")
5442 .unwrap();
5443 let native_thread_id = "thr_terminal_gate_fixture";
5444 let virtual_thread_id = format!("local_thread_{}", "a".repeat(24));
5445 let virtual_turn_id = format!("local_turn_{}", "b".repeat(24));
5446 host.install_unsettled_turn_for_tests(
5447 "run_fixture",
5448 native_thread_id,
5449 &virtual_thread_id,
5450 &virtual_turn_id,
5451 )
5452 .unwrap();
5453 host.acquire_inference_ownership_for_tests().await;
5454 assert!(host.inference_ownership_is_held_for_tests());
5455
5456 let seq = controller.next_runtime_seq("run_fixture");
5457 assert!(controller.queue_runtime_chat_envelope(
5458 "run_fixture",
5459 runtime_chat_envelope(
5460 seq,
5461 "turn.completed",
5462 Some(&virtual_thread_id),
5463 Some(&virtual_turn_id),
5464 Some("native_event_terminal_gate_fixture"),
5465 "2026-08-23T00:00:00Z".to_string(),
5466 json!({ "status": "completed" }),
5467 ),
5468 ));
5469 host.mark_projected(native_thread_id, 1, &virtual_turn_id, "turn.completed")
5470 .unwrap();
5471 assert!(!host.has_any_unsettled_turns());
5472 controller.reconcile_runtime_cursor("run_fixture", seq.saturating_sub(1));
5473 assert!(
5474 host.inference_ownership_is_held_for_tests(),
5475 "local terminal projection is not a server acknowledgement"
5476 );
5477 controller.reconcile_runtime_cursor("run_fixture", seq);
5478 assert!(
5479 !host.inference_ownership_is_held_for_tests(),
5480 "the accepted server cursor releases attached-run provider ownership"
5481 );
5482 }
5483
5484 #[tokio::test]
5485 async fn restart_rehydrates_runtime_chat_ownership_until_terminal_cursor() {
5486 let root = tempfile::tempdir().unwrap();
5487 let runtime_root = root.path().join("runtime-chat");
5488 let journal_root = root.path().join("journal");
5489 std::fs::create_dir_all(&journal_root).unwrap();
5490 let terminal_seq;
5491 {
5492 let mut first = RemoteControlController::default();
5493 first
5494 .configure_runtime_chat(
5495 crate::config::Config::default(),
5496 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5497 runtime_root.clone(),
5498 "target_fixture".to_string(),
5499 "session_fixture".to_string(),
5500 )
5501 .unwrap();
5502 first.journal = Some(
5503 RuntimeEventJournal::open(&journal_root, "target_fixture", "session_fixture")
5504 .unwrap(),
5505 );
5506 let host = first.runtime_chat.as_ref().unwrap().clone();
5507 host.bind_account("account_fixture", "target_fixture")
5508 .unwrap();
5509 let native_thread_id = "thr_restart_terminal_gate";
5510 let virtual_thread_id = format!("local_thread_{}", "c".repeat(24));
5511 let virtual_turn_id = format!("local_turn_{}", "d".repeat(24));
5512 host.install_unsettled_turn_for_tests(
5513 "run_fixture",
5514 native_thread_id,
5515 &virtual_thread_id,
5516 &virtual_turn_id,
5517 )
5518 .unwrap();
5519 host.acquire_inference_ownership_for_tests().await;
5520 terminal_seq = first.next_runtime_seq("run_fixture");
5521 assert!(first.queue_runtime_chat_envelope(
5522 "run_fixture",
5523 runtime_chat_envelope(
5524 terminal_seq,
5525 "turn.completed",
5526 Some(&virtual_thread_id),
5527 Some(&virtual_turn_id),
5528 Some("native_event_restart_terminal_gate"),
5529 "2026-08-23T00:00:00Z".to_string(),
5530 json!({ "turn": { "status": "completed" } }),
5531 ),
5532 ));
5533 host.mark_projected(native_thread_id, 1, &virtual_turn_id, "turn.completed")
5534 .unwrap();
5535 assert!(!host.has_any_unsettled_turns());
5536 }
5537
5538 let mut reopened = RemoteControlController::default();
5539 reopened
5540 .configure_runtime_chat(
5541 crate::config::Config::default(),
5542 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5543 runtime_root,
5544 "target_fixture".to_string(),
5545 "session_fixture".to_string(),
5546 )
5547 .unwrap();
5548 let journal =
5549 RuntimeEventJournal::open(&journal_root, "target_fixture", "session_fixture").unwrap();
5550 reopened.reset_pending_from(journal.load().unwrap());
5551 reopened.journal = Some(journal);
5552 reopened
5553 .recover_runtime_chat_ownership_before_worker()
5554 .unwrap();
5555
5556 let mut participant = tokio::spawn(async {
5557 crate::client::acquire_remote_control_inference_participant().await
5558 });
5559 assert!(
5560 tokio::time::timeout(Duration::from_millis(40), &mut participant)
5561 .await
5562 .is_err(),
5563 "restart recovery must retain the provider writer before CWC cursor ack"
5564 );
5565 reopened.reconcile_runtime_cursor("run_fixture", terminal_seq);
5566 let permit = tokio::time::timeout(Duration::from_secs(1), participant)
5567 .await
5568 .expect("participant resumes after cursor")
5569 .expect("participant task");
5570 drop(permit);
5571 }
5572
5573 #[tokio::test]
5574 async fn restart_reclaims_unsettled_runtime_chat_before_worker_or_fails_closed() {
5575 let root = tempfile::tempdir().unwrap();
5576 let runtime_root = root.path().join("runtime-chat");
5577 let native_thread_id = "thr_restart_unsettled_gate";
5578 let virtual_thread_id = format!("local_thread_{}", "e".repeat(24));
5579 let virtual_turn_id = format!("local_turn_{}", "f".repeat(24));
5580 {
5581 let mut first = RemoteControlController::default();
5582 first
5583 .configure_runtime_chat(
5584 crate::config::Config::default(),
5585 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5586 runtime_root.clone(),
5587 "target_fixture".to_string(),
5588 "session_fixture".to_string(),
5589 )
5590 .unwrap();
5591 let host = first.runtime_chat.as_ref().unwrap();
5592 host.bind_account("account_fixture", "target_fixture")
5593 .unwrap();
5594 host.install_unsettled_turn_for_tests(
5595 "run_fixture",
5596 native_thread_id,
5597 &virtual_thread_id,
5598 &virtual_turn_id,
5599 )
5600 .unwrap();
5601 }
5602
5603 let mut reopened = RemoteControlController::default();
5604 reopened
5605 .configure_runtime_chat(
5606 crate::config::Config::default(),
5607 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5608 runtime_root,
5609 "target_fixture".to_string(),
5610 "session_fixture".to_string(),
5611 )
5612 .unwrap();
5613 let host = reopened.runtime_chat.as_ref().unwrap().clone();
5614 assert!(host.has_any_unsettled_turns());
5615
5616 let participant = crate::client::acquire_remote_control_inference_participant().await;
5617 let error = reopened
5618 .recover_runtime_chat_ownership_before_worker()
5619 .unwrap_err();
5620 assert!(error.contains("active local turn"), "{error}");
5621 assert!(!host.inference_ownership_is_held_for_tests());
5622 drop(participant);
5623
5624 reopened
5625 .recover_runtime_chat_ownership_before_worker()
5626 .unwrap();
5627 let mut blocked = tokio::spawn(async {
5628 crate::client::acquire_remote_control_inference_participant().await
5629 });
5630 assert!(
5631 tokio::time::timeout(Duration::from_millis(40), &mut blocked)
5632 .await
5633 .is_err(),
5634 "an unsettled recovered turn must own the gate before Connected"
5635 );
5636 host.mark_projected(native_thread_id, 1, &virtual_turn_id, "turn.completed")
5637 .unwrap();
5638 host.release_inference_ownership_if_settled();
5639 let permit = tokio::time::timeout(Duration::from_secs(1), blocked)
5640 .await
5641 .expect("participant resumes after durable terminal settlement")
5642 .expect("participant task");
5643 drop(permit);
5644 }
5645
5646 #[tokio::test(flavor = "current_thread")]
5647 async fn actual_start_reclaims_runtime_chat_writer_before_worker_spawn() {
5648 let root = tempfile::tempdir().unwrap();
5649 let runtime_root = root.path().join("runtime-chat");
5650 let journal_root = root.path().join("journal");
5651 let native_thread_id = "thr_actual_start_unsettled";
5652 let virtual_thread_id = format!("local_thread_{}", "c".repeat(24));
5653 let virtual_turn_id = format!("local_turn_{}", "d".repeat(24));
5654 {
5655 let mut first = RemoteControlController::default();
5656 first
5657 .prepare_remote_control_session_journal(
5658 &journal_root,
5659 "target_fixture",
5660 "session_fixture",
5661 )
5662 .unwrap();
5663 first
5664 .configure_runtime_chat(
5665 crate::config::Config::default(),
5666 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5667 runtime_root.clone(),
5668 "target_fixture".to_string(),
5669 "session_fixture".to_string(),
5670 )
5671 .unwrap();
5672 first
5673 .runtime_chat
5674 .as_ref()
5675 .unwrap()
5676 .bind_account("account_fixture", "target_fixture")
5677 .unwrap();
5678 first
5679 .runtime_chat
5680 .as_ref()
5681 .unwrap()
5682 .install_unsettled_turn_for_tests(
5683 "run_fixture",
5684 native_thread_id,
5685 &virtual_thread_id,
5686 &virtual_turn_id,
5687 )
5688 .unwrap();
5689 }
5690
5691 let mut reopened = RemoteControlController::default();
5692 reopened
5693 .prepare_remote_control_session_journal(
5694 &journal_root,
5695 "target_fixture",
5696 "session_fixture",
5697 )
5698 .unwrap();
5699 reopened
5700 .configure_runtime_chat(
5701 crate::config::Config::default(),
5702 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5703 runtime_root,
5704 "target_fixture".to_string(),
5705 "session_fixture".to_string(),
5706 )
5707 .unwrap();
5708 let host = reopened.runtime_chat.as_ref().unwrap().clone();
5709 let start = RemoteStart {
5710 session_id: "session_fixture".to_string(),
5711 journal_dir: Some(journal_root),
5712 ..fixture_start()
5713 };
5714
5715 let participant = crate::client::acquire_remote_control_inference_participant().await;
5716 let error = reopened.start(start.clone()).unwrap_err();
5717 assert!(error.contains("active local turn"), "{error}");
5718 assert!(reopened.worker.is_none());
5719 assert_eq!(reopened.status, Status::Off);
5720 drop(participant);
5721
5722 reopened.start(start).unwrap();
5723 assert!(host.inference_ownership_is_held_for_tests());
5724 assert_eq!(reopened.status, Status::Connecting);
5725 assert!(reopened.worker.is_some());
5726 // This current-thread test has not yielded since spawning the worker,
5727 // so abort it before enrollment can perform any network I/O.
5728 reopened.stop_worker();
5729 host.mark_projected(native_thread_id, 1, &virtual_turn_id, "turn.completed")
5730 .unwrap();
5731 host.release_inference_ownership_if_settled();
5732 assert!(!host.inference_ownership_is_held_for_tests());
5733 }
5734
5735 #[test]
5736 fn aborting_a_relay_worker_releases_unjournaled_projection_claims() {
5737 let root = tempfile::tempdir().unwrap();
5738 let mut controller = RemoteControlController::default();
5739 controller
5740 .configure_runtime_chat(
5741 crate::config::Config::default(),
5742 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
5743 root.path().join("runtime-chat"),
5744 "target_fixture".to_string(),
5745 "session_fixture".to_string(),
5746 )
5747 .unwrap();
5748 let host = controller.runtime_chat.as_ref().unwrap().clone();
5749 host.install_projection_claim_for_tests("thr_claimed", 7);
5750 assert!(host.projection_is_claimed_for_tests("thr_claimed", 7));
5751
5752 controller.stop_worker();
5753
5754 assert!(!host.projection_is_claimed_for_tests("thr_claimed", 7));
5755 }
5756
5757 #[tokio::test]
5758 async fn idle_runtime_chat_ticks_cannot_starve_the_control_poll() {
5759 if !cfg!(debug_assertions) {
5760 return;
5761 }
5762 let _env = crate::test_support::lock_test_env();
5763 let secrets_root = tempfile::tempdir().expect("isolated remote-control secrets");
5764 let _codewhale_home =
5765 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", secrets_root.path());
5766 let server = MockServer::start().await;
5767 let plane = format!("{}/", server.uri().trim_end_matches('/'));
5768 let _control_plane =
5769 crate::test_support::EnvVarGuard::set("CWC_RUNNER_CONTROL_PLANE_BASE", &plane);
5770 let base = runner_control_plane_base().expect("loopback control plane");
5771 save_persisted_enrollment(&fixture_enrollment(&base).persisted)
5772 .expect("persist matching enrollment");
5773
5774 let access_token = crate::test_support::future_test_jwt(&"a".repeat(40));
5775 Mock::given(method("POST"))
5776 .and(path("/api/runner/enrollments/token"))
5777 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
5778 "enrollment": {
5779 "id": "enrollment_fixture",
5780 "userId": "account_fixture",
5781 "deviceId": "device_fixture",
5782 "runtimeVersion": "0.9.6",
5783 "runtimeCommit": "a".repeat(40),
5784 "capabilities": CAPABILITIES,
5785 },
5786 "credential": { "accessToken": access_token },
5787 })))
5788 .mount(&server)
5789 .await;
5790 Mock::given(method("POST"))
5791 .and(path("/api/local-runners/connect"))
5792 .respond_with(ResponseTemplate::new(200).set_body_json(fixture_connection_response()))
5793 .mount(&server)
5794 .await;
5795 Mock::given(method("POST"))
5796 .and(path("/api/local-runners/runner_fixture/heartbeat"))
5797 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
5798 .mount(&server)
5799 .await;
5800 let polls = ControlPollCounter::default();
5801 Mock::given(method("GET"))
5802 .and(path("/api/local-runners/runner_fixture/runs"))
5803 .respond_with(polls.clone())
5804 .mount(&server)
5805 .await;
5806
5807 let runtime_root = tempfile::tempdir().expect("idle Runtime Chat host");
5808 let host = RuntimeChatRelayHost::open(
5809 crate::config::Config::default(),
5810 Arc::new(crate::plugins::PluginRegistry::empty(runtime_root.path())),
5811 runtime_root.path().to_path_buf(),
5812 "target_fixture".to_string(),
5813 "session_fixture".to_string(),
5814 )
5815 .expect("open idle Runtime Chat host");
5816 let start = fixture_start();
5817 let (_worker_tx, worker_rx) = mpsc::unbounded_channel();
5818 let (event_tx, mut event_rx) = mpsc::unbounded_channel();
5819 let worker = tokio::spawn(async move {
5820 let mut phase = RelayPhase::Enrolling;
5821 relay_worker(start, Some(host), worker_rx, event_tx, &mut phase).await
5822 });
5823
5824 tokio::time::timeout(Duration::from_secs(5), async {
5825 loop {
5826 match event_rx.recv().await {
5827 Some(RemoteEvent::Connected { .. }) => break,
5828 Some(RemoteEvent::Notice(_)) => {}
5829 Some(RemoteEvent::Failed(error)) => {
5830 panic!("relay worker failed before attach: {error}");
5831 }
5832 Some(RemoteEvent::FailedPreLease(error)) => {
5833 panic!("relay worker failed before attach: {error}");
5834 }
5835 Some(_) => panic!("relay worker emitted an unexpected event before attach"),
5836 None => panic!("relay worker stopped before attach"),
5837 }
5838 }
5839 })
5840 .await
5841 .expect("relay worker should attach");
5842
5843 tokio::time::sleep(RUNTIME_UPLOAD_RETRY_INTERVAL.saturating_mul(2)).await;
5844 assert_eq!(
5845 polls.hits.load(Ordering::SeqCst),
5846 0,
5847 "the first control poll must still wait SYNC_INTERVAL while idle chat ticks fire"
5848 );
5849 tokio::time::sleep(SYNC_INTERVAL).await;
5850 assert!(
5851 polls.hits.load(Ordering::SeqCst) >= 1,
5852 "idle Runtime Chat ticks must not reset the control poll"
5853 );
5854 worker.abort();
5855 let _ = worker.await;
5856 }
5857
5858 #[test]
5859 fn preattachment_failure_cannot_release_a_recovered_unsettled_turn() {
5860 let root = tempfile::tempdir().unwrap();
5861 let registry = Arc::new(crate::plugins::PluginRegistry::empty(root.path()));
5862 let mut controller = RemoteControlController::default();
5863 controller
5864 .configure_runtime_chat(
5865 crate::config::Config::default(),
5866 Arc::clone(&registry),
5867 root.path().join("runtime-chat"),
5868 "target_fixture".to_string(),
5869 "session_fixture".to_string(),
5870 )
5871 .unwrap();
5872 let host = controller.runtime_chat.as_ref().unwrap();
5873 host.bind_account("account_fixture", "target_fixture")
5874 .unwrap();
5875 host.install_unsettled_turn_for_tests(
5876 "run_old",
5877 "thr_recovered",
5878 &format!("local_thread_{}", "a".repeat(24)),
5879 &format!("local_turn_{}", "b".repeat(24)),
5880 )
5881 .unwrap();
5882 let retained_model = host.configured_default_model_for_tests();
5883 controller
5884 .configure_runtime_chat(
5885 crate::config::Config {
5886 provider: Some("ollama".to_string()),
5887 default_text_model: Some("must-not-replace-live-host".to_string()),
5888 ..crate::config::Config::default()
5889 },
5890 Arc::clone(&registry),
5891 root.path().join("runtime-chat"),
5892 "target_fixture".to_string(),
5893 "session_fixture".to_string(),
5894 )
5895 .unwrap();
5896 assert_eq!(
5897 controller
5898 .runtime_chat
5899 .as_ref()
5900 .unwrap()
5901 .configured_default_model_for_tests(),
5902 retained_model,
5903 "an unsettled same-scope turn must retain its lifetime manager"
5904 );
5905 controller.status = Status::Failed;
5906 controller.ownership_blocked_until = Some(Instant::now() - Duration::from_millis(1));
5907
5908 assert!(controller.try_next_event().is_none());
5909 assert_eq!(controller.status, Status::Failed);
5910 assert!(controller.runtime_chat.is_some());
5911 assert!(
5912 controller
5913 .configure_runtime_chat(
5914 crate::config::Config::default(),
5915 registry,
5916 root.path().join("runtime-chat"),
5917 "target_other".to_string(),
5918 "session_other".to_string(),
5919 )
5920 .is_err()
5921 );
5922 }
5923
5924 #[test]
5925 fn runtime_chat_reenrollment_rejects_attachment_from_a_different_account() {
5926 let root = tempfile::tempdir().unwrap();
5927 let registry = Arc::new(crate::plugins::PluginRegistry::empty(root.path()));
5928 let (mut controller, _worker_rx, event_tx, _journal_root) = wired_controller();
5929 controller
5930 .configure_runtime_chat(
5931 crate::config::Config::default(),
5932 registry,
5933 root.path().join("runtime-chat"),
5934 "target_fixture".to_string(),
5935 "session_fixture".to_string(),
5936 )
5937 .unwrap();
5938 controller
5939 .runtime_chat
5940 .as_ref()
5941 .unwrap()
5942 .bind_account("account_a", "target_fixture")
5943 .unwrap();
5944 controller.account_ref = Some("account_a".to_string());
5945 controller.target_ref = Some("target_fixture".to_string());
5946 controller.attached_run_id = Some("run_account_a".to_string());
5947
5948 event_tx
5949 .send(RemoteEvent::Attachment {
5950 account_ref: "account_b".to_string(),
5951 target_ref: "target_fixture".to_string(),
5952 attachment: RemoteAttachment {
5953 run_id: "run_account_b".to_string(),
5954 workspace_id: "workspace_account_b".to_string(),
5955 runtime_cursor: 0,
5956 snapshot_present: false,
5957 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
5958 runtime_chat_relay_challenge: "b".repeat(32),
5959 },
5960 links: RemoteLinks::default(),
5961 })
5962 .unwrap();
5963
5964 let event = controller.try_next_event().unwrap();
5965 let RemoteEvent::Failed(error) = event else {
5966 panic!("an account-switch attachment must fail closed");
5967 };
5968 assert!(error.contains("belongs to another account"), "{error}");
5969 assert_eq!(controller.account_ref.as_deref(), Some("account_a"));
5970 assert_eq!(controller.attached_run_id.as_deref(), Some("run_account_a"));
5971 assert_eq!(controller.status, Status::Failed);
5972 assert!(controller.worker_tx.is_none());
5973 assert!(controller.event_rx.is_none());
5974 for command in [
5975 RemoteCommand::Prompt {
5976 turn_id: "turn_rejected".to_string(),
5977 prompt: "must not execute".to_string(),
5978 },
5979 RemoteCommand::Approval {
5980 gate: "local_approval_rejected".to_string(),
5981 approved: true,
5982 },
5983 ] {
5984 assert!(
5985 controller
5986 .claim_command("run_account_b", 1, &command)
5987 .is_err(),
5988 "rejected account command must not be claimable: {command:?}"
5989 );
5990 }
5991 }
5992
5993 #[tokio::test]
5994 async fn connect_request_opts_into_runtime_chat_without_exposing_local_state() {
5995 let server = MockServer::start().await;
5996 let enrollment = fixture_enrollment(&format!("{}/", server.uri()));
5997 let start = fixture_start();
5998 let expected = json!({
5999 "deviceId": "device_fixture",
6000 "targetRef": "target_fixture",
6001 "displayLabel": "private-project",
6002 "runtimeVersion": "0.9.6",
6003 "runtimeCommit": "a".repeat(40),
6004 "capabilities": CAPABILITIES,
6005 "status": "active",
6006 "sessionRef": "session:fixture@01",
6007 "runtimeChatRelayProtocol": RUNTIME_CHAT_RELAY_PROTOCOL
6008 });
6009 assert_eq!(connect_runner_body(&enrollment, &start), expected);
6010 for forbidden in [
6011 "sessionId",
6012 "workspacePath",
6013 "path",
6014 "prompt",
6015 "environment",
6016 "env",
6017 "token",
6018 "credential",
6019 ] {
6020 assert!(expected.get(forbidden).is_none(), "leaked {forbidden}");
6021 }
6022 Mock::given(method("POST"))
6023 .and(path("/api/local-runners/connect"))
6024 .and(body_json(expected))
6025 .respond_with(ResponseTemplate::new(200).set_body_json(fixture_connection_response()))
6026 .expect(1)
6027 .mount(&server)
6028 .await;
6029 let client = crate::tls::reqwest_client_builder()
6030 .redirect(reqwest::redirect::Policy::none())
6031 .build()
6032 .expect("fixture client");
6033
6034 let connection = connect_runner(&client, &enrollment, &start)
6035 .await
6036 .expect("strict runner attachment");
6037
6038 assert_eq!(connection.runner_id, "runner_fixture");
6039 assert_eq!(connection.attachment.run_id, "run_fixture");
6040 assert_eq!(connection.attachment.runtime_cursor, 41);
6041 assert!(!connection.attachment.snapshot_present);
6042 assert_eq!(
6043 connection.attachment.runtime_chat_relay_protocol,
6044 RUNTIME_CHAT_RELAY_PROTOCOL
6045 );
6046 assert_eq!(
6047 connection.attachment.runtime_chat_relay_challenge,
6048 "a".repeat(32)
6049 );
6050 }
6051
6052 #[test]
6053 fn connection_without_links_yields_no_urls() {
6054 let enrollment = fixture_enrollment("https://api.codewhale.net/");
6055 let start = fixture_start();
6056 let connection =
6057 parse_runner_connection(&fixture_connection_response(), &enrollment, &start)
6058 .expect("legacy lease without links still attaches");
6059 assert_eq!(connection.links, RemoteLinks::default());
6060 assert!(connection.links.run_url.is_none());
6061 assert!(connection.links.computer_url.is_none());
6062 }
6063
6064 #[test]
6065 fn connection_links_are_parsed_from_the_runner_lease() {
6066 let enrollment = fixture_enrollment("https://api.codewhale.net/");
6067 let start = fixture_start();
6068 let mut value = fixture_connection_response();
6069 value["runner"]["runUrl"] = json!("https://app.codewhale.net/session?run=run_fixture");
6070 value["runner"]["computerUrl"] =
6071 json!("https://app.codewhale.net/settings?section=workspaces");
6072 let connection = parse_runner_connection(&value, &enrollment, &start)
6073 .expect("lease with links attaches");
6074 assert_eq!(
6075 connection.links.run_url.as_deref(),
6076 Some("https://app.codewhale.net/session?run=run_fixture")
6077 );
6078 assert_eq!(
6079 connection.links.computer_url.as_deref(),
6080 Some("https://app.codewhale.net/settings?section=workspaces")
6081 );
6082 }
6083
6084 #[test]
6085 fn connection_links_off_origin_or_for_another_run_are_dropped_not_fatal() {
6086 let enrollment = fixture_enrollment("https://api.codewhale.net/");
6087 let start = fixture_start();
6088 for spoofed in [
6089 "http://app.codewhale.net/session?run=run_fixture",
6090 "https://app.codewhale.net.evil.example/session?run=run_fixture",
6091 "https://evil.example/session?run=run_fixture",
6092 "https://user:pw@app.codewhale.net/session?run=run_fixture",
6093 "https://app.codewhale.net:8443/session?run=run_fixture",
6094 "https://app.codewhale.net/session?run=run_fixture#token=abc",
6095 "https://app.codewhale.net/session?run=run_other",
6096 "https://app.codewhale.net/session?run=run_fixture&next=https://evil.example",
6097 "https://app.codewhale.net/logout?run=run_fixture",
6098 "",
6099 "not a url",
6100 ] {
6101 let mut value = fixture_connection_response();
6102 value["runner"]["runUrl"] = json!(spoofed);
6103 value["runner"]["computerUrl"] = json!(spoofed);
6104 let connection = parse_runner_connection(&value, &enrollment, &start)
6105 .expect("a bad link must not break the attachment");
6106 assert!(
6107 connection.links.run_url.is_none(),
6108 "run link accepted: {spoofed}"
6109 );
6110 assert!(
6111 connection.links.computer_url.is_none(),
6112 "computer link accepted: {spoofed}"
6113 );
6114 }
6115 // A non-string value is treated as absent.
6116 let mut value = fixture_connection_response();
6117 value["runner"]["runUrl"] = json!(42);
6118 let connection = parse_runner_connection(&value, &enrollment, &start).unwrap();
6119 assert!(connection.links.run_url.is_none());
6120 }
6121
6122 #[test]
6123 fn banner_leads_with_the_session_link_when_present() {
6124 let with_link = remote_control_banner(
6125 "account_fixture",
6126 "runner_fixture",
6127 Some("https://app.codewhale.net/session?run=run_fixture"),
6128 );
6129 assert_eq!(
6130 with_link,
6131 "WEB MIRROR · https://app.codewhale.net/session?run=run_fixture · /rc stop"
6132 );
6133 let without_link = remote_control_banner("account_fixture", "runner_fixture", None);
6134 assert_eq!(
6135 without_link,
6136 "WEB MIRROR · account account_fixture · runner runner_fixture · /rc stop"
6137 );
6138 let notice =
6139 remote_control_link_notice("https://app.codewhale.net/session?run=run_fixture");
6140 assert!(notice.starts_with(
6141 "Remote control is live at https://app.codewhale.net/session?run=run_fixture"
6142 ));
6143 assert!(notice.contains("/rc open"));
6144 assert!(notice.contains("/rc link"));
6145 }
6146
6147 #[test]
6148 fn controller_exposes_links_only_while_connected() {
6149 let mut controller = RemoteControlController::default();
6150 assert!(controller.run_url().is_none());
6151 let (worker_tx, _worker_rx) = mpsc::unbounded_channel();
6152 let (event_tx, event_rx) = mpsc::unbounded_channel();
6153 controller.worker_tx = Some(worker_tx);
6154 controller.event_rx = Some(event_rx);
6155 event_tx
6156 .send(RemoteEvent::Connected {
6157 account_ref: "account_fixture".to_string(),
6158 runner_id: "runner_fixture".to_string(),
6159 target_ref: "target_fixture".to_string(),
6160 attachment: RemoteAttachment {
6161 run_id: "run_fixture".to_string(),
6162 workspace_id: "workspace_fixture".to_string(),
6163 runtime_cursor: 0,
6164 snapshot_present: false,
6165 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6166 runtime_chat_relay_challenge: "a".repeat(32),
6167 },
6168 links: RemoteLinks {
6169 run_url: Some("https://app.codewhale.net/session?run=run_fixture".to_string()),
6170 computer_url: Some(
6171 "https://app.codewhale.net/settings?section=workspaces".to_string(),
6172 ),
6173 },
6174 })
6175 .unwrap();
6176 controller.try_next_event().unwrap();
6177 assert_eq!(
6178 controller.run_url(),
6179 Some("https://app.codewhale.net/session?run=run_fixture")
6180 );
6181 assert_eq!(
6182 controller.computer_url(),
6183 Some("https://app.codewhale.net/settings?section=workspaces")
6184 );
6185 assert!(
6186 controller
6187 .status_line()
6188 .contains("open https://app.codewhale.net/session?run=run_fixture")
6189 );
6190
6191 event_tx.send(RemoteEvent::Stopped).unwrap();
6192 controller.try_next_event().unwrap();
6193 assert!(controller.run_url().is_none());
6194 assert!(controller.computer_url().is_none());
6195 }
6196
6197 #[test]
6198 fn connecting_never_blocks_local_prompts_and_shares_approvals_only_once_attached() {
6199 let mut controller = RemoteControlController::default();
6200 controller.status = Status::Connecting;
6201 controller.status_detail = "waiting for account authorization".to_string();
6202 // Mirror semantics: there is no local-input gate at all. The only
6203 // shared-decision surface is the approval card, and only once a
6204 // typed turn is bound.
6205 assert!(
6206 !controller.can_share_approval_with_web(),
6207 "without a confirmed run cursor the web cannot receive an approval"
6208 );
6209
6210 controller.status = Status::Connected;
6211 controller.attached_run_id = Some("run_fixture".to_string());
6212 assert!(
6213 !controller.can_share_approval_with_web(),
6214 "a connected idle session has no typed turn to receive approvals"
6215 );
6216 assert!(controller.attach_current_local_turn(Some("turn_fixture")));
6217 assert!(controller.can_share_approval_with_web());
6218 }
6219
6220 #[test]
6221 fn persisted_device_identity_survives_a_reload_and_outlives_enrollments() {
6222 let (minted, needs_save) = resolve_device_identity(None, None);
6223 assert!(needs_save);
6224 assert!(minted.starts_with("device_"));
6225 assert!(valid_opaque_ref(&minted));
6226
6227 let identity = PersistedDeviceIdentity {
6228 schema_version: 1,
6229 device_id: minted.clone(),
6230 };
6231 let raw = serde_json::to_string(&identity).expect("encode device identity");
6232 let reloaded: PersistedDeviceIdentity =
6233 serde_json::from_str(&raw).expect("decode device identity");
6234 assert_eq!(reloaded, identity);
6235
6236 // A saved identity wins over any enrollment's id and needs no re-save.
6237 let (resolved, needs_save) =
6238 resolve_device_identity(Some(reloaded.clone()), Some("device_enrolled"));
6239 assert_eq!(resolved, minted);
6240 assert!(!needs_save);
6241
6242 // Without a saved identity, an existing enrollment's device id is
6243 // adopted (upgrading terminals keep their computer row) and persisted.
6244 let (adopted, needs_save) = resolve_device_identity(None, Some("device_enrolled"));
6245 assert_eq!(adopted, "device_enrolled");
6246 assert!(needs_save);
6247
6248 // An unreadable identity is replaced, never fatal.
6249 let broken = PersistedDeviceIdentity {
6250 schema_version: 2,
6251 device_id: "x".to_string(),
6252 };
6253 let (replaced, needs_save) = resolve_device_identity(Some(broken), None);
6254 assert_ne!(replaced, "x");
6255 assert!(needs_save);
6256 assert!(serde_json::from_str::<PersistedDeviceIdentity>("{\"deviceId\":\"a\"}").is_err());
6257 }
6258
6259 #[test]
6260 fn remote_control_receipts_never_select_the_system_keychain() {
6261 assert!(
6262 remote_control_secrets()
6263 .backend_name()
6264 .starts_with("file-based"),
6265 "/rc enrollment must not trigger an OS keychain prompt"
6266 );
6267 }
6268
6269 #[test]
6270 fn attachment_response_validation_fails_closed() {
6271 let enrollment = fixture_enrollment("https://api.codewhale.net/");
6272 let start = fixture_start();
6273 let valid = fixture_connection_response();
6274 assert!(parse_runner_connection(&valid, &enrollment, &start).is_ok());
6275
6276 let mut missing = valid.clone();
6277 missing.as_object_mut().unwrap().remove("attachment");
6278 assert!(parse_runner_connection(&missing, &enrollment, &start).is_err());
6279
6280 let mut oversized_cursor = valid.clone();
6281 oversized_cursor["attachment"]["runtimeCursor"] = json!(JS_MAX_SAFE_INTEGER + 1);
6282 assert!(parse_runner_connection(&oversized_cursor, &enrollment, &start).is_err());
6283
6284 let mut false_receipt = valid.clone();
6285 false_receipt["attachment"]["snapshotPresent"] = json!("false");
6286 assert!(parse_runner_connection(&false_receipt, &enrollment, &start).is_err());
6287
6288 let mut extra_authority = valid.clone();
6289 extra_authority["attachment"]["workspacePath"] = json!("/private/project");
6290 assert!(parse_runner_connection(&extra_authority, &enrollment, &start).is_err());
6291
6292 let mut wrong_protocol = valid.clone();
6293 wrong_protocol["attachment"]["runtimeChatRelayProtocol"] = json!("legacy");
6294 assert!(parse_runner_connection(&wrong_protocol, &enrollment, &start).is_err());
6295
6296 let mut invalid_challenge = valid.clone();
6297 invalid_challenge["attachment"]["runtimeChatRelayChallenge"] = json!("too-short");
6298 assert!(parse_runner_connection(&invalid_challenge, &enrollment, &start).is_err());
6299
6300 let mut wrong_control_path = valid;
6301 wrong_control_path["runner"]["controlPath"] = json!("direct_native");
6302 assert!(parse_runner_connection(&wrong_control_path, &enrollment, &start).is_err());
6303 }
6304
6305 #[test]
6306 fn attachment_cursor_seeds_the_first_runtime_event_sequence() {
6307 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
6308 event_tx
6309 .send(RemoteEvent::Connected {
6310 account_ref: "account_fixture".to_string(),
6311 runner_id: "runner_fixture".to_string(),
6312 target_ref: "target_fixture".to_string(),
6313 attachment: RemoteAttachment {
6314 run_id: "run_fixture".to_string(),
6315 workspace_id: "workspace_fixture".to_string(),
6316 runtime_cursor: 41,
6317 snapshot_present: false,
6318 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6319 runtime_chat_relay_challenge: "a".repeat(32),
6320 },
6321 links: RemoteLinks::default(),
6322 })
6323 .unwrap();
6324
6325 assert!(matches!(
6326 controller.try_next_event(),
6327 Some(RemoteEvent::Connected { .. })
6328 ));
6329 controller.upload_snapshot("run_fixture", &[]);
6330
6331 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
6332 panic!("expected snapshot upload");
6333 };
6334 assert_eq!(envelopes.len(), 1);
6335 assert_eq!(envelopes[0]["event"], "session.snapshot");
6336 assert_eq!(envelopes[0]["seq"], 42);
6337 }
6338
6339 #[test]
6340 fn connected_attachment_adopts_the_existing_turn_and_streams_typed_state_once() {
6341 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
6342 event_tx
6343 .send(RemoteEvent::Connected {
6344 account_ref: "account_fixture".to_string(),
6345 runner_id: "runner_fixture".to_string(),
6346 target_ref: "target_fixture".to_string(),
6347 attachment: RemoteAttachment {
6348 run_id: "run_fixture".to_string(),
6349 workspace_id: "workspace_fixture".to_string(),
6350 runtime_cursor: 11,
6351 snapshot_present: false,
6352 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6353 runtime_chat_relay_challenge: "a".repeat(32),
6354 },
6355 links: RemoteLinks::default(),
6356 })
6357 .unwrap();
6358 assert!(matches!(
6359 controller.try_next_event(),
6360 Some(RemoteEvent::Connected { .. })
6361 ));
6362 assert!(
6363 !controller.can_share_approval_with_web(),
6364 "a connected attachment without a bound typed turn keeps approvals local"
6365 );
6366 assert!(controller.attach_current_local_turn(Some("turn_existing")));
6367 assert!(
6368 controller.can_share_approval_with_web(),
6369 "the bound active turn can carry typed approvals to the web"
6370 );
6371 assert!(controller.has_active_run());
6372 assert!(controller.active_run_matches("run_fixture"));
6373 assert!(
6374 !controller.attach_current_local_turn(Some("turn_duplicate")),
6375 "replaying the attachment must not replace or duplicate the turn"
6376 );
6377
6378 controller.observe_engine_event(&EngineEvent::MessageDelta {
6379 index: 0,
6380 content: "existing turn output".to_string(),
6381 });
6382 controller.observe_engine_event(&EngineEvent::ToolCallStarted {
6383 id: "tool_existing".to_string(),
6384 name: "shell".to_string(),
6385 input: json!({ "never": "relayed" }),
6386 });
6387 let gate = controller.record_remote_approval(
6388 "tool_existing",
6389 "shell",
6390 "approve bounded fixture",
6391 &json!({ "credential": "must-not-cross" }),
6392 "approval-key",
6393 None,
6394 );
6395
6396 let mut envelopes = Vec::new();
6397 for _ in 0..3 {
6398 let WorkerCommand::Upload {
6399 envelopes: batch, ..
6400 } = worker_rx.try_recv().expect("typed active-turn upload")
6401 else {
6402 panic!("active-turn state must use the runtime envelope channel");
6403 };
6404 envelopes.extend(batch);
6405 }
6406 assert_eq!(
6407 envelopes
6408 .iter()
6409 .map(|event| event["event"].as_str().unwrap())
6410 .collect::<Vec<_>>(),
6411 vec!["item.delta", "item.started", "approval.required"]
6412 );
6413 assert!(
6414 envelopes
6415 .iter()
6416 .all(|event| event["turn_id"] == "turn_existing")
6417 );
6418 assert_eq!(
6419 envelopes[2]["payload"]["approval_id"].as_str(),
6420 Some(gate.as_str())
6421 );
6422 let projected = serde_json::to_string(&envelopes).unwrap();
6423 assert!(!projected.contains("must-not-cross"));
6424 assert!(!projected.contains("approval-key"));
6425 assert!(worker_rx.try_recv().is_err());
6426 }
6427
6428 #[test]
6429 fn dispatch_window_attachment_promotes_on_typed_start_and_reconnect_is_idempotent() {
6430 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
6431 let attachment = RemoteAttachment {
6432 run_id: "run_fixture".to_string(),
6433 workspace_id: "workspace_fixture".to_string(),
6434 runtime_cursor: 3,
6435 snapshot_present: false,
6436 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6437 runtime_chat_relay_challenge: "a".repeat(32),
6438 };
6439 event_tx
6440 .send(RemoteEvent::Connected {
6441 account_ref: "account_fixture".to_string(),
6442 runner_id: "runner_fixture".to_string(),
6443 target_ref: "target_fixture".to_string(),
6444 attachment: attachment.clone(),
6445 links: RemoteLinks::default(),
6446 })
6447 .unwrap();
6448 controller.try_next_event().unwrap();
6449
6450 assert!(controller.attach_current_local_turn(None));
6451 let pending_lease = controller
6452 .journal
6453 .as_ref()
6454 .and_then(RuntimeEventJournal::classic_lease)
6455 .expect("dispatch-window lease is durable");
6456 assert!(pending_lease.turn_id.is_none());
6457 assert!(
6458 controller.has_active_run(),
6459 "the dispatch window gates stop"
6460 );
6461 assert!(controller.active_run_matches("run_fixture"));
6462 assert!(
6463 !controller.can_share_approval_with_web(),
6464 "a pending dispatch has no typed turn id, so approvals stay local"
6465 );
6466 assert!(worker_rx.try_recv().is_err());
6467
6468 controller.observe_engine_event(&EngineEvent::TurnStarted {
6469 turn_id: "turn_started_later".to_string(),
6470 created_at: chrono::Utc::now(),
6471 route: None,
6472 });
6473 let WorkerCommand::Upload { envelopes, .. } =
6474 worker_rx.try_recv().expect("one typed turn start")
6475 else {
6476 panic!("turn start must use the runtime envelope channel");
6477 };
6478 assert_eq!(envelopes.len(), 1);
6479 assert_eq!(envelopes[0]["seq"], 4);
6480 assert_eq!(envelopes[0]["event"], "turn.started");
6481 assert_eq!(envelopes[0]["turn_id"], "turn_started_later");
6482 let promoted_lease = controller
6483 .journal
6484 .as_ref()
6485 .and_then(RuntimeEventJournal::classic_lease)
6486 .expect("typed start keeps the durable lease");
6487 assert_eq!(
6488 promoted_lease.turn_id.as_deref(),
6489 Some("turn_started_later")
6490 );
6491 assert_eq!(
6492 promoted_lease.lease_id, pending_lease.lease_id,
6493 "typed start promotes the same dispatch generation"
6494 );
6495 let started_envelope = envelopes[0].clone();
6496 assert!(
6497 controller.can_share_approval_with_web(),
6498 "typed TurnStarted promotes the dispatch into a web-owned active turn"
6499 );
6500
6501 event_tx
6502 .send(RemoteEvent::Attachment {
6503 account_ref: "account_fixture".to_string(),
6504 target_ref: "target_fixture".to_string(),
6505 attachment,
6506 links: RemoteLinks::default(),
6507 })
6508 .unwrap();
6509 controller.try_next_event().unwrap();
6510 assert!(
6511 !controller.attach_current_local_turn(Some("turn_replayed")),
6512 "a reconnect must not rebind the live turn"
6513 );
6514 let WorkerCommand::Upload { envelopes, .. } = worker_rx
6515 .try_recv()
6516 .expect("the unacknowledged start is replayed unchanged")
6517 else {
6518 panic!("runtime replay must use the envelope channel");
6519 };
6520 assert_eq!(
6521 envelopes,
6522 vec![started_envelope],
6523 "retry safety preserves the original sequence and payload"
6524 );
6525 assert!(worker_rx.try_recv().is_err());
6526
6527 controller.observe_engine_event(&turn_complete_event());
6528 let WorkerCommand::Upload { envelopes, .. } =
6529 worker_rx.try_recv().expect("one terminal receipt")
6530 else {
6531 panic!("turn completion must use the runtime envelope channel");
6532 };
6533 assert_eq!(envelopes.len(), 1);
6534 assert_eq!(envelopes[0]["seq"], 5);
6535 assert_eq!(envelopes[0]["event"], "turn.completed");
6536 assert_eq!(envelopes[0]["turn_id"], "turn_started_later");
6537 assert!(!controller.has_active_run());
6538 assert!(worker_rx.try_recv().is_err());
6539 }
6540
6541 #[test]
6542 fn dispatch_window_that_ends_before_typed_start_returns_to_idle_attachment() {
6543 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
6544 event_tx
6545 .send(RemoteEvent::Connected {
6546 account_ref: "account_fixture".to_string(),
6547 runner_id: "runner_fixture".to_string(),
6548 target_ref: "target_fixture".to_string(),
6549 attachment: RemoteAttachment {
6550 run_id: "run_fixture".to_string(),
6551 workspace_id: "workspace_fixture".to_string(),
6552 runtime_cursor: 8,
6553 snapshot_present: false,
6554 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6555 runtime_chat_relay_challenge: "a".repeat(32),
6556 },
6557 links: RemoteLinks::default(),
6558 })
6559 .unwrap();
6560 controller.try_next_event().unwrap();
6561
6562 assert!(controller.attach_current_local_turn(None));
6563 assert!(controller.has_active_run());
6564 assert!(controller.release_unstarted_local_turn());
6565 assert!(!controller.has_active_run());
6566 assert!(!controller.can_share_approval_with_web());
6567 assert!(
6568 !controller.release_unstarted_local_turn(),
6569 "reconciling the same idle boundary is idempotent"
6570 );
6571 assert!(worker_rx.try_recv().is_err());
6572 }
6573
6574 #[test]
6575 fn fresh_controller_refreshes_old_server_snapshot_then_deduplicates_reconnects() {
6576 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
6577 event_tx
6578 .send(RemoteEvent::Connected {
6579 account_ref: "account_fixture".to_string(),
6580 runner_id: "runner_fixture".to_string(),
6581 target_ref: "target_fixture".to_string(),
6582 attachment: RemoteAttachment {
6583 run_id: "run_fixture".to_string(),
6584 workspace_id: "workspace_fixture".to_string(),
6585 runtime_cursor: 7,
6586 snapshot_present: true,
6587 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6588 runtime_chat_relay_challenge: "a".repeat(32),
6589 },
6590 links: RemoteLinks::default(),
6591 })
6592 .unwrap();
6593 controller.try_next_event().unwrap();
6594
6595 controller.upload_snapshot("run_fixture", &[]);
6596 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
6597 panic!("fresh controller must refresh saved history");
6598 };
6599 assert_eq!(envelopes[0]["seq"], 8);
6600
6601 event_tx
6602 .send(RemoteEvent::Attachment {
6603 account_ref: "account_fixture".to_string(),
6604 target_ref: "target_fixture".to_string(),
6605 attachment: RemoteAttachment {
6606 run_id: "run_fixture".to_string(),
6607 workspace_id: "workspace_fixture".to_string(),
6608 runtime_cursor: 7,
6609 snapshot_present: false,
6610 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6611 runtime_chat_relay_challenge: "a".repeat(32),
6612 },
6613 links: RemoteLinks::default(),
6614 })
6615 .unwrap();
6616 controller.try_next_event().unwrap();
6617 controller.upload_snapshot("run_fixture", &[]);
6618 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
6619 panic!("unacknowledged snapshot must be retried");
6620 };
6621 assert_eq!(envelopes[0]["seq"], 8);
6622 controller.upload_snapshot("run_fixture", &[]);
6623 assert!(worker_rx.try_recv().is_err());
6624
6625 event_tx
6626 .send(RemoteEvent::Attachment {
6627 account_ref: "account_fixture".to_string(),
6628 target_ref: "target_fixture".to_string(),
6629 attachment: RemoteAttachment {
6630 run_id: "run_fixture".to_string(),
6631 workspace_id: "workspace_fixture".to_string(),
6632 runtime_cursor: 8,
6633 snapshot_present: true,
6634 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6635 runtime_chat_relay_challenge: "a".repeat(32),
6636 },
6637 links: RemoteLinks::default(),
6638 })
6639 .unwrap();
6640 controller.try_next_event().unwrap();
6641 controller.upload_snapshot("run_fixture", &[]);
6642 assert!(worker_rx.try_recv().is_err());
6643 }
6644
6645 #[test]
6646 fn reconnect_cursor_retires_only_the_acknowledged_prefix() {
6647 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
6648 controller.event_seq.insert("run_fixture".to_string(), 6);
6649 controller.upload_envelope(
6650 "run_fixture",
6651 "item.delta",
6652 None,
6653 json!({ "delta": "seven" }),
6654 );
6655 controller.upload_envelope(
6656 "run_fixture",
6657 "item.delta",
6658 None,
6659 json!({ "delta": "eight" }),
6660 );
6661 worker_rx.try_recv().unwrap();
6662 worker_rx.try_recv().unwrap();
6663
6664 event_tx
6665 .send(RemoteEvent::Attachment {
6666 account_ref: "account_fixture".to_string(),
6667 target_ref: "target_fixture".to_string(),
6668 attachment: RemoteAttachment {
6669 run_id: "run_fixture".to_string(),
6670 workspace_id: "workspace_fixture".to_string(),
6671 runtime_cursor: 7,
6672 snapshot_present: false,
6673 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6674 runtime_chat_relay_challenge: "a".repeat(32),
6675 },
6676 links: RemoteLinks::default(),
6677 })
6678 .unwrap();
6679 controller.try_next_event().unwrap();
6680
6681 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
6682 panic!("seq 8 must remain pending");
6683 };
6684 assert_eq!(envelopes.len(), 1);
6685 assert_eq!(envelopes[0]["seq"], 8);
6686 assert_eq!(
6687 controller
6688 .pending_runtime_events
6689 .get("run_fixture")
6690 .unwrap()
6691 .keys()
6692 .copied()
6693 .collect::<Vec<_>>(),
6694 vec![8]
6695 );
6696
6697 event_tx
6698 .send(RemoteEvent::Attachment {
6699 account_ref: "account_fixture".to_string(),
6700 target_ref: "target_fixture".to_string(),
6701 attachment: RemoteAttachment {
6702 run_id: "run_fixture".to_string(),
6703 workspace_id: "workspace_fixture".to_string(),
6704 runtime_cursor: 6,
6705 snapshot_present: false,
6706 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
6707 runtime_chat_relay_challenge: "a".repeat(32),
6708 },
6709 links: RemoteLinks::default(),
6710 })
6711 .unwrap();
6712 controller.try_next_event().unwrap();
6713 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
6714 panic!("older cursor cannot discard seq 8");
6715 };
6716 assert_eq!(envelopes[0]["seq"], 8);
6717
6718 event_tx
6719 .send(RemoteEvent::RuntimeCursor {
6720 run_id: "run_fixture".to_string(),
6721 cursor: 8,
6722 })
6723 .unwrap();
6724 controller.try_next_event().unwrap();
6725 assert!(
6726 !controller
6727 .pending_runtime_events
6728 .contains_key("run_fixture")
6729 );
6730 }
6731
6732 #[tokio::test]
6733 async fn ambiguous_success_retries_the_identical_runtime_event_until_cursor_acceptance() {
6734 let server = MockServer::start().await;
6735 let responder = AmbiguousRuntimeResponder::default();
6736 Mock::given(method("POST"))
6737 .and(path(
6738 "/api/local-runners/runner_fixture/runs/run_fixture/events",
6739 ))
6740 .respond_with(responder.clone())
6741 .expect(2)
6742 .mount(&server)
6743 .await;
6744 let enrollment = fixture_enrollment(&format!("{}/", server.uri()));
6745 let client = crate::tls::reqwest_client_builder()
6746 .redirect(reqwest::redirect::Policy::none())
6747 .build()
6748 .expect("fixture client");
6749 let envelope = runtime_envelope(
6750 1,
6751 "item.delta",
6752 None,
6753 "2026-08-08T12:00:00Z".to_string(),
6754 json!({ "delta": "exact body" }),
6755 );
6756 let mut outbox = RuntimeTransportOutbox::default();
6757 outbox
6758 .enqueue("run_fixture", envelope)
6759 .expect("queue runtime event");
6760
6761 assert_eq!(
6762 outbox
6763 .try_flush_one(&client, &enrollment, "runner_fixture")
6764 .await
6765 .unwrap(),
6766 RuntimeFlushOutcome::Retryable
6767 );
6768 assert_eq!(outbox.events.len(), 1);
6769 assert_eq!(
6770 outbox
6771 .try_flush_one(&client, &enrollment, "runner_fixture")
6772 .await
6773 .unwrap(),
6774 RuntimeFlushOutcome::Accepted {
6775 run_id: "run_fixture".to_string(),
6776 cursor: 1,
6777 }
6778 );
6779 assert!(outbox.events.is_empty());
6780 let bodies = responder.bodies.lock().unwrap();
6781 assert_eq!(bodies.len(), 2);
6782 assert_eq!(bodies[0], bodies[1]);
6783 }
6784
6785 #[test]
6786 fn snapshot_envelope_is_unicode_safe_and_keeps_newest_history() {
6787 let messages = (0..80)
6788 .map(|index| {
6789 let marker = format!("message-{index:02}-");
6790 text_message(
6791 if index % 2 == 0 { "user" } else { "assistant" },
6792 marker + &"🫧\"\\\n".repeat(1_500),
6793 )
6794 })
6795 .collect::<Vec<_>>();
6796
6797 let envelope = bounded_session_snapshot_envelope(1, &messages);
6798 let encoded = serde_json::to_vec(&envelope).unwrap();
6799 let retained = envelope["payload"]["messages"].as_array().unwrap();
6800
6801 assert!(encoded.len() <= SNAPSHOT_ENVELOPE_BYTE_BUDGET);
6802 assert!(encoded.len() < MAX_RUNTIME_ENVELOPE_BYTES);
6803 assert!(!retained.is_empty());
6804 assert!(retained.len() <= MAX_SNAPSHOT_MESSAGES);
6805 assert!(
6806 retained.last().unwrap()["text"]
6807 .as_str()
6808 .unwrap()
6809 .starts_with("message-79-")
6810 );
6811 for message in retained {
6812 let text = message["text"].as_str().unwrap();
6813 assert!(!text.contains('\u{FFFD}'));
6814 assert!(text.is_char_boundary(text.len()));
6815 }
6816 }
6817
6818 #[test]
6819 fn snapshot_truncation_pins_the_exact_encoded_byte_boundary() {
6820 let source = "🫧\"\\\n".repeat(40_000);
6821 let message = text_message("assistant", source);
6822 let envelope = bounded_session_snapshot_envelope(9, std::slice::from_ref(&message));
6823 let encoded = serde_json::to_vec(&envelope).unwrap();
6824 assert!(encoded.len() <= SNAPSHOT_ENVELOPE_BYTE_BUDGET);
6825
6826 let retained = envelope["payload"]["messages"][0]["text"].as_str().unwrap();
6827 let projected = project_session_message(&message).unwrap().1;
6828 let retained_chars = retained.chars().count();
6829 let next = projected.chars().nth(retained_chars).unwrap();
6830 let mut one_more = retained.to_string();
6831 one_more.push(next);
6832 let timestamp = envelope["timestamp"].as_str().unwrap();
6833 let expanded = vec![json!({ "role": "assistant", "text": one_more })];
6834 assert!(
6835 snapshot_envelope_len(9, timestamp, &expanded) > SNAPSHOT_ENVELOPE_BYTE_BUDGET,
6836 "one more Unicode scalar must cross the chosen encoded boundary"
6837 );
6838 }
6839
6840 #[test]
6841 fn fatal_engine_error_projects_failure_and_releases_the_remote_run() {
6842 let (mut controller, mut worker_rx, _event_tx, _journal_root) = wired_controller();
6843 controller
6844 .activate_prompt("run_fixture", "turn_fixture")
6845 .unwrap();
6846 let secret = "sk-runtime-secret-that-must-not-cross-the-relay";
6847 let message = format!(
6848 "DeepSeek API key: {secret}\n{}",
6849 "🫧".repeat(MAX_REMOTE_ERROR_MESSAGE_BYTES)
6850 );
6851
6852 controller.observe_engine_event(&EngineEvent::Error {
6853 envelope: crate::error_taxonomy::ErrorEnvelope::new(
6854 crate::error_taxonomy::ErrorCategory::Authentication,
6855 crate::error_taxonomy::ErrorSeverity::Critical,
6856 false,
6857 "llm_auth_error",
6858 message,
6859 ),
6860 recoverable: false,
6861 });
6862
6863 assert!(!controller.has_active_run());
6864 let WorkerCommand::Upload {
6865 envelopes: failed, ..
6866 } = worker_rx.try_recv().expect("fatal item upload")
6867 else {
6868 panic!("fatal error must upload an item.failed envelope");
6869 };
6870 let WorkerCommand::Upload {
6871 envelopes: completed,
6872 ..
6873 } = worker_rx.try_recv().expect("fatal turn upload")
6874 else {
6875 panic!("fatal error must upload a terminal turn envelope");
6876 };
6877 assert_eq!(failed.len(), 1);
6878 assert_eq!(failed[0]["seq"], 1);
6879 assert_eq!(failed[0]["event"], "item.failed");
6880 assert_eq!(failed[0]["turn_id"], "turn_fixture");
6881 assert_eq!(failed[0]["payload"]["item"]["kind"], "error");
6882 assert_eq!(failed[0]["payload"]["item"]["status"], "failed");
6883 let projected = failed[0]["payload"]["item"]["detail"]
6884 .as_str()
6885 .expect("bounded error detail");
6886 assert!(projected.len() <= MAX_REMOTE_ERROR_MESSAGE_BYTES);
6887 assert!(projected.is_char_boundary(projected.len()));
6888 assert!(!projected.contains(secret));
6889 assert!(projected.contains("[redacted]"));
6890
6891 assert_eq!(completed.len(), 1);
6892 assert_eq!(completed[0]["seq"], 2);
6893 assert_eq!(completed[0]["event"], "turn.completed");
6894 assert_eq!(completed[0]["turn_id"], "turn_fixture");
6895 assert_eq!(completed[0]["payload"]["turn"]["status"], "failed");
6896 assert_eq!(
6897 controller.pending_runtime_events["run_fixture"]
6898 .keys()
6899 .copied()
6900 .collect::<Vec<_>>(),
6901 vec![1, 2]
6902 );
6903 assert!(worker_rx.try_recv().is_err());
6904 }
6905
6906 #[test]
6907 fn recoverable_engine_error_stays_nonterminal_for_provider_fallback() {
6908 let mut controller = RemoteControlController::default();
6909 let (worker_tx, mut worker_rx) = mpsc::unbounded_channel();
6910 controller.worker_tx = Some(worker_tx);
6911 controller
6912 .activate_prompt("run_fixture", "turn_fixture")
6913 .unwrap();
6914
6915 controller.observe_engine_event(&EngineEvent::Error {
6916 envelope: crate::error_taxonomy::ErrorEnvelope::network(
6917 "temporary provider connection failure",
6918 ),
6919 recoverable: true,
6920 });
6921
6922 assert!(controller.active_run_matches("run_fixture"));
6923 assert!(controller.pending_runtime_events.is_empty());
6924 assert!(worker_rx.try_recv().is_err());
6925 }
6926
6927 #[test]
6928 fn terminal_pre_dispatch_error_uses_the_same_failure_projection() {
6929 let (mut controller, mut worker_rx, _event_tx, _journal_root) = wired_controller();
6930 controller
6931 .activate_prompt("run_fixture", "turn_fixture")
6932 .unwrap();
6933
6934 controller.fail_active_dispatch(
6935 "DeepSeek API key: sk-preflight-secret-that-must-not-cross-the-relay",
6936 );
6937
6938 assert!(!controller.has_active_run());
6939 let WorkerCommand::Upload { envelopes, .. } =
6940 worker_rx.try_recv().expect("pre-dispatch item upload")
6941 else {
6942 panic!("pre-dispatch error must upload item.failed");
6943 };
6944 assert_eq!(envelopes[0]["event"], "item.failed");
6945 assert!(!envelopes[0].to_string().contains("sk-preflight-secret"));
6946 let WorkerCommand::Upload { envelopes, .. } =
6947 worker_rx.try_recv().expect("pre-dispatch turn upload")
6948 else {
6949 panic!("pre-dispatch error must upload turn.completed");
6950 };
6951 assert_eq!(envelopes[0]["event"], "turn.completed");
6952 assert_eq!(envelopes[0]["payload"]["turn"]["status"], "failed");
6953 assert!(worker_rx.try_recv().is_err());
6954 }
6955
6956 #[test]
6957 fn typed_command_parser_rejects_shell_and_cross_run_content() {
6958 let prompt = parse_remote_command(
6959 &json!({
6960 "type": "prompt.request",
6961 "runId": "run-1",
6962 "turnId": "turn-1",
6963 "prompt": "Continue",
6964 }),
6965 "run-1",
6966 )
6967 .unwrap();
6968 assert_eq!(
6969 prompt,
6970 RemoteCommand::Prompt {
6971 turn_id: "turn-1".to_string(),
6972 prompt: "Continue".to_string(),
6973 }
6974 );
6975 assert!(
6976 parse_remote_command(
6977 &json!({
6978 "type": "shell",
6979 "runId": "run-1",
6980 "command": "rm -rf /",
6981 }),
6982 "run-1"
6983 )
6984 .is_err()
6985 );
6986 assert!(
6987 parse_remote_command(
6988 &json!({
6989 "type": "prompt.request",
6990 "runId": "run-other",
6991 "turnId": "turn-1",
6992 "prompt": "Continue",
6993 }),
6994 "run-1"
6995 )
6996 .is_err()
6997 );
6998 }
6999
7000 fn runtime_chat_prompt_fixture() -> Value {
7001 json!({
7002 "type": "prompt.request",
7003 "runId": "run-1",
7004 "turnId": format!("local_turn_{}", "b".repeat(24)),
7005 "operationKey": "operation-1",
7006 "runtimeBindingId": "binding-1",
7007 "runtimeThreadId": format!("local_thread_{}", "a".repeat(24)),
7008 "prompt": "Hello from account Chat",
7009 "systemPrompt": "Answer directly.",
7010 "model": "model-1",
7011 "modelProvider": "ollama",
7012 "modelProviderId": "ollama",
7013 "reasoningEffort": "high",
7014 "allowedTools": [],
7015 "mode": "chat",
7016 "requestedMode": "chat",
7017 "workspace": {
7018 "id": "workspace-1",
7019 "targetRef": "target-1"
7020 }
7021 })
7022 }
7023
7024 #[test]
7025 fn runtime_chat_parser_requires_exact_route_tools_mode_and_interrupt_scope() {
7026 let command = parse_remote_command(&runtime_chat_prompt_fixture(), "run-1").unwrap();
7027 assert!(matches!(command, RemoteCommand::RuntimeChatPrompt(_)));
7028
7029 for mutation in [
7030 ("allowedTools", json!(["bash"])),
7031 ("mode", json!("work")),
7032 ("requestedMode", json!("operate")),
7033 ("modelProvider", json!("https://provider.invalid")),
7034 ] {
7035 let mut value = runtime_chat_prompt_fixture();
7036 value[mutation.0] = mutation.1;
7037 assert!(parse_remote_command(&value, "run-1").is_err());
7038 }
7039 let mut unknown = runtime_chat_prompt_fixture();
7040 unknown["workspacePath"] = json!("/private/workspace");
7041 assert!(parse_remote_command(&unknown, "run-1").is_err());
7042 let mut missing_markers = runtime_chat_prompt_fixture();
7043 let record = missing_markers.as_object_mut().unwrap();
7044 record.remove("operationKey");
7045 record.remove("runtimeBindingId");
7046 record.remove("runtimeThreadId");
7047 assert!(
7048 parse_remote_command(&missing_markers, "run-1").is_err(),
7049 "a rich Chat-shaped payload cannot downgrade into a legacy Work prompt"
7050 );
7051
7052 let interrupt = json!({
7053 "type": "run.control",
7054 "runId": "run-1",
7055 "action": "interrupt",
7056 "reason": "Stop this turn",
7057 "turnId": format!("local_turn_{}", "b".repeat(24)),
7058 "runtimeBindingId": "binding-1",
7059 "runtimeThreadId": format!("local_thread_{}", "a".repeat(24))
7060 });
7061 assert!(matches!(
7062 parse_remote_command(&interrupt, "run-1").unwrap(),
7063 RemoteCommand::Control {
7064 runtime_chat: Some(_),
7065 ..
7066 }
7067 ));
7068 let mut drifted = interrupt;
7069 drifted["runtimeThreadId"] = json!(format!("local_thread_{}", "F".repeat(24)));
7070 assert!(parse_remote_command(&drifted, "run-1").is_err());
7071 }
7072
7073 #[test]
7074 fn legacy_control_requires_and_matches_the_exact_active_turn() {
7075 assert!(
7076 parse_remote_command(
7077 &json!({
7078 "type": "run.control",
7079 "runId": "run-1",
7080 "action": "interrupt"
7081 }),
7082 "run-1"
7083 )
7084 .is_err(),
7085 "run-only cancellation can target a newer turn in the same run"
7086 );
7087 assert!(
7088 parse_remote_command(
7089 &json!({
7090 "type": "run.control",
7091 "runId": "run-1",
7092 "action": "interrupt",
7093 "turnId": "../../stale"
7094 }),
7095 "run-1"
7096 )
7097 .is_err()
7098 );
7099 let parsed = parse_remote_command(
7100 &json!({
7101 "type": "run.control",
7102 "runId": "run-1",
7103 "action": "interrupt",
7104 "turnId": "turn-current"
7105 }),
7106 "run-1",
7107 )
7108 .unwrap();
7109 assert!(matches!(
7110 parsed,
7111 RemoteCommand::Control {
7112 turn_id: Some(ref turn_id),
7113 runtime_chat: None,
7114 ..
7115 } if turn_id == "turn-current"
7116 ));
7117
7118 let mut controller = RemoteControlController::default();
7119 controller.activate_prompt("run-1", "turn-current").unwrap();
7120 assert!(controller.active_turn_matches("run-1", "turn-current"));
7121 assert!(!controller.active_turn_matches("run-1", "turn-stale"));
7122 assert!(!controller.active_turn_matches("run-other", "turn-current"));
7123 }
7124
7125 #[test]
7126 fn runtime_chat_semantic_envelope_carries_stable_native_source_id() {
7127 let source_event_id = format!("native_event_{}", "a".repeat(64));
7128 let semantic = runtime_chat_envelope(
7129 9,
7130 "item.delta",
7131 Some("local_thread_fixture"),
7132 Some("local_turn_fixture"),
7133 Some(&source_event_id),
7134 "2026-08-23T00:00:00Z".to_string(),
7135 json!({ "delta": "hello" }),
7136 );
7137 assert_eq!(semantic["schema_version"], 2);
7138 assert_eq!(semantic["source_event_id"], source_event_id);
7139
7140 let catalog_payload = json!({
7141 "schemaVersion": 2,
7142 "challenge": "a".repeat(32),
7143 "providers": [{ "id": "safe", "models": [{ "id": "model-1" }] }]
7144 });
7145 let fingerprint =
7146 RuntimeChatRelayHost::catalog_payload_fingerprint(&catalog_payload).unwrap();
7147 let catalog_source_id = runtime_chat_catalog_source_event_id("run_fixture", &fingerprint);
7148 let catalog = runtime_chat_envelope(
7149 1,
7150 "runtime.catalog",
7151 None,
7152 None,
7153 Some(&catalog_source_id),
7154 RUNTIME_CHAT_CATALOG_TIMESTAMP.to_string(),
7155 catalog_payload.clone(),
7156 );
7157 let replay = runtime_chat_envelope(
7158 99,
7159 "runtime.catalog",
7160 None,
7161 None,
7162 Some(&catalog_source_id),
7163 RUNTIME_CHAT_CATALOG_TIMESTAMP.to_string(),
7164 catalog_payload,
7165 );
7166 assert_eq!(catalog["source_event_id"], catalog_source_id);
7167 let mut catalog_semantic = catalog;
7168 let mut replay_semantic = replay;
7169 catalog_semantic.as_object_mut().unwrap().remove("seq");
7170 replay_semantic.as_object_mut().unwrap().remove("seq");
7171 assert_eq!(catalog_semantic, replay_semantic);
7172
7173 let changed_fingerprint = RuntimeChatRelayHost::catalog_payload_fingerprint(
7174 &json!({ "schemaVersion": 2, "challenge": "a".repeat(32), "providers": [] }),
7175 )
7176 .unwrap();
7177 assert_ne!(
7178 runtime_chat_catalog_source_event_id("run_fixture", &changed_fingerprint),
7179 catalog_source_id
7180 );
7181 }
7182
7183 #[test]
7184 fn approval_projection_matches_control_plane_namespace() {
7185 assert_eq!(projected_approval_id("tool-call-1").len(), 39);
7186 assert!(projected_approval_id("tool-call-1").starts_with("local_approval_"));
7187 assert_ne!(
7188 projected_approval_id("tool-call-1"),
7189 projected_approval_id("tool-call-2")
7190 );
7191 }
7192
7193 #[test]
7194 fn authorization_url_is_exact_and_cannot_redirect_or_add_parameters() {
7195 assert!(
7196 validate_authorization_url(
7197 "https://app.codewhale.net/runner/authorize?user_code=ABCD-EFGH-JKLM",
7198 "ABCD-EFGH-JKLM",
7199 )
7200 .is_ok()
7201 );
7202 for spoofed in [
7203 "http://app.codewhale.net/runner/authorize?user_code=ABCD-EFGH-JKLM",
7204 "https://app.codewhale.net.evil.example/runner/authorize?user_code=ABCD-EFGH-JKLM",
7205 "https://app.codewhale.net/runner/authorize?user_code=ABCD-EFGH-JKLM&next=https://evil.example",
7206 "https://app.codewhale.net/runner/authorize?user_code=WRONG-CODE",
7207 ] {
7208 assert!(validate_authorization_url(spoofed, "ABCD-EFGH-JKLM").is_err());
7209 }
7210 }
7211
7212 #[test]
7213 fn command_sequences_are_content_bound_and_replay_safe() {
7214 let mut controller = RemoteControlController::default();
7215 controller.status = Status::Connected;
7216 controller.attached_run_id = Some("run-1".to_string());
7217 let prompt = RemoteCommand::Prompt {
7218 turn_id: "turn-1".to_string(),
7219 prompt: "Continue".to_string(),
7220 };
7221 assert_eq!(controller.claim_command("run-1", 1, &prompt), Ok(true));
7222 assert_eq!(controller.claim_command("run-1", 1, &prompt), Ok(false));
7223 assert!(
7224 controller
7225 .claim_command(
7226 "run-1",
7227 1,
7228 &RemoteCommand::Prompt {
7229 turn_id: "turn-1".to_string(),
7230 prompt: "Changed".to_string(),
7231 },
7232 )
7233 .is_err()
7234 );
7235 }
7236
7237 #[tokio::test]
7238 async fn durable_runtime_chat_replay_bypasses_only_new_work_recovery_gates() {
7239 let root = tempfile::tempdir().unwrap();
7240 let runtime_root = root.path().join("runtime-chat");
7241 let command = parse_remote_command(&runtime_chat_prompt_fixture(), "run-1").unwrap();
7242 let RemoteCommand::RuntimeChatPrompt(prompt) = &command else {
7243 panic!("fixture must parse as Runtime Chat");
7244 };
7245 {
7246 let mut first = RemoteControlController::default();
7247 first
7248 .configure_runtime_chat(
7249 crate::config::Config::default(),
7250 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
7251 runtime_root.clone(),
7252 "target-1".to_string(),
7253 "session_fixture".to_string(),
7254 )
7255 .unwrap();
7256 let host = first.runtime_chat.as_ref().unwrap();
7257 host.bind_account("account_fixture", "target-1").unwrap();
7258 host.install_prompt_replay_for_tests(prompt, true)
7259 .await
7260 .unwrap();
7261 }
7262
7263 let mut controller = RemoteControlController::default();
7264 controller
7265 .configure_runtime_chat(
7266 crate::config::Config::default(),
7267 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
7268 runtime_root.clone(),
7269 "target-1".to_string(),
7270 "session_fixture".to_string(),
7271 )
7272 .unwrap();
7273 controller
7274 .runtime_chat
7275 .as_ref()
7276 .unwrap()
7277 .bind_account("account_fixture", "target-1")
7278 .unwrap();
7279 controller
7280 .runtime_chat
7281 .as_ref()
7282 .unwrap()
7283 .authorize_run("run-1")
7284 .unwrap();
7285 let envelope = runtime_chat_envelope(
7286 9,
7287 "turn.completed",
7288 Some(&prompt.runtime_thread_id),
7289 Some(&prompt.turn_id),
7290 Some("native_event_exact_replay_gate"),
7291 "2026-08-23T00:00:00Z".to_string(),
7292 json!({ "turn": { "status": "completed" } }),
7293 );
7294 controller.pending_runtime_events.insert(
7295 "run-1".to_string(),
7296 BTreeMap::from([(
7297 9,
7298 PendingRuntimeEnvelope {
7299 encoded_len: serde_json::to_vec(&envelope).unwrap().len(),
7300 envelope,
7301 integrity: true,
7302 handed_off: true,
7303 },
7304 )]),
7305 );
7306 let changed_config = crate::config::Config {
7307 default_text_model: Some("changed-after-restart".to_string()),
7308 ..crate::config::Config::default()
7309 };
7310 controller
7311 .configure_runtime_chat(
7312 changed_config,
7313 Arc::new(crate::plugins::PluginRegistry::empty(root.path())),
7314 runtime_root,
7315 "target-1".to_string(),
7316 "session_fixture".to_string(),
7317 )
7318 .unwrap();
7319 assert!(controller.pending_runtime_chat_configuration.is_some());
7320 controller.status = Status::Connected;
7321 controller.attached_run_id = Some("run-1".to_string());
7322 controller.attached_workspace_id = Some(prompt.workspace.id.clone());
7323 controller.target_ref = Some(prompt.workspace.target_ref.clone());
7324
7325 assert_eq!(controller.claim_command("run-1", 17, &command), Ok(false));
7326 controller.apply_runtime_chat_prompt(prompt).await.unwrap();
7327 let mut changed = runtime_chat_prompt_fixture();
7328 changed["prompt"] = json!("changed replay body");
7329 let changed = parse_remote_command(&changed, "run-1").unwrap();
7330 assert!(controller.claim_command("run-1", 17, &changed).is_err());
7331 }
7332
7333 #[tokio::test]
7334 async fn pre_lease_failure_never_locks_and_allows_immediate_retry() {
7335 let (mut controller, _worker_rx, event_tx, _journal_root) = wired_controller();
7336 controller.status = Status::Connecting;
7337 event_tx
7338 .send(RemoteEvent::FailedPreLease(
7339 "Codewhale rejected remote-control enrollment (403): client version not accepted."
7340 .to_string(),
7341 ))
7342 .unwrap();
7343 assert!(matches!(
7344 controller.try_next_event(),
7345 Some(RemoteEvent::FailedPreLease(_))
7346 ));
7347 assert_eq!(controller.status, Status::Failed);
7348 assert!(
7349 controller.ownership_blocked_until.is_none(),
7350 "a rejection before any lease must never start the reconnect blackout"
7351 );
7352 let line = controller.status_line();
7353 assert!(line.contains("failed before connecting"), "{line}");
7354 assert!(line.contains("/rc to retry"), "{line}");
7355 assert!(
7356 line.contains("403"),
7357 "the sanitized HTTP status must surface: {line}"
7358 );
7359 assert!(line.contains("client version not accepted"), "{line}");
7360
7361 // Stopping after a pre-lease failure is an ordinary reset (no lease
7362 // to drain, no blackout to honor).
7363 controller.stop();
7364 assert_eq!(controller.status, Status::Off);
7365
7366 // Immediate retry is allowed — no lease drain wait.
7367 let result = controller.start(RemoteStart {
7368 workspace_label: "fixture".to_string(),
7369 target_ref: "target_fixture".to_string(),
7370 session_id: "session_fixture".to_string(),
7371 runtime_version: "0.9.1".to_string(),
7372 runtime_commit: "a".repeat(40),
7373 journal_dir: None,
7374 git_remote: None,
7375 });
7376 assert!(result.is_ok(), "{result:?}");
7377 }
7378
7379 #[test]
7380 fn sanitized_rejection_excerpt_reads_only_bounded_error_fields() {
7381 assert_eq!(
7382 sanitized_rejection_excerpt(
7383 br#"{"error":"client version not accepted","details":"noise"}"#
7384 )
7385 .as_deref(),
7386 Some("client version not accepted")
7387 );
7388 assert_eq!(
7389 sanitized_rejection_excerpt(br#"{"message":"enrollment closed"}"#).as_deref(),
7390 Some("enrollment closed")
7391 );
7392 // Control characters are stripped, never echoed. A JSON-escaped NUL
7393 // parses into the value; the strip must remove it. A raw NUL byte
7394 // makes serde_json reject the body outright, which is also safe
7395 // (no excerpt) — assert both directions.
7396 let with_control = "{\"error\":\"bad\\u0000opaque\"}".to_string();
7397 assert_eq!(
7398 sanitized_rejection_excerpt(with_control.as_bytes()).as_deref(),
7399 Some("badopaque")
7400 );
7401 let raw_nul = "{\"error\":\"bad\u{0}opaque\"}".to_string();
7402 assert_eq!(sanitized_rejection_excerpt(raw_nul.as_bytes()), None);
7403 // Non-JSON bodies yield no excerpt.
7404 assert_eq!(sanitized_rejection_excerpt(b"<html>403</html>"), None);
7405 // Overlong reasons are capped.
7406 let long = format!("{{\"error\":\"{}\"}}", "x".repeat(400));
7407 let excerpt = sanitized_rejection_excerpt(long.as_bytes()).expect("capped excerpt");
7408 assert!(excerpt.chars().count() <= 140);
7409 }
7410
7411 #[test]
7412 fn view_gate_matching_never_confuses_two_approval_cards() {
7413 use crate::tui::approval::ApprovalRequest;
7414 use crate::tui::approval::ApprovalView;
7415 use crate::tui::views::ViewStack;
7416
7417 let request = ApprovalRequest::new(
7418 "tool_A",
7419 "edit",
7420 "Edit A",
7421 &serde_json::json!({ "file": "a" }),
7422 "approval_key_A",
7423 );
7424 let card = ApprovalView::new(request);
7425 let gate_a = projected_approval_id("tool_A");
7426 let gate_b = projected_approval_id("tool_B");
7427
7428 let mut stack = ViewStack::new();
7429 stack.push(card);
7430 assert!(
7431 stack.top_matches_approval_gate(&gate_a),
7432 "the matching gate must match"
7433 );
7434 assert!(
7435 !stack.top_matches_approval_gate(&gate_b),
7436 "a different gate must NEVER match this card — the whole point of identity-aware dismissal"
7437 );
7438 assert!(!stack.top_matches_approval_gate("local_approval_missing"));
7439 }
7440
7441 #[tokio::test]
7442 async fn enrollment_rejection_carries_a_sanitized_actionable_reason() {
7443 let server = MockServer::start().await;
7444 Mock::given(method("POST"))
7445 .and(path("/oauth/device"))
7446 .respond_with(ResponseTemplate::new(403).set_body_json(json!({
7447 "error": "client version not accepted",
7448 "documentation": "https://example.test/docs"
7449 })))
7450 .mount(&server)
7451 .await;
7452 let client = crate::tls::reqwest_client_builder()
7453 .https_only(false)
7454 .redirect(reqwest::redirect::Policy::none())
7455 .timeout(Duration::from_secs(5))
7456 .build()
7457 .expect("test client");
7458 let error = public_request(
7459 &client,
7460 Method::POST,
7461 Url::parse(&format!(
7462 "{}/oauth/device",
7463 server.uri().trim_end_matches('/')
7464 ))
7465 .expect("server url"),
7466 json!({ "audience": "codewhale-runner" }),
7467 )
7468 .await
7469 .expect_err("403 must fail");
7470 assert!(error.contains("403"), "{error}");
7471 assert!(error.contains("client version not accepted"), "{error}");
7472 assert!(
7473 !error.contains("documentation"),
7474 "only the conventional error fields may surface: {error}"
7475 );
7476 }
7477
7478 #[tokio::test]
7479 async fn failed_relay_keeps_reconnect_blocked_until_lease_expiry() {
7480 let mut controller = RemoteControlController::default();
7481 controller.status = Status::Failed;
7482 controller.ownership_blocked_until = Some(Instant::now() + Duration::from_secs(90));
7483 // Mirror semantics: local input is never locked, but reconnecting
7484 // while the server lease may still be live is refused — the web must
7485 // not see two runners for the same session.
7486 let start = RemoteStart {
7487 workspace_label: "fixture".to_string(),
7488 target_ref: "target_fixture".to_string(),
7489 session_id: "session_fixture".to_string(),
7490 runtime_version: "0.9.1".to_string(),
7491 runtime_commit: "a".repeat(40),
7492 journal_dir: None,
7493 git_remote: None,
7494 };
7495 assert!(controller.start(start.clone()).is_err());
7496 // The web cannot answer shared approvals while the relay is failed.
7497 assert!(!controller.can_share_approval_with_web());
7498 controller.ownership_blocked_until = Some(Instant::now() - Duration::from_secs(1));
7499 assert!(controller.start(start).is_ok());
7500 }
7501
7502 #[test]
7503 fn stop_after_lease_expiry_preserves_pending_approvals_for_restoration() {
7504 let mut controller = RemoteControlController::default();
7505 controller.status = Status::Failed;
7506 controller.ownership_blocked_until = Some(Instant::now() - Duration::from_secs(1));
7507 controller.pending_approvals.insert(
7508 "approval_fixture".to_string(),
7509 PendingRemoteApproval {
7510 tool_id: "tool_fixture".to_string(),
7511 },
7512 );
7513
7514 controller.stop();
7515 assert_eq!(controller.status, Status::Failed);
7516 assert_eq!(controller.pending_approvals.len(), 1);
7517
7518 let event = controller.try_next_event();
7519 assert!(matches!(
7520 event,
7521 Some(RemoteEvent::OwnershipRestored { approvals })
7522 if approvals.len() == 1 && approvals[0].tool_id == "tool_fixture"
7523 ));
7524 assert_eq!(controller.status, Status::Off);
7525 assert!(controller.pending_approvals.is_empty());
7526 }
7527
7528 #[test]
7529 fn cancelling_a_connect_keeps_reconnect_blocked_until_lease_drain() {
7530 let mut controller = RemoteControlController::default();
7531 controller.status = Status::Connecting;
7532 controller.stop();
7533
7534 assert_eq!(controller.status, Status::Failed);
7535 // Mirror semantics: nothing about a cancelled connect locks local
7536 // input; reconnect stays blocked until the possible lease drains.
7537 let result = controller.start(RemoteStart {
7538 workspace_label: "fixture".to_string(),
7539 target_ref: "target_fixture".to_string(),
7540 session_id: "session_fixture".to_string(),
7541 runtime_version: "0.9.1".to_string(),
7542 runtime_commit: "a".repeat(40),
7543 journal_dir: None,
7544 git_remote: None,
7545 });
7546 assert!(result.is_err());
7547 assert!(result.unwrap_err().contains("previous remote lease"));
7548 }
7549
7550 #[test]
7551 fn failed_worker_retains_snapshot_marker_and_exact_unacked_event() {
7552 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
7553 controller.status = Status::Connected;
7554 controller.upload_snapshot("run-1", &[]);
7555 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
7556 panic!("snapshot queued");
7557 };
7558 let exact = envelopes[0].clone();
7559 event_tx
7560 .send(RemoteEvent::Failed("fixture disconnect".to_string()))
7561 .unwrap();
7562
7563 assert!(matches!(
7564 controller.try_next_event(),
7565 Some(RemoteEvent::Failed(_))
7566 ));
7567 assert!(controller.uploaded_snapshots.contains("run-1"));
7568 assert_eq!(
7569 controller.pending_runtime_events["run-1"]
7570 .values()
7571 .next()
7572 .map(|entry| &entry.envelope),
7573 Some(&exact)
7574 );
7575 // Fail-closed: the web can no longer answer shared approvals, and
7576 // reconnecting waits out the possible server lease.
7577 assert!(!controller.can_share_approval_with_web());
7578 assert!(controller.ownership_blocked_until.is_some());
7579 }
7580
7581 #[tokio::test]
7582 async fn cwc_runner_wire_contract_preserves_pending_and_recovery_commands() {
7583 let server = MockServer::start().await;
7584 Mock::given(method("GET"))
7585 .and(path("/api/local-runners/runner-1/runs/run-1/commands"))
7586 .and(query_param("since_seq", "0"))
7587 .and(query_param("include_accepted", "1"))
7588 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
7589 "commands": [{
7590 "seq": 1,
7591 "deliveryStatus": "pending",
7592 "ackStatus": "",
7593 "command": {
7594 "type": "prompt.request",
7595 "runId": "run-1",
7596 "turnId": "turn-1",
7597 "prompt": "Continue from the web."
7598 }
7599 }, {
7600 "seq": 2,
7601 "deliveryStatus": "acknowledged",
7602 "ackStatus": "accepted",
7603 "command": {
7604 "type": "run.control",
7605 "runId": "run-1",
7606 "action": "interrupt"
7607 }
7608 }]
7609 })))
7610 .expect(1)
7611 .mount(&server)
7612 .await;
7613 Mock::given(method("POST"))
7614 .and(path("/api/local-runners/runner-1/runs/run-1/events"))
7615 .and(body_json(json!({
7616 "acknowledgements": [{
7617 "commandSeq": 1,
7618 "commandType": "prompt.request",
7619 "status": "accepted",
7620 "turnId": "turn-1"
7621 }],
7622 "envelopes": []
7623 })))
7624 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
7625 "accepted": [],
7626 "count": 1,
7627 "cursor": 0
7628 })))
7629 .expect(1)
7630 .mount(&server)
7631 .await;
7632
7633 let enrollment = LiveEnrollment {
7634 persisted: PersistedEnrollment {
7635 schema_version: 1,
7636 control_plane_base: format!("{}/", server.uri()),
7637 runner_enrollment_id: "enrollment-1".to_string(),
7638 account_ref: "account-1".to_string(),
7639 device_id: "device-1".to_string(),
7640 target_ref: "target-1".to_string(),
7641 target_grant_ref: "grant-1".to_string(),
7642 runtime_version: "0.9.1".to_string(),
7643 runtime_commit: "a".repeat(40),
7644 bootstrap_secret: "b".repeat(43),
7645 },
7646 access_token: "fixture-runner-access-token".to_string(),
7647 };
7648 let client = crate::tls::reqwest_client_builder()
7649 .redirect(reqwest::redirect::Policy::none())
7650 .build()
7651 .expect("fixture client");
7652
7653 let listed = list_commands(&client, &enrollment, "runner-1", "run-1", 0)
7654 .await
7655 .expect("CWC command list");
7656 assert_eq!(listed.len(), 2);
7657 assert_eq!(listed[0].ack_status, "");
7658 assert_eq!(listed[1].ack_status, "accepted");
7659 let prompt =
7660 parse_remote_command(&listed[0].command, "run-1").expect("typed prompt command");
7661 upload_command_accepted(
7662 &client,
7663 &enrollment,
7664 "runner-1",
7665 "run-1",
7666 listed[0].seq,
7667 &prompt,
7668 )
7669 .await
7670 .expect("durable accepted acknowledgement");
7671 }
7672
7673 fn wired_controller() -> (
7674 RemoteControlController,
7675 mpsc::UnboundedReceiver<WorkerCommand>,
7676 mpsc::UnboundedSender<RemoteEvent>,
7677 tempfile::TempDir,
7678 ) {
7679 let mut controller = RemoteControlController::default();
7680 let (worker_tx, worker_rx) = mpsc::unbounded_channel();
7681 let (event_tx, event_rx) = mpsc::unbounded_channel();
7682 let journal_root = tempfile::tempdir().expect("wired controller journal root");
7683 controller.journal = Some(
7684 RuntimeEventJournal::open(
7685 journal_root.path(),
7686 "target_wired_fixture",
7687 "session_wired_fixture",
7688 )
7689 .expect("wired controller journal"),
7690 );
7691 controller.worker_tx = Some(worker_tx);
7692 controller.event_rx = Some(event_rx);
7693 (controller, worker_rx, event_tx, journal_root)
7694 }
7695
7696 fn turn_complete_event() -> EngineEvent {
7697 EngineEvent::TurnComplete {
7698 usage: codewhale_models::Usage::default(),
7699 parent_route_usage: codewhale_models::Usage::default(),
7700 routed_usage_dropped_records: 0,
7701 status: TurnOutcomeStatus::Completed,
7702 error: None,
7703 tool_catalog: None,
7704 base_url: None,
7705 }
7706 }
7707
7708 #[test]
7709 fn stop_refusal_holds_until_terminal_event_is_acknowledged() {
7710 let (mut controller, mut worker_rx, event_tx, _journal_root) = wired_controller();
7711 controller
7712 .activate_prompt("run_fixture", "turn_fixture")
7713 .unwrap();
7714 let refusal = controller.stop_refusal().expect("active turn blocks stop");
7715 assert!(refusal.contains("active remote turn"), "{refusal}");
7716
7717 controller.observe_engine_event(&turn_complete_event());
7718 assert!(
7719 !controller.has_active_run(),
7720 "the terminal event releases the run binding"
7721 );
7722 let refusal = controller
7723 .stop_refusal()
7724 .expect("a queued but unacknowledged terminal event must still block stop");
7725 assert!(refusal.contains("acknowledged"), "{refusal}");
7726 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
7727 panic!("the terminal envelope must be handed to the transport");
7728 };
7729 assert_eq!(envelopes[0]["event"], "turn.completed");
7730 let seq = envelopes[0]["seq"].as_u64().expect("terminal seq");
7731
7732 event_tx
7733 .send(RemoteEvent::RuntimeCursor {
7734 run_id: "run_fixture".to_string(),
7735 cursor: seq,
7736 })
7737 .unwrap();
7738 controller.try_next_event().unwrap();
7739 assert_eq!(
7740 controller.stop_refusal(),
7741 None,
7742 "a server-acknowledged terminal event unblocks stop"
7743 );
7744 }
7745
7746 #[test]
7747 fn different_run_attachment_is_quarantined_during_active_classic_work() {
7748 let (mut controller, _worker_rx, event_tx, _journal_root) = wired_controller();
7749 let attachment = |run_id: &str| RemoteAttachment {
7750 run_id: run_id.to_string(),
7751 workspace_id: "workspace_fixture".to_string(),
7752 runtime_cursor: 0,
7753 snapshot_present: false,
7754 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
7755 runtime_chat_relay_challenge: "a".repeat(32),
7756 };
7757 event_tx
7758 .send(RemoteEvent::Connected {
7759 account_ref: "account_fixture".to_string(),
7760 runner_id: "runner_fixture".to_string(),
7761 target_ref: "target_wired_fixture".to_string(),
7762 attachment: attachment("run_a"),
7763 links: RemoteLinks::default(),
7764 })
7765 .unwrap();
7766 controller.try_next_event().unwrap();
7767 controller.activate_prompt("run_a", "turn_a").unwrap();
7768
7769 event_tx
7770 .send(RemoteEvent::Attachment {
7771 account_ref: "account_fixture".to_string(),
7772 target_ref: "target_wired_fixture".to_string(),
7773 attachment: attachment("run_b"),
7774 links: RemoteLinks::default(),
7775 })
7776 .unwrap();
7777 let rejected = controller
7778 .try_next_event()
7779 .expect("rejected attachment event");
7780 assert!(matches!(rejected, RemoteEvent::Failed(_)));
7781 assert_eq!(controller.attached_run_id.as_deref(), Some("run_a"));
7782 assert!(controller.active_turn_matches("run_a", "turn_a"));
7783 assert_eq!(controller.status, Status::Failed);
7784 assert!(
7785 controller.worker_tx.is_none(),
7786 "rejected authority is quarantined"
7787 );
7788 assert!(
7789 controller
7790 .claim_command(
7791 "run_b",
7792 1,
7793 &RemoteCommand::Prompt {
7794 turn_id: "turn_b".to_string(),
7795 prompt: "must not run".to_string(),
7796 },
7797 )
7798 .is_err(),
7799 "no command from the rejected run may execute"
7800 );
7801 }
7802
7803 #[test]
7804 fn failed_stop_stays_fail_closed_with_no_dual_ownership() {
7805 let (mut controller, _worker_rx, event_tx, _journal_root) = wired_controller();
7806 controller.status = Status::Connected;
7807 controller.stop();
7808 assert_eq!(controller.status, Status::Stopping);
7809 assert!(
7810 !controller.can_share_approval_with_web(),
7811 "stopping must close the shared-decision channel before confirmation"
7812 );
7813
7814 // The worker could not confirm the drain or the offline heartbeat.
7815 event_tx
7816 .send(RemoteEvent::Failed(
7817 "the offline heartbeat could not be delivered".to_string(),
7818 ))
7819 .unwrap();
7820 let event = controller.try_next_event().unwrap();
7821 assert!(matches!(event, RemoteEvent::Failed(_)));
7822 assert!(
7823 !controller.can_share_approval_with_web(),
7824 "an unconfirmed stop must stay fail-closed through the lease expiry"
7825 );
7826 assert!(controller.ownership_blocked_until.is_some());
7827 assert!(
7828 controller.status_line().contains("lost after connecting"),
7829 "{}",
7830 controller.status_line()
7831 );
7832 assert!(
7833 controller.try_next_event().is_none(),
7834 "ownership must not be restored while the lease could still be live"
7835 );
7836 }
7837
7838 #[tokio::test]
7839 async fn stop_drain_flushes_runtime_outbox_with_byte_identical_retries() {
7840 let server = MockServer::start().await;
7841 let responder = AmbiguousRuntimeResponder::default();
7842 Mock::given(method("POST"))
7843 .and(path(
7844 "/api/local-runners/runner_fixture/runs/run_fixture/events",
7845 ))
7846 .respond_with(responder.clone())
7847 .expect(2)
7848 .mount(&server)
7849 .await;
7850 let mut enrollment = fixture_enrollment(&format!("{}/", server.uri()));
7851 let mut runner_id = "runner_fixture".to_string();
7852 let client = crate::tls::reqwest_client_builder()
7853 .redirect(reqwest::redirect::Policy::none())
7854 .build()
7855 .expect("fixture client");
7856 let (event_tx, mut event_rx) = mpsc::unbounded_channel();
7857 let mut outbox = RuntimeTransportOutbox::default();
7858 outbox
7859 .enqueue(
7860 "run_fixture",
7861 runtime_envelope(
7862 1,
7863 "turn.completed",
7864 Some("turn_fixture"),
7865 "2026-08-08T12:00:00Z".to_string(),
7866 json!({ "turn": { "status": "completed", "usage": {} } }),
7867 ),
7868 )
7869 .expect("queue terminal envelope");
7870
7871 drain_runtime_outbox_for_stop(
7872 &client,
7873 &mut enrollment,
7874 &mut runner_id,
7875 &fixture_start(),
7876 &event_tx,
7877 &mut outbox,
7878 Instant::now() + Duration::from_secs(10),
7879 )
7880 .await
7881 .expect("the drain must complete before stop is confirmed");
7882
7883 assert!(outbox.events.is_empty(), "the outbox must drain fully");
7884 let RemoteEvent::RuntimeCursor { run_id, cursor } = event_rx
7885 .try_recv()
7886 .expect("cursor event for journal compaction")
7887 else {
7888 panic!("drain must surface the server cursor");
7889 };
7890 assert_eq!(run_id, "run_fixture");
7891 assert_eq!(cursor, 1);
7892 let bodies = responder.bodies.lock().unwrap();
7893 assert_eq!(bodies.len(), 2, "ambiguous response must be retried");
7894 assert_eq!(bodies[0], bodies[1], "retries must be byte-identical");
7895 }
7896
7897 #[tokio::test]
7898 async fn stop_drain_deadline_failure_refuses_to_confirm_stop() {
7899 let server = MockServer::start().await;
7900 Mock::given(method("POST"))
7901 .and(path(
7902 "/api/local-runners/runner_fixture/runs/run_fixture/events",
7903 ))
7904 .respond_with(ResponseTemplate::new(500))
7905 .mount(&server)
7906 .await;
7907 let mut enrollment = fixture_enrollment(&format!("{}/", server.uri()));
7908 let mut runner_id = "runner_fixture".to_string();
7909 let client = crate::tls::reqwest_client_builder()
7910 .redirect(reqwest::redirect::Policy::none())
7911 .build()
7912 .expect("fixture client");
7913 let (event_tx, _event_rx) = mpsc::unbounded_channel();
7914 let mut outbox = RuntimeTransportOutbox::default();
7915 outbox
7916 .enqueue(
7917 "run_fixture",
7918 runtime_envelope(
7919 1,
7920 "turn.completed",
7921 Some("turn_fixture"),
7922 "2026-08-08T12:00:00Z".to_string(),
7923 json!({ "turn": { "status": "completed", "usage": {} } }),
7924 ),
7925 )
7926 .expect("queue terminal envelope");
7927
7928 let error = drain_runtime_outbox_for_stop(
7929 &client,
7930 &mut enrollment,
7931 &mut runner_id,
7932 &fixture_start(),
7933 &event_tx,
7934 &mut outbox,
7935 Instant::now() + Duration::from_millis(700),
7936 )
7937 .await
7938 .expect_err("an undrained outbox must fail the stop");
7939 assert!(error.contains("not confirmed"), "{error}");
7940 assert!(
7941 !outbox.events.is_empty(),
7942 "the exact unacknowledged envelope must be retained for the reconnect resend"
7943 );
7944 }
7945
7946 #[test]
7947 fn journal_roundtrip_restores_unacknowledged_envelopes_byte_identically() {
7948 let dir = tempfile::tempdir().expect("journal tempdir");
7949 let journal =
7950 RuntimeEventJournal::open(dir.path(), "target:fixture@01", "session:fixture@01")
7951 .expect("journal setup");
7952 assert!(journal.load().expect("missing file is empty").is_empty());
7953
7954 let delta = runtime_envelope(
7955 1,
7956 "item.delta",
7957 Some("turn_fixture"),
7958 "2026-08-08T12:00:00Z".to_string(),
7959 json!({ "kind": "agent_message", "delta": "exact 🫧 body" }),
7960 );
7961 let terminal = runtime_envelope(
7962 2,
7963 "turn.completed",
7964 Some("turn_fixture"),
7965 "2026-08-08T12:00:01Z".to_string(),
7966 json!({ "turn": { "status": "completed", "usage": {} } }),
7967 );
7968 let mut pending: HashMap<String, BTreeMap<u64, PendingRuntimeEnvelope>> = HashMap::new();
7969 let mut events = BTreeMap::new();
7970 for envelope in [delta.clone(), terminal.clone()] {
7971 let seq = runtime_envelope_seq(&envelope).unwrap();
7972 let encoded_len = serde_json::to_vec(&envelope).unwrap().len();
7973 let integrity = runtime_envelope_event(&envelope).is_some_and(integrity_critical_event);
7974 events.insert(
7975 seq,
7976 PendingRuntimeEnvelope {
7977 envelope,
7978 encoded_len,
7979 integrity,
7980 handed_off: true,
7981 },
7982 );
7983 }
7984 pending.insert("run_fixture".to_string(), events);
7985 journal.persist(&pending).expect("atomic persist");
7986 drop(journal);
7987
7988 let reopened =
7989 RuntimeEventJournal::open(dir.path(), "target:fixture@01", "session:fixture@01")
7990 .expect("journal reopen");
7991 let restored = reopened.load().expect("verified load");
7992 let events = restored.get("run_fixture").expect("restored run");
7993 assert_eq!(events.len(), 2);
7994 assert_eq!(
7995 serde_json::to_vec(&events[&1]).unwrap(),
7996 serde_json::to_vec(&delta).unwrap(),
7997 "a restored envelope must re-serialize byte-identically for ambiguous retries"
7998 );
7999 assert_eq!(
8000 serde_json::to_vec(&events[&2]).unwrap(),
8001 serde_json::to_vec(&terminal).unwrap()
8002 );
8003
8004 // Compaction: an empty pending set removes the file entirely.
8005 pending.get_mut("run_fixture").unwrap().clear();
8006 reopened.persist(&pending).expect("compacting persist");
8007 assert!(!reopened.path.exists(), "acknowledged journals are deleted");
8008 }
8009
8010 #[test]
8011 fn legacy_session_only_journal_is_preserved_and_repeated_starts_fail_closed() {
8012 let dir = tempfile::tempdir().expect("journal tempdir");
8013 let session_id = "session_legacy_shared";
8014 let journal_a = RuntimeEventJournal::open(dir.path(), "target_a", session_id).unwrap();
8015 let mut hasher = Sha256::new();
8016 hasher.update(b"cwc-remote-control-journal\0");
8017 hasher.update(session_id.as_bytes());
8018 let session_tag = bytes_to_hex(&hasher.finalize())[..32].to_string();
8019 assert_eq!(
8020 journal_a.legacy_path,
8021 dir.path().join(format!("journal_{session_tag}.json"))
8022 );
8023 let envelope = runtime_envelope(
8024 1,
8025 "turn.completed",
8026 Some("turn_legacy"),
8027 "2026-08-08T12:00:00Z".to_string(),
8028 json!({ "turn": { "status": "completed" } }),
8029 );
8030 crate::utils::write_atomic(
8031 &journal_a.legacy_path,
8032 &serde_json::to_vec(&json!({
8033 "schemaVersion": 1,
8034 "session": session_tag,
8035 "runs": { "run_legacy": [envelope] },
8036 }))
8037 .unwrap(),
8038 )
8039 .unwrap();
8040
8041 assert_eq!(journal_a.load().unwrap_err(), JOURNAL_LEGACY_SCOPE_ERROR);
8042 journal_a.quarantine_legacy();
8043 let unscoped_path = journal_a.legacy_unscoped_path.clone();
8044 assert!(unscoped_path.exists());
8045 assert!(!journal_a.path.exists());
8046 drop(journal_a);
8047
8048 let retry_a = RuntimeEventJournal::open(dir.path(), "target_a", session_id).unwrap();
8049 assert_eq!(retry_a.load().unwrap_err(), JOURNAL_LEGACY_SCOPE_ERROR);
8050 drop(retry_a);
8051 assert_eq!(
8052 RuntimeEventJournal::open(dir.path(), "target_b", session_id)
8053 .err()
8054 .expect("the legacy session remains permanently bound to target A"),
8055 CLASSIC_LEASE_SCOPE_ERROR
8056 );
8057 assert!(unscoped_path.exists());
8058 }
8059
8060 #[test]
8061 fn corrupt_v2_quarantine_never_moves_an_unrelated_legacy_journal() {
8062 let dir = tempfile::tempdir().expect("journal tempdir");
8063 let journal = RuntimeEventJournal::open(dir.path(), "target_b", "session_shared").unwrap();
8064 crate::utils::write_atomic(&journal.legacy_path, b"legacy target A recovery material")
8065 .unwrap();
8066 crate::utils::write_atomic(&journal.path, b"{not-json").unwrap();
8067 assert_eq!(journal.load().unwrap_err(), JOURNAL_UNTRUSTED_ERROR);
8068 journal.quarantine_current();
8069 assert!(journal.legacy_path.exists());
8070 assert!(!journal.legacy_unscoped_path.exists());
8071 assert!(journal.path.with_extension("corrupt").exists());
8072 }
8073
8074 #[test]
8075 fn runtime_chat_projection_is_not_handed_off_before_durable_journal_write() {
8076 let dir = tempfile::tempdir().expect("journal tempdir");
8077 let journal =
8078 RuntimeEventJournal::open(dir.path(), "target:fixture@01", "session:fixture@01")
8079 .expect("journal setup");
8080 let journal_path = journal.path.clone();
8081 let mut controller = RemoteControlController::default();
8082 let (worker_tx, mut worker_rx) = mpsc::unbounded_channel();
8083 controller.worker_tx = Some(worker_tx);
8084 controller.journal = Some(journal);
8085 let envelope = runtime_chat_envelope(
8086 1,
8087 "turn.completed",
8088 Some("local_thread_aaaaaaaaaaaaaaaaaaaaaaaa"),
8089 Some("local_turn_bbbbbbbbbbbbbbbbbbbbbbbb"),
8090 Some("native_event_fixture"),
8091 RUNTIME_CHAT_CATALOG_TIMESTAMP.to_string(),
8092 json!({ "turn": { "status": "completed" } }),
8093 );
8094
8095 inject_journal_persist_failures(&journal_path, 1);
8096 assert!(
8097 !controller.queue_runtime_chat_envelope("run_fixture", envelope.clone()),
8098 "a failed durable append must reject native cursor advancement"
8099 );
8100 assert!(controller.pending_runtime_events.is_empty());
8101 assert!(!controller.event_seq.contains_key("run_fixture"));
8102 assert!(worker_rx.try_recv().is_err());
8103
8104 assert!(controller.queue_runtime_chat_envelope("run_fixture", envelope));
8105 assert!(journal_path.exists());
8106 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
8107 panic!("durable retry must hand off the exact projection");
8108 };
8109 assert_eq!(envelopes[0]["seq"], 1);
8110 }
8111
8112 #[test]
8113 fn classic_terminal_journal_failure_stays_retained_and_fail_closed_until_cursor() {
8114 let (mut controller, mut worker_rx, _event_tx, _journal_root) = wired_controller();
8115 controller.status = Status::Connected;
8116 controller
8117 .activate_prompt("run_fixture", "turn_fixture")
8118 .unwrap();
8119 let journal_path = controller.journal.as_ref().unwrap().path.clone();
8120 inject_journal_persist_failures(&journal_path, 2);
8121
8122 controller.observe_engine_event(&turn_complete_event());
8123 assert_eq!(controller.status, Status::Failed);
8124 assert!(worker_rx.try_recv().is_err(), "failed append must not send");
8125 assert!(
8126 controller.has_unacknowledged_integrity_events(),
8127 "classic terminal must remain retryable in memory"
8128 );
8129 assert!(controller.stop_refusal().is_some());
8130 controller.ownership_blocked_until = Some(Instant::now() - Duration::from_millis(1));
8131 assert!(controller.try_next_event().is_none());
8132 assert_eq!(
8133 controller.status,
8134 Status::Failed,
8135 "lease expiry cannot discard an unacknowledged classic terminal"
8136 );
8137
8138 controller
8139 .journal
8140 .as_ref()
8141 .unwrap()
8142 .persist(&controller.pending_runtime_events)
8143 .expect("repair durable journal");
8144 let (worker_tx, mut repaired_worker_rx) = mpsc::unbounded_channel();
8145 controller.worker_tx = Some(worker_tx);
8146 controller.flush_pending_runtime_events("run_fixture");
8147 let WorkerCommand::Upload { envelopes, .. } = repaired_worker_rx.try_recv().unwrap() else {
8148 panic!("repaired terminal must be handed off");
8149 };
8150 let seq = runtime_envelope_seq(&envelopes[0]).unwrap();
8151 controller.reconcile_runtime_cursor("run_fixture", seq);
8152 assert!(!controller.has_unacknowledged_integrity_events());
8153 }
8154
8155 #[test]
8156 fn classic_worker_failure_retains_live_turn_until_exact_terminal_is_durable() {
8157 let (mut controller, _worker_rx, event_tx, _journal_root) = wired_controller();
8158 controller.status = Status::Connected;
8159 controller
8160 .activate_prompt("run_fixture", "turn_fixture")
8161 .unwrap();
8162 event_tx
8163 .send(RemoteEvent::Failed("fixture transport loss".to_string()))
8164 .unwrap();
8165 assert!(matches!(
8166 controller.try_next_event(),
8167 Some(RemoteEvent::Failed(_))
8168 ));
8169 assert!(controller.has_active_run());
8170 assert!(controller.has_durable_classic_lease());
8171
8172 controller.ownership_blocked_until = Some(Instant::now() - Duration::from_millis(1));
8173 assert!(controller.try_next_event().is_none());
8174 assert_eq!(controller.status, Status::Failed);
8175 assert!(controller.has_active_run());
8176 assert!(controller.start(fixture_start()).is_err());
8177
8178 let usage = codewhale_models::Usage {
8179 input_tokens: 17,
8180 output_tokens: 5,
8181 ..codewhale_models::Usage::default()
8182 };
8183 controller.observe_engine_event(&EngineEvent::TurnComplete {
8184 usage: usage.clone(),
8185 parent_route_usage: usage.clone(),
8186 routed_usage_dropped_records: 0,
8187 status: TurnOutcomeStatus::Completed,
8188 error: None,
8189 tool_catalog: None,
8190 base_url: None,
8191 });
8192 assert!(!controller.has_active_run());
8193 assert!(!controller.has_durable_classic_lease());
8194 let terminals: Vec<&PendingRuntimeEnvelope> =
8195 controller.pending_runtime_events["run_fixture"]
8196 .values()
8197 .filter(|entry| runtime_envelope_event(&entry.envelope) == Some("turn.completed"))
8198 .collect();
8199 assert_eq!(terminals.len(), 1);
8200 assert_eq!(
8201 terminals[0].envelope["payload"]["turn"]["usage"],
8202 serde_json::to_value(usage).unwrap()
8203 );
8204 }
8205
8206 #[test]
8207 fn classic_recovery_uses_persisted_seq_floor_and_ignores_older_terminal() {
8208 let root = tempfile::tempdir().unwrap();
8209 let old_terminal = runtime_envelope(
8210 40,
8211 "turn.completed",
8212 Some("turn_floor_fixture"),
8213 "2026-08-08T12:00:00Z".to_string(),
8214 json!({ "turn": { "status": "completed", "usage": {} } }),
8215 );
8216 {
8217 let mut first = RemoteControlController::default();
8218 first.journal = Some(
8219 RuntimeEventJournal::open(root.path(), "target_floor", "session_floor").unwrap(),
8220 );
8221 first.reset_pending_from(HashMap::from([(
8222 "run_floor".to_string(),
8223 BTreeMap::from([(40, old_terminal.clone())]),
8224 )]));
8225 first.event_seq.insert("run_floor".to_string(), 41);
8226 first
8227 .activate_prompt("run_floor", "turn_floor_fixture")
8228 .unwrap();
8229 assert_eq!(
8230 first
8231 .journal
8232 .as_ref()
8233 .unwrap()
8234 .classic_lease()
8235 .unwrap()
8236 .seq_floor,
8237 41
8238 );
8239 }
8240
8241 let mut reopened = RemoteControlController::default();
8242 let journal =
8243 RuntimeEventJournal::open(root.path(), "target_floor", "session_floor").unwrap();
8244 reopened.reset_pending_from(journal.load().unwrap());
8245 reopened.journal = Some(journal);
8246 reopened.recover_classic_lease_before_worker().unwrap();
8247
8248 let events = &reopened.pending_runtime_events["run_floor"];
8249 assert!(events.contains_key(&40));
8250 let recovered = events.get(&42).expect("recovery follows the durable floor");
8251 assert_eq!(
8252 runtime_envelope_event(&recovered.envelope),
8253 Some("turn.completed")
8254 );
8255 assert_eq!(recovered.envelope["turn_id"], "turn_floor_fixture");
8256 assert_eq!(recovered.envelope["payload"]["turn"]["status"], "failed");
8257 assert!(!reopened.has_durable_classic_lease());
8258 }
8259
8260 #[test]
8261 fn blank_predispatch_lease_id_always_starts_a_fresh_generation() {
8262 let root = tempfile::tempdir().unwrap();
8263 let mut controller = RemoteControlController::default();
8264 controller.journal = Some(
8265 RuntimeEventJournal::open(root.path(), "target_generation", "session_generation")
8266 .unwrap(),
8267 );
8268 controller
8269 .begin_classic_lease(ClassicRunLease {
8270 run_id: "run_generation".to_string(),
8271 turn_id: None,
8272 lease_id: String::new(),
8273 seq_floor: 0,
8274 })
8275 .unwrap();
8276 let stale_lease = controller
8277 .journal
8278 .as_ref()
8279 .and_then(RuntimeEventJournal::classic_lease)
8280 .unwrap();
8281
8282 controller
8283 .begin_classic_lease(ClassicRunLease {
8284 run_id: "run_generation".to_string(),
8285 turn_id: None,
8286 lease_id: String::new(),
8287 seq_floor: 0,
8288 })
8289 .unwrap();
8290 let fresh_lease = controller
8291 .journal
8292 .as_ref()
8293 .and_then(RuntimeEventJournal::classic_lease)
8294 .unwrap();
8295
8296 assert_ne!(
8297 fresh_lease.lease_id, stale_lease.lease_id,
8298 "a stale empty-turn lease cannot define the next recovery identity"
8299 );
8300 }
8301
8302 #[test]
8303 fn separate_predispatch_crashes_on_one_run_get_distinct_recovery_turn_ids() {
8304 let root = tempfile::tempdir().unwrap();
8305 let first_recovery_turn;
8306 {
8307 let mut first = RemoteControlController::default();
8308 first.journal = Some(
8309 RuntimeEventJournal::open(root.path(), "target_generation", "session_generation")
8310 .unwrap(),
8311 );
8312 first
8313 .begin_classic_lease(ClassicRunLease {
8314 run_id: "run_generation".to_string(),
8315 turn_id: None,
8316 lease_id: String::new(),
8317 seq_floor: 0,
8318 })
8319 .unwrap();
8320 }
8321 {
8322 let mut recovered = RemoteControlController::default();
8323 let journal =
8324 RuntimeEventJournal::open(root.path(), "target_generation", "session_generation")
8325 .unwrap();
8326 recovered.reset_pending_from(journal.load().unwrap());
8327 recovered.journal = Some(journal);
8328 recovered.recover_classic_lease_before_worker().unwrap();
8329 first_recovery_turn =
8330 recovered.pending_runtime_events["run_generation"][&1].envelope["turn_id"]
8331 .as_str()
8332 .unwrap()
8333 .to_string();
8334 recovered.reconcile_runtime_cursor("run_generation", 1);
8335 recovered
8336 .begin_classic_lease(ClassicRunLease {
8337 run_id: "run_generation".to_string(),
8338 turn_id: None,
8339 lease_id: String::new(),
8340 seq_floor: recovered.runtime_seq_floor("run_generation"),
8341 })
8342 .unwrap();
8343 }
8344 let mut recovered_again = RemoteControlController::default();
8345 let journal =
8346 RuntimeEventJournal::open(root.path(), "target_generation", "session_generation")
8347 .unwrap();
8348 recovered_again.reset_pending_from(journal.load().unwrap());
8349 recovered_again.journal = Some(journal);
8350 recovered_again
8351 .recover_classic_lease_before_worker()
8352 .unwrap();
8353 let second_recovery_turn = recovered_again.pending_runtime_events["run_generation"][&2]
8354 .envelope["turn_id"]
8355 .as_str()
8356 .unwrap();
8357 assert_ne!(first_recovery_turn, second_recovery_turn);
8358 }
8359
8360 #[test]
8361 fn classic_terminal_cursor_repairs_failed_canonical_clear_before_compaction() {
8362 let (mut controller, _worker_rx, _event_tx, journal_root) = wired_controller();
8363 controller.status = Status::Connected;
8364 controller
8365 .activate_prompt("run_fixture", "turn_fixture")
8366 .unwrap();
8367 let state_path = controller
8368 .journal
8369 .as_ref()
8370 .unwrap()
8371 .active_index_path
8372 .clone();
8373 inject_journal_persist_failures(&state_path, 1);
8374
8375 controller.observe_engine_event(&turn_complete_event());
8376 assert!(controller.has_durable_classic_lease());
8377 assert!(controller.has_active_run());
8378 let terminal_seq = *controller.pending_runtime_events["run_fixture"]
8379 .last_key_value()
8380 .unwrap()
8381 .0;
8382 controller.reconcile_runtime_cursor("run_fixture", terminal_seq);
8383 assert!(!controller.has_durable_classic_lease());
8384 assert!(!controller.has_active_run());
8385 assert!(controller.pending_runtime_events.is_empty());
8386 drop(controller);
8387
8388 let journal = RuntimeEventJournal::open(
8389 journal_root.path(),
8390 "target_wired_fixture",
8391 "session_wired_fixture",
8392 )
8393 .unwrap();
8394 assert!(journal.classic_lease().is_none());
8395 assert!(journal.load().unwrap().is_empty());
8396 }
8397
8398 #[test]
8399 fn saved_session_is_permanently_bound_to_its_first_remote_target() {
8400 let dir = tempfile::tempdir().expect("journal tempdir");
8401 let first = RuntimeEventJournal::open(dir.path(), "target_a", "session_shared")
8402 .expect("first target journal");
8403 assert_eq!(
8404 RuntimeEventJournal::open(dir.path(), "target_b", "session_shared")
8405 .err()
8406 .expect("a second target must fail closed"),
8407 CLASSIC_LEASE_SCOPE_ERROR
8408 );
8409
8410 let pending_for = |run_id: &str, marker: &str| {
8411 let envelope = runtime_envelope(
8412 1,
8413 "turn.completed",
8414 None,
8415 "2026-08-08T12:00:00Z".to_string(),
8416 json!({ "turn": { "status": "completed" }, "marker": marker }),
8417 );
8418 let encoded_len = serde_json::to_vec(&envelope).unwrap().len();
8419 HashMap::from([(
8420 run_id.to_string(),
8421 BTreeMap::from([(
8422 1,
8423 PendingRuntimeEnvelope {
8424 envelope,
8425 encoded_len,
8426 integrity: true,
8427 handed_off: true,
8428 },
8429 )]),
8430 )])
8431 };
8432 first.persist(&pending_for("run_a", "a")).unwrap();
8433 assert_eq!(
8434 first.load().unwrap().keys().collect::<Vec<_>>(),
8435 vec!["run_a"]
8436 );
8437 drop(first);
8438 assert_eq!(
8439 RuntimeEventJournal::open(dir.path(), "target_b", "session_shared")
8440 .err()
8441 .expect("the permanent target binding must fail closed"),
8442 CLASSIC_LEASE_SCOPE_ERROR,
8443 "the target binding survives terminal settlement and process restart"
8444 );
8445 RuntimeEventJournal::open(dir.path(), "target_a", "session_shared")
8446 .expect("the original target can reopen its saved session");
8447 }
8448
8449 #[cfg(unix)]
8450 #[test]
8451 fn journal_directory_and_file_are_owner_only() {
8452 use std::os::unix::fs::PermissionsExt;
8453 let base = tempfile::tempdir().expect("journal tempdir");
8454 let dir = base.path().join("journal");
8455 let journal = RuntimeEventJournal::open(&dir, "target:fixture@01", "session:fixture@01")
8456 .expect("journal setup");
8457 let mut pending: HashMap<String, BTreeMap<u64, PendingRuntimeEnvelope>> = HashMap::new();
8458 let envelope = runtime_envelope(
8459 1,
8460 "turn.completed",
8461 None,
8462 "2026-08-08T12:00:00Z".to_string(),
8463 json!({ "turn": { "status": "completed", "usage": {} } }),
8464 );
8465 let encoded_len = serde_json::to_vec(&envelope).unwrap().len();
8466 pending.insert(
8467 "run_fixture".to_string(),
8468 BTreeMap::from([(
8469 1,
8470 PendingRuntimeEnvelope {
8471 envelope,
8472 encoded_len,
8473 integrity: true,
8474 handed_off: true,
8475 },
8476 )]),
8477 );
8478 journal.persist(&pending).expect("atomic persist");
8479 let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
8480 assert_eq!(dir_mode, 0o700, "journal directory must be private");
8481 let file_mode = std::fs::metadata(&journal.path)
8482 .unwrap()
8483 .permissions()
8484 .mode()
8485 & 0o777;
8486 assert_eq!(file_mode, 0o600, "journal file must be owner-only");
8487 }
8488
8489 #[test]
8490 fn corrupt_journal_fails_closed_and_start_quarantines_it() {
8491 let dir = tempfile::tempdir().expect("journal tempdir");
8492 let probe = RuntimeEventJournal::open(dir.path(), "target_fixture", "session:fixture@01")
8493 .expect("journal setup");
8494 std::fs::write(&probe.path, b"{ not json").expect("plant corrupt journal");
8495 let corrupt_path = probe.path.clone();
8496 drop(probe);
8497
8498 let mut controller = RemoteControlController::default();
8499 let error = controller
8500 .start(RemoteStart {
8501 journal_dir: Some(dir.path().to_path_buf()),
8502 ..fixture_start()
8503 })
8504 .expect_err("a corrupt journal must fail closed");
8505 assert_eq!(error, JOURNAL_UNTRUSTED_ERROR);
8506 assert_eq!(controller.status, Status::Off, "no relay may start");
8507 assert!(
8508 !corrupt_path.exists(),
8509 "the untrusted journal must not stay in place"
8510 );
8511 assert!(
8512 corrupt_path.with_extension("corrupt").exists(),
8513 "the untrusted journal is quarantined, not silently discarded"
8514 );
8515
8516 // A mismatched session tag is equally untrusted.
8517 let other =
8518 RuntimeEventJournal::open(dir.path(), "target:fixture@01", "session:fixture@02")
8519 .expect("journal setup");
8520 std::fs::write(
8521 &other.path,
8522 serde_json::to_vec(&json!({
8523 "schemaVersion": JOURNAL_SCHEMA_VERSION,
8524 "scope": "00000000000000000000000000000000",
8525 "runs": {},
8526 }))
8527 .unwrap(),
8528 )
8529 .expect("plant mismatched journal");
8530 assert_eq!(other.load().unwrap_err(), JOURNAL_UNTRUSTED_ERROR);
8531 }
8532
8533 #[test]
8534 fn start_recovers_journaled_envelopes_and_resends_on_connect() {
8535 let dir = tempfile::tempdir().expect("journal tempdir");
8536 {
8537 let journal =
8538 RuntimeEventJournal::open(dir.path(), "target:fixture@01", "session:fixture@01")
8539 .expect("journal setup");
8540 let envelope = runtime_envelope(
8541 3,
8542 "turn.completed",
8543 Some("turn_fixture"),
8544 "2026-08-08T12:00:00Z".to_string(),
8545 json!({ "turn": { "status": "completed", "usage": {} } }),
8546 );
8547 let encoded_len = serde_json::to_vec(&envelope).unwrap().len();
8548 let pending = HashMap::from([(
8549 "run_fixture".to_string(),
8550 BTreeMap::from([(
8551 3,
8552 PendingRuntimeEnvelope {
8553 envelope,
8554 encoded_len,
8555 integrity: true,
8556 handed_off: true,
8557 },
8558 )]),
8559 )]);
8560 journal
8561 .persist(&pending)
8562 .expect("previous process persisted");
8563 }
8564
8565 let mut controller = RemoteControlController::default();
8566 let journal =
8567 RuntimeEventJournal::open(dir.path(), "target:fixture@01", "session:fixture@01")
8568 .expect("journal setup");
8569 controller.reset_pending_from(journal.load().expect("clean recovery"));
8570 controller.journal = Some(journal);
8571 assert!(
8572 controller.has_unacknowledged_integrity_events(),
8573 "recovered terminal state must gate /rc stop until acknowledged"
8574 );
8575 assert!(controller.stop_refusal().is_some());
8576
8577 let (worker_tx, mut worker_rx) = mpsc::unbounded_channel();
8578 let (event_tx, event_rx) = mpsc::unbounded_channel();
8579 controller.worker_tx = Some(worker_tx);
8580 controller.event_rx = Some(event_rx);
8581 event_tx
8582 .send(RemoteEvent::Connected {
8583 account_ref: "account_fixture".to_string(),
8584 runner_id: "runner_fixture".to_string(),
8585 target_ref: "target_fixture".to_string(),
8586 attachment: RemoteAttachment {
8587 run_id: "run_other".to_string(),
8588 workspace_id: "workspace_fixture".to_string(),
8589 runtime_cursor: 0,
8590 snapshot_present: false,
8591 runtime_chat_relay_protocol: RUNTIME_CHAT_RELAY_PROTOCOL.to_string(),
8592 runtime_chat_relay_challenge: "a".repeat(32),
8593 },
8594 links: RemoteLinks::default(),
8595 })
8596 .unwrap();
8597 controller.try_next_event().unwrap();
8598 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
8599 panic!("recovered envelopes must resend on connect");
8600 };
8601 assert_eq!(envelopes[0]["seq"], 3);
8602 assert_eq!(envelopes[0]["event"], "turn.completed");
8603 }
8604
8605 #[test]
8606 fn delta_pressure_sheds_to_resync_and_preserves_integrity_capacity() {
8607 let (mut controller, _worker_rx, _event_tx, _journal_root) = wired_controller();
8608 let delta_budget = MAX_JOURNAL_EVENTS - JOURNAL_RESERVED_INTEGRITY_EVENTS;
8609 for index in 0..delta_budget {
8610 assert!(
8611 controller.queue_runtime_envelope(
8612 "run_fixture",
8613 runtime_envelope(
8614 (index + 1) as u64,
8615 "item.delta",
8616 Some("turn_fixture"),
8617 "2026-08-08T12:00:00Z".to_string(),
8618 json!({ "kind": "agent_message", "delta": index.to_string() }),
8619 ),
8620 ),
8621 "delta {index} fits the unreserved budget"
8622 );
8623 }
8624 assert_eq!(controller.pending_event_count, delta_budget);
8625
8626 let shed_seq = (delta_budget + 1) as u64;
8627 assert!(
8628 !controller.queue_runtime_envelope(
8629 "run_fixture",
8630 runtime_envelope(
8631 shed_seq,
8632 "item.delta",
8633 Some("turn_fixture"),
8634 "2026-08-08T12:00:00Z".to_string(),
8635 json!({ "kind": "agent_message", "delta": "over budget" }),
8636 ),
8637 ),
8638 "a delta beyond the unreserved budget is shed"
8639 );
8640 assert_eq!(controller.pending_event_count, delta_budget);
8641 assert!(controller.resync_required.contains("run_fixture"));
8642 assert_ne!(
8643 controller.status,
8644 Status::Failed,
8645 "delta pressure is ordinary and must not fail the relay"
8646 );
8647
8648 // Reserved capacity keeps the terminal boundary deliverable, and the
8649 // terminal boundary schedules the resynchronization snapshot.
8650 controller
8651 .activate_prompt("run_fixture", "turn_fixture")
8652 .unwrap();
8653 controller.observe_engine_event(&turn_complete_event());
8654 assert!(
8655 controller.has_unacknowledged_integrity_events(),
8656 "the terminal envelope must use the reserved capacity"
8657 );
8658 assert_eq!(
8659 controller.take_pending_resync().as_deref(),
8660 Some("run_fixture"),
8661 "the shed run resynchronizes at its terminal boundary"
8662 );
8663 controller.upload_resync_snapshot("run_fixture", &[]);
8664 assert!(
8665 controller
8666 .pending_runtime_events
8667 .get("run_fixture")
8668 .is_some_and(|events| events.values().any(|entry| runtime_envelope_event(
8669 &entry.envelope
8670 ) == Some("session.snapshot"))),
8671 "the bounded snapshot restores account truth"
8672 );
8673 }
8674
8675 #[test]
8676 fn integrity_overflow_fails_closed_without_restoring_input() {
8677 let (mut controller, _worker_rx, _event_tx, _journal_root) = wired_controller();
8678 controller.status = Status::Connected;
8679 for index in 0..MAX_JOURNAL_EVENTS {
8680 assert!(controller.queue_runtime_envelope(
8681 "run_fixture",
8682 runtime_envelope(
8683 (index + 1) as u64,
8684 "item.failed",
8685 Some("turn_fixture"),
8686 "2026-08-08T12:00:00Z".to_string(),
8687 json!({ "item": { "id": index.to_string(), "kind": "error" } }),
8688 ),
8689 ));
8690 }
8691
8692 assert!(!controller.queue_runtime_envelope(
8693 "run_fixture",
8694 runtime_envelope(
8695 (MAX_JOURNAL_EVENTS + 1) as u64,
8696 "turn.completed",
8697 Some("turn_fixture"),
8698 "2026-08-08T12:00:00Z".to_string(),
8699 json!({ "turn": { "status": "failed", "usage": {} } }),
8700 ),
8701 ));
8702 assert_eq!(
8703 controller.status,
8704 Status::Failed,
8705 "losing integrity state can never be silent"
8706 );
8707 assert!(
8708 !controller.can_share_approval_with_web(),
8709 "a failed-closed relay keeps the shared-decision channel closed"
8710 );
8711 }
8712
8713 #[test]
8714 fn message_deltas_coalesce_until_a_handoff_boundary() {
8715 let (mut controller, mut worker_rx, _event_tx, _journal_root) = wired_controller();
8716 controller
8717 .activate_prompt("run_fixture", "turn_fixture")
8718 .unwrap();
8719 controller.observe_engine_event(&EngineEvent::MessageDelta {
8720 index: 0,
8721 content: "Hello ".to_string(),
8722 });
8723 controller.observe_engine_event(&EngineEvent::MessageDelta {
8724 index: 0,
8725 content: "world".to_string(),
8726 });
8727 assert!(
8728 worker_rx.try_recv().is_err(),
8729 "deferred deltas coalesce before any transport handoff"
8730 );
8731 let events = controller
8732 .pending_runtime_events
8733 .get("run_fixture")
8734 .unwrap();
8735 assert_eq!(events.len(), 1, "both deltas share one envelope");
8736 assert_eq!(
8737 events.values().next().unwrap().envelope["payload"]["delta"],
8738 "Hello world"
8739 );
8740
8741 controller.observe_engine_event(&EngineEvent::ToolCallStarted {
8742 id: "tool_fixture".to_string(),
8743 name: "shell".to_string(),
8744 input: json!({}),
8745 });
8746 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
8747 panic!("the coalesced delta must hand off before a later event");
8748 };
8749 assert_eq!(envelopes[0]["event"], "item.delta");
8750 assert_eq!(envelopes[0]["payload"]["delta"], "Hello world");
8751 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
8752 panic!("the tool event follows the coalesced delta");
8753 };
8754 assert_eq!(envelopes[0]["event"], "item.started");
8755
8756 // Once handed off an envelope is immutable; new deltas open a fresh
8757 // envelope that flushes on the next UI poll.
8758 controller.observe_engine_event(&EngineEvent::MessageDelta {
8759 index: 0,
8760 content: "again".to_string(),
8761 });
8762 assert!(worker_rx.try_recv().is_err());
8763 assert!(controller.try_next_event().is_none());
8764 let WorkerCommand::Upload { envelopes, .. } = worker_rx.try_recv().unwrap() else {
8765 panic!("the UI poll hands the deferred delta to the transport");
8766 };
8767 assert_eq!(envelopes[0]["payload"]["delta"], "again");
8768 }
8769 #[test]
8770 fn runtime_image_legacy_work_never_downgrades_to_text() {
8771 let image = crate::image_attach::tests::runtime_image_fixture(1);
8772 let command = json!({"type":"prompt.request","runId":"run_fixture","turnId":"turn_fixture","prompt":"look","images":[image]});
8773 assert!(
8774 parse_remote_command(&command, "run_fixture")
8775 .unwrap_err()
8776 .contains("legacy remote Work")
8777 );
8778 let mut text = command;
8779 text.as_object_mut().unwrap().remove("images");
8780 assert!(matches!(
8781 parse_remote_command(&text, "run_fixture").unwrap(),
8782 RemoteCommand::Prompt { .. }
8783 ));
8784 }
8785
8786 #[tokio::test]
8787 async fn runtime_image_command_response_has_bounded_larger_budget() {
8788 use axum::{Router, routing::get};
8789 let payload = serde_json::to_string(
8790 &json!({"commands":[],"fixture": "x".repeat(MAX_RESPONSE_BYTES + 1)}),
8791 )
8792 .unwrap();
8793 let app = Router::new().route(
8794 "/",
8795 get(move || {
8796 let payload = payload.clone();
8797 async move { payload }
8798 }),
8799 );
8800 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
8801 let addr = listener.local_addr().unwrap();
8802 let server = tokio::spawn(async move {
8803 axum::serve(listener, app).await.unwrap();
8804 });
8805 let client = crate::tls::reqwest_client();
8806 let url = format!("http://{addr}/");
8807 assert!(
8808 read_bounded_json(client.get(&url).send().await.unwrap())
8809 .await
8810 .is_err()
8811 );
8812 let parsed = read_bounded_json_with_limit(
8813 client.get(&url).send().await.unwrap(),
8814 codewhale_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES,
8815 )
8816 .await
8817 .unwrap();
8818 assert_eq!(
8819 parsed["fixture"].as_str().unwrap().len(),
8820 MAX_RESPONSE_BYTES + 1
8821 );
8822 server.abort();
8823 }
8824 }
8825
8825 lines RUST