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