Skip to main content

car_server_core/
handler.rs

1//! WebSocket connection handler — bidirectional JSON-RPC.
2//!
3//! Tool callback flow:
4//! 1. Client submits proposal via proposal.submit
5//! 2. Runtime encounters a ToolCall action
6//! 3. WsToolExecutor sends tools.execute request to client via shared write half
7//! 4. WsToolExecutor awaits response on a oneshot channel
8//! 5. Client executes tool locally, sends JSON-RPC response back
9//! 6. Handler receives the response, resolves the oneshot
10//! 7. Runtime continues execution with the tool result
11
12use crate::session::{A2aRouteAuth, ChatGoalState, ServerState, WsChannel};
13use car_proto::*;
14use car_verify;
15use futures::{SinkExt, StreamExt};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use std::collections::HashMap;
20use std::net::SocketAddr;
21use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
22use std::sync::Arc;
23use tokio::net::TcpStream;
24use tokio::sync::Mutex;
25use tokio_tungstenite::{accept_async, tungstenite::Message};
26use tracing::{error, info, instrument};
27
28#[cfg(test)]
29struct CredentialFanoutTestGate {
30    ready: tokio::sync::Notify,
31    release: tokio::sync::Notify,
32}
33
34#[cfg(test)]
35fn credential_fanout_test_gate() -> &'static CredentialFanoutTestGate {
36    static GATE: std::sync::OnceLock<CredentialFanoutTestGate> = std::sync::OnceLock::new();
37    GATE.get_or_init(|| CredentialFanoutTestGate {
38        ready: tokio::sync::Notify::new(),
39        release: tokio::sync::Notify::new(),
40    })
41}
42
43#[cfg(test)]
44async fn pause_credential_fanout_for_test() {
45    if std::env::var_os("CAR_TEST_PAUSE_CREDENTIAL_FANOUT").is_some() {
46        let gate = credential_fanout_test_gate();
47        gate.ready.notify_one();
48        gate.release.notified().await;
49    }
50}
51
52#[cfg(not(test))]
53async fn pause_credential_fanout_for_test() {}
54
55#[cfg(test)]
56fn credential_handoff_test_gate() -> &'static CredentialFanoutTestGate {
57    static GATE: std::sync::OnceLock<CredentialFanoutTestGate> = std::sync::OnceLock::new();
58    GATE.get_or_init(|| CredentialFanoutTestGate {
59        ready: tokio::sync::Notify::new(),
60        release: tokio::sync::Notify::new(),
61    })
62}
63
64#[cfg(test)]
65async fn pause_credential_handoff_for_test() {
66    if std::env::var_os("CAR_TEST_PAUSE_CREDENTIAL_HANDOFF").is_some() {
67        let gate = credential_handoff_test_gate();
68        gate.ready.notify_one();
69        gate.release.notified().await;
70    }
71}
72
73#[cfg(not(test))]
74async fn pause_credential_handoff_for_test() {}
75
76fn credential_event_is_eligible(
77    session: &crate::session::ClientSession,
78    transport_auth_required: bool,
79    host_role_required: bool,
80) -> bool {
81    (!transport_auth_required || session.authenticated.load(Ordering::Acquire))
82        && (!host_role_required || session.is_host.load(Ordering::Acquire))
83        && session.negotiated_protocol_version.load(Ordering::Acquire)
84            == car_proto::PROTOCOL_VERSION
85}
86
87fn credential_status_advances(
88    previous: Option<car_auth::CredentialReadStatus>,
89    next: car_auth::CredentialReadStatus,
90) -> bool {
91    let Some(previous) = previous else {
92        return true;
93    };
94    if next.generation != previous.generation {
95        return next.generation > previous.generation;
96    }
97    previous.state == car_auth::CredentialReadStatusState::Pending
98        && next.state != car_auth::CredentialReadStatusState::Pending
99}
100
101#[derive(Debug, Clone, Deserialize)]
102#[allow(dead_code)]
103pub struct JsonRpcMessage {
104    #[serde(default)]
105    pub jsonrpc: String,
106    #[serde(default)]
107    pub method: Option<String>,
108    #[serde(default)]
109    pub params: Value,
110    #[serde(default)]
111    pub id: Value,
112    // Response fields
113    #[serde(default)]
114    pub result: Option<Value>,
115    #[serde(default)]
116    pub error: Option<Value>,
117}
118
119#[derive(Debug, Serialize)]
120pub struct JsonRpcResponse {
121    pub jsonrpc: &'static str,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub result: Option<Value>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub error: Option<JsonRpcError>,
126    pub id: Value,
127}
128
129#[derive(Debug, Serialize)]
130pub struct JsonRpcError {
131    pub code: i32,
132    pub message: String,
133}
134
135impl JsonRpcResponse {
136    pub fn success(id: Value, result: Value) -> Self {
137        Self {
138            jsonrpc: "2.0",
139            result: Some(result),
140            error: None,
141            id,
142        }
143    }
144    pub fn error(id: Value, code: i32, message: &str) -> Self {
145        Self {
146            jsonrpc: "2.0",
147            result: None,
148            error: Some(JsonRpcError {
149                code,
150                message: message.to_string(),
151            }),
152            id,
153        }
154    }
155}
156
157/// The `-32001 auth required` body, naming the file the client should actually
158/// read.
159///
160/// It used to name the three per-platform defaults unconditionally. That is the
161/// wrong file for any daemon started with `CAR_HOME`: the token directory moves
162/// under the state root (see `car_daemon_client::auth_token`, which resolves
163/// `CAR_HOME` ahead of the per-platform branches), so an operator running an
164/// isolated daemon was sent to the *primary's* token — the one credential
165/// `CAR_HOME` exists to stop the two daemons from sharing. Names the path, never
166/// the token value.
167fn auth_required_message() -> String {
168    const PREFIX: &str = "auth required: send `session.auth` with the per-launch token";
169    const SUFFIX: &str = "as the first frame on this connection";
170    match car_home::override_root() {
171        Some(root) => format!(
172            "{PREFIX} from {} {SUFFIX} (CAR_HOME is set, so this daemon's token \
173             lives under its own state root, not the default per-platform directory)",
174            root.join("ai.parslee.car").join("auth-token").display(),
175        ),
176        None => format!(
177            "{PREFIX} from ~/Library/Application Support/ai.parslee.car/auth-token \
178             (macOS), $XDG_RUNTIME_DIR/ai.parslee.car/auth-token (Linux), or \
179             %LOCALAPPDATA%\\ai.parslee.car\\auth-token (Windows) {SUFFIX} \
180             (setting CAR_HOME moves the token under that state root instead)"
181        ),
182    }
183}
184
185// ---------------------------------------------------------------------------
186// Param-extraction helpers
187//
188// Every JSON-RPC handler pulls fields off `req.params` (a `serde_json::Value`).
189// Before these helpers each arm hand-rolled the same three shapes — a required
190// string (`.get(f).and_then(|v| v.as_str()).ok_or_else(|| "f is required")`), an
191// optional string, and a full typed deserialize — with per-call-site error
192// wording. These centralize the pattern and the wording so the ~200 call sites
193// read uniformly and a new handler can't invent a fourth phrasing.
194// ---------------------------------------------------------------------------
195
196/// A required string field, or a uniform invalid-params error that names the
197/// missing field. The prefix is classified as JSON-RPC -32602 at the dispatch
198/// boundary.
199pub(crate) fn require_str<'a>(params: &'a Value, field: &str) -> Result<&'a str, String> {
200    params
201        .get(field)
202        .and_then(|v| v.as_str())
203        .ok_or_else(|| format!("invalid params: {field} is required"))
204}
205
206/// An optional string field (absent or non-string → `None`).
207pub(crate) fn opt_str<'a>(params: &'a Value, field: &str) -> Option<&'a str> {
208    params.get(field).and_then(|v| v.as_str())
209}
210
211/// An optional string field with a fallback default.
212pub(crate) fn str_or<'a>(params: &'a Value, field: &str, default: &'a str) -> &'a str {
213    params
214        .get(field)
215        .and_then(|v| v.as_str())
216        .unwrap_or(default)
217}
218
219/// Deserialize the whole params object into `T`, with a uniform
220/// `"invalid params: …"` error. The typed-params companion to [`require_str`].
221pub(crate) fn typed_params<T: serde::de::DeserializeOwned>(params: &Value) -> Result<T, String> {
222    serde_json::from_value(params.clone()).map_err(|e| format!("invalid params: {e}"))
223}
224
225/// Convenience wrapper for the standalone `car-server` binary: accepts
226/// the WebSocket handshake on a raw [`TcpStream`] then delegates to
227/// [`run_dispatch`]. Embedders that already have a handshake-completed
228/// `WebSocketStream` skip this and call `run_dispatch` directly.
229#[instrument(
230    name = "ws.connection",
231    skip_all,
232    fields(peer = %peer),
233)]
234pub async fn handle_connection(
235    stream: TcpStream,
236    peer: SocketAddr,
237    state: Arc<ServerState>,
238) -> Result<(), Box<dyn std::error::Error>> {
239    let ws_stream = accept_async(stream).await?;
240    let (write, read) = ws_stream.split();
241    run_dispatch(read, Box::pin(write), peer.to_string(), state).await
242}
243
244/// Convenience wrapper for the daemon-as-default Unix-socket
245/// listener. Same shape as [`handle_connection`] but accepts a
246/// `UnixStream` — used by the per-user UDS listener in
247/// `car-server::main` (default transport for FFI thin clients,
248/// since UDS is faster + permission-scoped vs localhost TCP).
249///
250/// Unix-only — `tokio::net::UnixStream` is gated on
251/// `cfg(all(unix, feature = "net"))`. On Windows the daemon binds
252/// only the TCP listener (loopback) and this entry point is
253/// compiled out; consumers must use `handle_connection` instead.
254#[cfg(unix)]
255#[instrument(
256    name = "ws.connection",
257    skip_all,
258    fields(peer = %peer),
259)]
260pub async fn handle_connection_unix(
261    stream: tokio::net::UnixStream,
262    peer: String,
263    state: Arc<ServerState>,
264) -> Result<(), Box<dyn std::error::Error>> {
265    let ws_stream = tokio_tungstenite::accept_async(stream).await?;
266    let (write, read) = ws_stream.split();
267    run_dispatch(read, Box::pin(write), peer, state).await
268}
269
270fn handler_default_deadline_secs() -> u64 {
271    std::env::var("CAR_HANDLER_TIMEOUT")
272        .ok()
273        .and_then(|s| s.trim().parse::<u64>().ok())
274        .unwrap_or(1800)
275}
276
277/// Per-request server-side deadline. A wedged handler that never completes
278/// otherwise hangs the calling client forever (try/catch can't catch a hang) —
279/// the incident class this guards against. Policy errs toward NOT killing real
280/// work:
281///   - Genuinely UNBOUNDED methods (streams, subscriptions, run-loops, voice
282///     turns) are EXEMPT (None) — they legitimately stay open indefinitely.
283///   - Bounded, CANCELLATION-SAFE reads (list/get/query/count, auth/handshake)
284///     get a tight deadline so a wedge recovers quickly — dropping a read
285///     mid-flight can't corrupt state.
286///   - EVERYTHING ELSE — including MUTATING memory ops (add_fact/persist/
287///     consolidate, which mutate-then-persist) and all agentic/inference/
288///     workflow/automation methods, and any method not explicitly classified —
289///     gets a GENEROUS default, which is well above any legitimate duration, so
290///     real work is never killed yet a true infinite wedge is still reaped. We
291///     do NOT pull mutating handlers into the tight bucket, because a mid-flight
292///     drop there could leave in-memory and on-disk state divergent. Override
293///     the default via CAR_HANDLER_TIMEOUT (seconds).
294///   - `proposal.submit` additionally RAISES that default when the submitted
295///     proposal's own action budgets exceed it — see
296///     [`proposal_submit_deadline_secs`]. The generous default used to be
297///     justified by "the client itself caps proposal.submit at ~900s"; #265
298///     deleted that premise by deriving the client read deadline from the
299///     proposal's budgets, so a flat server deadline would re-reap server-side
300///     exactly what the client patiently waits for.
301///
302/// NOTE: timeout cancels the handler future at an .await point — it reaps async
303/// hangs (e.g. a lock().await that never acquires). It cannot preempt a handler
304/// blocked inside a synchronous syscall with no .await; those need spawn_blocking.
305// ---- Deadline policy table (auditable in one place; covered by tests below) ----
306// Unbounded by design — streams/subscriptions/run-loops/voice turns. Any NEW
307// indefinitely-blocking method MUST add its token here or it will inherit the
308// generous default and be killed.
309// `heal.run` is exempt because its own internals are bounded and the handler
310// default is NOT: the per-item ceiling is 2700s against a 1800s default, so
311// every sweep that actually did work was dropped mid-session. A dropped future
312// cannot clean up, and `heal_runner` has already taken the session's
313// `JoinHandle` — dropping it DETACHES the coder session rather than aborting
314// it, leaving it editing the operator's repository with `coder.cancel` disarmed
315// while the next cadence tick starts a second session on the same branch. The
316// operation bounds itself (item ceiling, panel ceiling, single-sweep lock);
317// what it cannot survive is being cut in half.
318const DEADLINE_EXEMPT_SUBSTRINGS: &[&str] = &[
319    "stream",
320    "subscribe",
321    "run_loop",
322    "dispatch_turn",
323    "heal.run",
324];
325// Credential-backed auth operations are daemon-owned, not merely deadline
326// exempt. Their core futures carry no connection/session/channel and survive a
327// socket close; only their response waiters live in `conn_tasks`.
328const DAEMON_OWNED_AUTH_METHODS: &[&str] = &[
329    "auth.authority_hint",
330    "auth.start",
331    "auth.complete",
332    "auth.completion_status",
333    "auth.snapshot",
334    "auth.status",
335    "auth.switch_org",
336    "auth.accounts",
337    "auth.switch_account",
338    "auth.remove_account",
339    "auth.logout",
340];
341// FAST = bounded, CANCELLATION-SAFE reads only. Mutating/heavy methods are
342// deliberately omitted so they take the safe generous default — a mid-flight
343// drop on a mutator could diverge in-memory vs on-disk state. The suffixes are a
344// heuristic: any NEW .list/.get/.query/.count that mutates or does heavy I/O
345// must be excluded (it then falls through to the safe default).
346const DEADLINE_FAST_SUFFIXES: &[&str] = &[".list", ".get", ".query", ".count"];
347const DEADLINE_FAST_PREFIXES: &[&str] = &["auth."];
348const DEADLINE_FAST_EXACT: &[&str] = &[
349    "agents.health",
350    // Bounded read of the installed admission rules. The *setter* is
351    // deliberately absent: it mutates, so it takes the safe generous default.
352    "memory.admission_table",
353    "memory.evaluate",
354    "memory.intervene",
355    "server.handshake",
356    "session.auth",
357    "session.init",
358];
359const DEADLINE_FAST_SECS: u64 = 180;
360/// Transport grace added on top of a `proposal.submit`'s derived action budget,
361/// so the daemon's handler deadline fires strictly *after* the executor's own
362/// per-action waits.
363///
364/// What this guarantees unconditionally is the hop that matters: the daemon
365/// never abandons a submit the executor is still legitimately spending budget
366/// on. `car-engine`'s `execute_with_retry` reaps a single attempt at
367/// `timeout_ms`; this deadline reaps the whole submit no earlier than
368/// `Σ(budget × attempts) + 15s`.
369///
370/// Keep this value STRICTLY SMALLER than the client's 30s
371/// `car_daemon_client::proxy::PROPOSAL_TRANSPORT_GRACE_SECS`, so that wherever
372/// the two derivations both dominate their floors, the daemon answers first and
373/// the caller sees an explicit `-32004` rather than an opaque transport timeout.
374/// That full innermost-first ordering — executor → daemon handler → client —
375/// only holds once the derived term clears both floors (Σ above roughly 1785s,
376/// this default minus this grace). Below it the client's 900s floor is smaller
377/// than this 1800s default and the client gives up first. That inversion
378/// predates the derivation and harms nothing: the client's floor still strictly
379/// exceeds the executor's worst case there, so no in-budget work is killed —
380/// only a genuinely wedged handler surfaces as a transport timeout instead of
381/// `-32004`.
382const PROPOSAL_DEADLINE_GRACE_SECS: u64 = 15;
383/// `max_retries` assumed for a `failure_behavior: "retry"` action that does not
384/// pin one. Mirrors `car_ir`'s private `default_max_retries()` (3), pinned to it
385/// by `derived_retry_default_matches_car_ir` below. The executor runs
386/// `max_retries + 1` attempts for a retry action, so the default worst case is
387/// 4 attempts, not 3 — the same undercount #265 fixed on the client side.
388const DEFAULT_PROPOSAL_MAX_RETRIES: u64 = 3;
389/// JSON-RPC application error for a handler abandoned by the daemon's own
390/// deadline. Clients must distinguish this ambiguous outcome from a handler
391/// that replied with a terminal failure.
392const HANDLER_DEADLINE_ERROR_CODE: i32 = -32004;
393
394/// Protocol v2 made the Parslee browser-login lifecycle attempt-bound. An old
395/// host may understand `host.subscribe` and `auth.start` but not the required
396/// `auth.complete`/reconciliation contract, so those surfaces must stay hidden
397/// until this connection has proved exact wire compatibility.
398fn requires_protocol_handshake(method: &str) -> bool {
399    method == "host.subscribe"
400        || method.starts_with("auth.")
401        || method == "diagnostics.secret_store_activity"
402        || method == "session.clear_halt"
403        || method == "infer.cancel"
404        || method == "infer.deadline"
405        || method == "runs.cancel"
406        || matches!(
407            method,
408            "agent_permissions.set_tool"
409                | "agent_permissions.reset_tool"
410                | "agent_permissions.evaluate_tool"
411        )
412        // The feedback.* surface is protocol-v2-native (PAR-1): a legacy host
413        // has no consent UI for it, so it stays hidden pre-negotiation.
414        || method == "feedback.compose_preview"
415        || method == "feedback.submit"
416        || method == "feedback.status"
417        || method == "feedback.list"
418}
419
420fn session_has_capability(session: &crate::session::ClientSession, capability: &str) -> bool {
421    session
422        .negotiated_capabilities
423        .read()
424        .map(|capabilities| capabilities.contains(capability))
425        .unwrap_or(false)
426}
427
428/// The `feedback.*` surface is capability-gated like every sibling surface
429/// (`infer.cancel`, `runs.resume`, …): a session that completed the v3
430/// handshake WITHOUT negotiating `feedback.v1` cannot spool reports or read
431/// submission summaries. The refusal carries the standard mismatch prefix so
432/// bundled clients classify it the same way as the other gated methods.
433fn require_feedback_capability(session: &crate::session::ClientSession) -> Result<(), String> {
434    if session_has_capability(session, car_proto::FEEDBACK_CAPABILITY) {
435        Ok(())
436    } else {
437        Err(format!(
438            "{} negotiate `{}` as a required or optional capability before calling `feedback.*`",
439            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
440            car_proto::FEEDBACK_CAPABILITY
441        ))
442    }
443}
444
445fn require_agent_tool_override_capability(
446    session: &crate::session::ClientSession,
447) -> Result<(), String> {
448    if session_has_capability(session, car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY) {
449        Ok(())
450    } else {
451        Err(format!(
452            "this operation requires negotiated capability {}",
453            car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY
454        ))
455    }
456}
457
458// ---- WebSocket keepalive (server-side ping/liveness) ----
459// tokio-tungstenite sends no automatic pings, so a silently-dead connection
460// (TCP half-open, no close frame) on a QUIET stream is never noticed and its
461// subscriber/registry state leaks until daemon restart. We ping periodically;
462// any inbound frame (incl. the pong) refreshes the liveness clock; no frame for
463// KEEPALIVE_DEAD_AFTER (or a failed ping write) → the connection is dead.
464const KEEPALIVE_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
465const KEEPALIVE_DEAD_AFTER: std::time::Duration = std::time::Duration::from_secs(90); // ~3 missed pings
466const KEEPALIVE_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // matches drain task
467const DEFAULT_INFER_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
468/// Maximum time the daemon keeps a composed auth-state response waiter:
469/// 30s bounded in-process coordinator queue + 30s cross-process auth lock +
470/// one 15s authoritative read + one 15s reservation/claim/terminal publication
471/// + 5s scheduling margin. The state operation itself is daemon-owned;
472/// exceeding this response bound never drops its coordinator guard or blocking
473/// worker.
474const AUTH_STATE_RESPONSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(95);
475const AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX: &str =
476    "auth coordination deadline before redemption; safe to retry auth.complete";
477const AUTH_START_RETRY_PREFIX: &str =
478    "auth.start coordination deadline before reservation; safe to retry auth.start serially";
479const AUTH_STATUS_RETRY_PREFIX: &str =
480    "auth.completion_status coordination deadline; retry the proof read serially";
481/// Native hosts reconcile an accepted or ambiguous completion for 480 seconds.
482/// This server-side mirror exists only to prove that the daemon's bounded
483/// retry/worker/proof/hydration composition continues to fit inside that
484/// external horizon.
485#[cfg(test)]
486const AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON: std::time::Duration =
487    std::time::Duration::from_secs(480);
488
489fn is_daemon_owned_auth_method(method: &str) -> bool {
490    DAEMON_OWNED_AUTH_METHODS.contains(&method)
491}
492
493fn server_deadline_for(method: &str, params: &Value) -> Option<std::time::Duration> {
494    if DEADLINE_EXEMPT_SUBSTRINGS
495        .iter()
496        .any(|s| method.contains(s))
497    {
498        return None;
499    }
500    let fast = DEADLINE_FAST_SUFFIXES.iter().any(|s| method.ends_with(s))
501        || DEADLINE_FAST_PREFIXES.iter().any(|p| method.starts_with(p))
502        || DEADLINE_FAST_EXACT.contains(&method);
503    let secs = if fast {
504        DEADLINE_FAST_SECS
505    } else if method == "proposal.submit" {
506        proposal_submit_deadline_secs(params)
507    } else {
508        handler_default_deadline_secs()
509    };
510    Some(std::time::Duration::from_secs(secs))
511}
512
513fn inference_total_timeout() -> std::time::Duration {
514    std::env::var("CAR_INFER_TIMEOUT_SECS")
515        .ok()
516        .and_then(|value| value.parse::<u64>().ok())
517        .map(std::time::Duration::from_secs)
518        .unwrap_or(DEFAULT_INFER_TOTAL_TIMEOUT)
519}
520
521/// Server-side deadline for `proposal.submit`, derived from the budgets the
522/// submitted proposal actually declares instead of a flat per-method constant.
523///
524/// `proposal.submit` blocks on the whole DAG — every action's tool callback,
525/// every retry attempt. #265 taught the client to size its read deadline from
526/// those same budgets, which deleted the premise the flat generous default here
527/// rested on ("the client itself caps proposal.submit at ~900s"). Left flat,
528/// this deadline abandoned a legitimately in-budget proposal with `-32004`
529/// while `car-engine`'s `execute_with_retry` was still on an attempt and the
530/// client was still patiently reading — the #259/#265 reap class relocated one
531/// layer inward. One retried action with a 10-minute budget is already 2400s,
532/// past the 1800s default.
533///
534/// The derivation ONLY RAISES: the result is
535/// `max(handler_default_deadline_secs(), Σ + grace)`, so a short proposal, or
536/// params carrying no parseable proposal, keeps exactly the generous default it
537/// had before. `CAR_HANDLER_TIMEOUT` keeps its meaning as that default/floor —
538/// it is deliberately NOT a ceiling here, because capping the daemon below the
539/// executor's own budget is the bug this fixes.
540fn proposal_submit_deadline_secs(params: &Value) -> u64 {
541    let default_secs = handler_default_deadline_secs();
542    match derive_proposal_budget_secs(params) {
543        0 => default_secs,
544        derived => default_secs.max(derived.saturating_add(PROPOSAL_DEADLINE_GRACE_SECS)),
545    }
546}
547
548/// Cumulative daemon-side budget of a `proposal.submit`, in seconds, derived
549/// from `proposal.actions[*].timeout_ms`. Returns 0 when no proposal/actions
550/// are parseable, which the caller reads as "keep the generous default".
551///
552/// Mirrors `car_daemon_client::proxy::derive_proposal_budget_secs` so the two
553/// layers agree on what "in budget" means. Independent same-level actions run
554/// concurrently, so a level's true cost is the `max` of its actions rather than
555/// the sum — but the DAG is not reconstructed here. The conservative
556/// `Σ(budget × attempts)` upper bound is taken instead: over-waiting only delays
557/// reaping a genuinely wedged handler, while under-waiting kills real work.
558///
559/// One term neither this nor the client's copy counts: the executor's
560/// inter-attempt backoff sleeps. At the built-in defaults
561/// (`RETRY_BASE_DELAY_MS` 100, factor 2) a 4-attempt action sleeps 0.7s total,
562/// far inside the grace. Only a harness-config `retry_backoff_ms` override
563/// (`None` by default, so unreachable on any default path) could push the sum
564/// past it — if that knob ever gets a live call site, fold the backoff series
565/// into this derivation rather than widening the grace.
566fn derive_proposal_budget_secs(params: &Value) -> u64 {
567    let Some(actions) = params
568        .get("proposal")
569        .and_then(|p| p.get("actions"))
570        .and_then(|a| a.as_array())
571    else {
572        return 0;
573    };
574    if actions.is_empty() {
575        return 0;
576    }
577
578    // Saturating throughout so a pathological `timeout_ms` can't wrap the
579    // accumulator into a *shorter* deadline than the flat default.
580    let mut total_ms: u64 = 0;
581    for action in actions {
582        let budget_ms = action
583            .get("timeout_ms")
584            .and_then(|v| v.as_u64())
585            .unwrap_or(crate::session::DEFAULT_TOOL_TIMEOUT_MS);
586        total_ms =
587            total_ms.saturating_add(budget_ms.saturating_mul(proposal_action_attempts(action)));
588    }
589
590    // ms → secs rounding UP, so a sub-second remainder never truncates the
591    // derived deadline below what the executor is allowed to spend.
592    total_ms.div_ceil(1000)
593}
594
595/// Worst-case attempt count the executor will run for one action.
596///
597/// Mirrors `car_engine::executor::execute_with_retry` (and the client's
598/// `car_daemon_client::proxy::action_attempts`): a `failure_behavior: "retry"`
599/// action runs `max_retries + 1` attempts — the initial try plus up to
600/// `max_retries` retries, so 4 at the default, NOT 3 — and every other behavior
601/// (`abort`, `skip`, or unset → `abort`) runs exactly once. Always `>= 1`.
602fn proposal_action_attempts(action: &Value) -> u64 {
603    let is_retry = action
604        .get("failure_behavior")
605        .and_then(|v| v.as_str())
606        .map(|s| s.eq_ignore_ascii_case("retry"))
607        .unwrap_or(false);
608
609    if is_retry {
610        action
611            .get("max_retries")
612            .and_then(|v| v.as_u64())
613            .unwrap_or(DEFAULT_PROPOSAL_MAX_RETRIES)
614            .saturating_add(1)
615    } else {
616        1
617    }
618}
619
620#[derive(Debug)]
621enum HandlerFailure {
622    Dispatch(String),
623    MethodNotFound(String),
624    InvalidParams(String),
625    Deadline(String),
626    /// Something in front of the model declined the request's content. Carries
627    /// its own JSON-RPC code so a consumer can tell a policy ruling from a
628    /// crash — see [`car_proto::CONTENT_REFUSED_ERROR_CODE`].
629    ContentRefused(String),
630    ProtocolCapability(String),
631    CatalogPrecondition(String),
632    RunOwnershipConflict(String),
633    RunTraceCorruption(String),
634}
635
636impl HandlerFailure {
637    /// Classify a handler's dispatch error string.
638    ///
639    /// This is the READER half of a writer/reader pair — the writer is
640    /// [`inference_dispatch_error`], which tags a typed
641    /// `InferenceError::ContentRefused` with
642    /// [`car_proto::CONTENT_REFUSED_MESSAGE_PREFIX`] on the way out of the
643    /// handler. The two must agree on that prefix, the same way
644    /// `car_inference::stream::error_tags` pairs its writer and reader. The
645    /// decode happens in exactly ONE place (here) so there is a single line to
646    /// keep in sync, and the typed classification is still done where the typed
647    /// error actually exists rather than by sniffing text.
648    fn from_dispatch(message: String) -> Self {
649        if message.starts_with("unknown method:") {
650            Self::MethodNotFound(message)
651        } else if message.starts_with("invalid params:") {
652            Self::InvalidParams(message)
653        } else if message.starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX) {
654            Self::ContentRefused(message)
655        } else if message.starts_with(car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX) {
656            Self::ProtocolCapability(message)
657        } else if message.starts_with(car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX) {
658            Self::CatalogPrecondition(message)
659        } else if message.starts_with(car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX) {
660            Self::RunOwnershipConflict(message)
661        } else if message.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
662            Self::RunTraceCorruption(message)
663        } else {
664            Self::Dispatch(message)
665        }
666    }
667
668    fn json_rpc_code(&self) -> i32 {
669        match self {
670            Self::Dispatch(_) => -32603,
671            Self::MethodNotFound(_) => -32601,
672            Self::InvalidParams(_) => -32602,
673            Self::Deadline(_) => HANDLER_DEADLINE_ERROR_CODE,
674            Self::ContentRefused(_) => car_proto::CONTENT_REFUSED_ERROR_CODE,
675            Self::ProtocolCapability(_) => car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
676            Self::CatalogPrecondition(_) => car_proto::CATALOG_PRECONDITION_MISMATCH_ERROR_CODE,
677            Self::RunOwnershipConflict(_) => car_proto::RUN_OWNERSHIP_CONFLICT_ERROR_CODE,
678            Self::RunTraceCorruption(_) => car_proto::RUN_TRACE_CORRUPTION_ERROR_CODE,
679        }
680    }
681
682    fn message(&self) -> &str {
683        match self {
684            Self::Dispatch(message)
685            | Self::MethodNotFound(message)
686            | Self::InvalidParams(message)
687            | Self::Deadline(message)
688            | Self::ContentRefused(message)
689            | Self::ProtocolCapability(message)
690            | Self::CatalogPrecondition(message)
691            | Self::RunOwnershipConflict(message)
692            | Self::RunTraceCorruption(message) => message,
693        }
694    }
695}
696
697fn json_rpc_response_for_handler_result(
698    id: Value,
699    result: Result<Value, HandlerFailure>,
700) -> JsonRpcResponse {
701    match result {
702        Ok(value) => JsonRpcResponse::success(id, value),
703        Err(error) => JsonRpcResponse::error(id, error.json_rpc_code(), error.message()),
704    }
705}
706
707async fn dispatch_with_server_deadline<F>(
708    method: &str,
709    params: &Value,
710    dispatch: F,
711) -> Result<Value, HandlerFailure>
712where
713    F: std::future::Future<Output = Result<Value, String>>,
714{
715    match server_deadline_for(method, params) {
716        Some(deadline) => match tokio::time::timeout(deadline, dispatch).await {
717            Ok(result) => result.map_err(HandlerFailure::from_dispatch),
718            Err(_) => Err(HandlerFailure::Deadline(format!(
719                "handler '{}' exceeded the server-side deadline of {}s and was abandoned",
720                method,
721                deadline.as_secs()
722            ))),
723        },
724        None => dispatch.await.map_err(HandlerFailure::from_dispatch),
725    }
726}
727
728async fn await_daemon_owned_auth_result(
729    method: &str,
730    result_rx: tokio::sync::oneshot::Receiver<Result<Value, HandlerFailure>>,
731) -> Result<Value, HandlerFailure> {
732    let receive = async {
733        match result_rx.await {
734            Ok(result) => result,
735            Err(_) => Err(HandlerFailure::Dispatch(format!(
736                "daemon-owned auth operation `{method}` stopped before publishing a result"
737            ))),
738        }
739    };
740    if !matches!(
741        method,
742        "auth.start" | "auth.complete" | "auth.completion_status"
743    ) {
744        return receive.await;
745    }
746
747    tokio::time::timeout(AUTH_STATE_RESPONSE_DEADLINE, receive)
748        .await
749        .unwrap_or_else(|_| match method {
750            "auth.complete" => Err(HandlerFailure::Deadline(format!(
751                "auth.complete durable claim outcome is ambiguous after {}s; do not replay the \
752                 authorization code and reconcile the exact attempt through auth.completion_status",
753                AUTH_STATE_RESPONSE_DEADLINE.as_secs()
754            ))),
755            "auth.start" => Err(HandlerFailure::Deadline(format!(
756                "auth.start reservation outcome is ambiguous after {}s; the daemon-owned \
757                 reservation continues, so do not overlap another auth.start",
758                AUTH_STATE_RESPONSE_DEADLINE.as_secs()
759            ))),
760            _ => Err(HandlerFailure::Deadline(format!(
761                "handler 'auth.completion_status' exceeded its {}s response deadline; \
762                 daemon-owned reconciliation continues and a later proof read is safe",
763                AUTH_STATE_RESPONSE_DEADLINE.as_secs()
764            ))),
765        })
766}
767
768fn classified_auth_failure(method: &str, error: car_auth::AuthOperationError) -> HandlerFailure {
769    match error {
770        car_auth::AuthOperationError::CoordinationDeadline(detail) => {
771            let message = match method {
772                "auth.complete" => format!("{AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX}: {detail}"),
773                "auth.start" => format!("{AUTH_START_RETRY_PREFIX}: {detail}"),
774                "auth.completion_status" => format!("{AUTH_STATUS_RETRY_PREFIX}: {detail}"),
775                _ => format!(
776                    "{method} coordination deadline before state work; retry the request safely: \
777                     {detail}"
778                ),
779            };
780            HandlerFailure::Deadline(message)
781        }
782        car_auth::AuthOperationError::Terminal(message) => HandlerFailure::from_dispatch(message),
783    }
784}
785
786#[cfg(test)]
787mod assistant_identity_tests {
788    use super::assistant_identity_wire;
789    use car_identity::AssistantIdentity;
790
791    #[test]
792    fn the_wire_shape_carries_everything_a_host_needs_to_address_the_assistant() {
793        // Hosts match wake phrases LOCALLY (their matcher has to work before
794        // the daemon answers), so the daemon has to hand them the derived alias
795        // list rather than only the name. Sending just the name is how Swift,
796        // Kotlin, and Rust drifted into three different lists in the first
797        // place.
798        let identity = AssistantIdentity::new("Jarvis")
799            .expect("valid name")
800            .with_spellings(vec!["jervis".into()])
801            .expect("valid spellings");
802        let wire = assistant_identity_wire(&identity);
803
804        assert_eq!(wire["name"], "Jarvis");
805        assert_eq!(wire["spellings"][0], "jervis");
806        assert_eq!(wire["brand"], car_identity::BRAND_NAME);
807
808        let aliases: Vec<String> =
809            serde_json::from_value(wire["aliases"].clone()).expect("aliases");
810        for expected in ["jarvis", "hey jarvis", "jervis", "hey jervis"] {
811            assert!(
812                aliases.contains(&expected.to_string()),
813                "{expected} missing from {aliases:?}"
814            );
815        }
816    }
817
818    #[test]
819    fn the_brand_travels_alongside_the_name_never_instead_of_it() {
820        // Store copy is contractually "Parslee Core" (docs/mobile-app-store-release.md).
821        // A host that renders brand copy needs both values, not a merged one.
822        let wire = assistant_identity_wire(&AssistantIdentity::new("Friday").unwrap());
823        assert_eq!(wire["name"], "Friday");
824        assert_eq!(wire["brand"], "Parslee Core");
825    }
826}
827
828#[cfg(test)]
829mod deadline_policy_tests {
830    use super::{
831        auth_completion_network_with_deadline, auth_completion_value,
832        await_daemon_owned_auth_result, classified_auth_failure, dispatch_with_server_deadline,
833        handler_default_deadline_secs, inference_dispatch_error, is_daemon_owned_auth_method,
834        json_rpc_response_for_handler_result, select_auth_start_api_base, server_deadline_for,
835        stream_dispatch_error, HandlerFailure, AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX,
836        AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON, AUTH_START_RETRY_PREFIX,
837        AUTH_STATE_RESPONSE_DEADLINE, AUTH_STATUS_RETRY_PREFIX, DAEMON_OWNED_AUTH_METHODS,
838        DEADLINE_FAST_SECS, DEFAULT_PROPOSAL_MAX_RETRIES, HANDLER_DEADLINE_ERROR_CODE,
839        PROPOSAL_DEADLINE_GRACE_SECS,
840    };
841    use serde_json::{json, Value};
842    use std::time::Duration;
843
844    #[test]
845    fn auth_start_base_precedence_is_passive_and_does_not_read_secrets() {
846        const SENTINEL: &str = "CAR_AUTH_START_BASE_CONTRACT_CHILD";
847        if std::env::var_os(SENTINEL).is_none() {
848            let status = std::process::Command::new(std::env::current_exe().unwrap())
849                .arg("--exact")
850                .arg(
851                    "handler::deadline_policy_tests::auth_start_base_precedence_is_passive_and_does_not_read_secrets",
852                )
853                .arg("--nocapture")
854                .env(SENTINEL, "1")
855                .status()
856                .expect("spawn isolated auth.start base contract");
857            assert!(status.success(), "isolated auth.start contract failed");
858            return;
859        }
860
861        let before = car_secrets::secret_store_activity();
862
863        assert_eq!(
864            select_auth_start_api_base(
865                &json!({ "api_base": " https://explicit.example.test/ " }),
866                Some("https://environment.example.test/"),
867            ),
868            "https://explicit.example.test"
869        );
870        assert_eq!(
871            select_auth_start_api_base(
872                &json!({ "api_base": "   " }),
873                Some(" https://environment.example.test/ "),
874            ),
875            "https://environment.example.test"
876        );
877        assert_eq!(
878            select_auth_start_api_base(&json!({}), Some("  ")),
879            car_auth::DEFAULT_API_BASE
880        );
881        assert_eq!(car_secrets::secret_store_activity(), before);
882    }
883
884    /// One `tool_call` action in the wire shape `proposal.submit` receives.
885    fn action(timeout_ms: u64, failure_behavior: &str) -> Value {
886        json!({
887            "id": "a1",
888            "type": "tool_call",
889            "tool": "shell",
890            "parameters": {},
891            "failure_behavior": failure_behavior,
892            "timeout_ms": timeout_ms,
893        })
894    }
895
896    fn submit_params(actions: Vec<Value>) -> Value {
897        json!({ "proposal": { "id": "p1", "goal": "g", "actions": actions } })
898    }
899
900    /// A single retried action with a 10-minute budget runs four attempts
901    /// (`max_retries` 3 + the initial try) — 2400s of daemon-side work that the
902    /// executor and, since #265, the client both permit. Keying the handler
903    /// deadline on the method name alone abandoned it at the flat 1800s
904    /// default with `-32004` while the work was still legitimately in budget.
905    #[test]
906    fn proposal_submit_deadline_covers_a_retried_actions_full_budget() {
907        let params = submit_params(vec![action(600_000, "retry")]);
908        let deadline =
909            server_deadline_for("proposal.submit", &params).expect("proposal.submit is bounded");
910
911        // 4 attempts × 600s.
912        assert!(
913            deadline > Duration::from_secs(2400),
914            "must outlast the executor's own 2400s worst case, got {deadline:?}"
915        );
916        assert!(
917            deadline > Duration::from_secs(1800),
918            "must RAISE the flat 1800s generous default, got {deadline:?}"
919        );
920        assert_eq!(
921            deadline,
922            Duration::from_secs(2400 + PROPOSAL_DEADLINE_GRACE_SECS)
923                .max(Duration::from_secs(handler_default_deadline_secs())),
924            "derived deadline is the action budget plus the server transport grace"
925        );
926    }
927
928    /// Chained actions add up: the derivation is the conservative
929    /// `Σ(budget × attempts)` upper bound, so a multi-step proposal whose sum
930    /// passes the default raises the deadline past that sum too.
931    #[test]
932    fn proposal_submit_deadline_covers_chained_action_budgets() {
933        let params = submit_params(vec![
934            action(900_000, "abort"),
935            action(900_000, "abort"),
936            action(300_000, "skip"),
937        ]);
938        let sum = Duration::from_secs(900 + 900 + 300);
939        let deadline =
940            server_deadline_for("proposal.submit", &params).expect("proposal.submit is bounded");
941
942        assert!(
943            deadline > sum,
944            "chained budgets summing to {sum:?} must not be reaped, got {deadline:?}"
945        );
946        assert!(
947            deadline > Duration::from_secs(1800),
948            "a 2100s chain must RAISE the flat 1800s default, got {deadline:?}"
949        );
950        assert_eq!(
951            deadline,
952            (sum + Duration::from_secs(PROPOSAL_DEADLINE_GRACE_SECS))
953                .max(Duration::from_secs(handler_default_deadline_secs()))
954        );
955    }
956
957    /// The derivation only ever RAISES: a short proposal keeps exactly the
958    /// generous default it had before, so no method gets a tighter deadline
959    /// than it had.
960    #[test]
961    fn a_short_proposal_keeps_exactly_the_generous_default() {
962        let params = submit_params(vec![action(5_000, "abort")]);
963        assert_eq!(
964            server_deadline_for("proposal.submit", &params),
965            Some(Duration::from_secs(handler_default_deadline_secs())),
966            "5s of budget must not lower the deadline below the generous default"
967        );
968    }
969
970    /// An action that declares no `timeout_ms` is budgeted at the daemon's own
971    /// `tool_callback_timeout(None)` bound, so the derivation tracks what the
972    /// executor will actually wait.
973    #[test]
974    fn actions_without_an_explicit_budget_use_the_daemon_tool_default() {
975        let params = json!({
976            "proposal": {
977                "actions": [
978                    { "id": "a1", "type": "tool_call", "failure_behavior": "retry" },
979                    { "id": "a2", "type": "tool_call", "failure_behavior": "retry" },
980                ]
981            }
982        });
983        // 2 actions × 4 attempts × 300s = 2400s.
984        let expected = 2 * 4 * (crate::session::DEFAULT_TOOL_TIMEOUT_MS / 1000);
985        assert_eq!(
986            server_deadline_for("proposal.submit", &params),
987            Some(
988                Duration::from_secs(expected + PROPOSAL_DEADLINE_GRACE_SECS)
989                    .max(Duration::from_secs(handler_default_deadline_secs()))
990            )
991        );
992    }
993
994    /// The reap ordering is executor → daemon handler → client, innermost
995    /// first. This server grace must therefore stay strictly below the client's
996    /// `car_daemon_client::proxy::PROPOSAL_TRANSPORT_GRACE_SECS` (30s) — named
997    /// here rather than imported, because car-server-core must not take a
998    /// dependency on the daemon client to assert an ordering invariant.
999    #[test]
1000    fn server_grace_is_strictly_inside_the_client_transport_grace() {
1001        assert!(
1002            PROPOSAL_DEADLINE_GRACE_SECS < 30,
1003            "server grace must fire before the client's 30s PROPOSAL_TRANSPORT_GRACE_SECS"
1004        );
1005    }
1006
1007    /// Pins the locally-mirrored retry default to `car_ir`'s own serde default,
1008    /// which is private — deserializing a minimal action is how we read it.
1009    #[test]
1010    fn derived_retry_default_matches_car_ir() {
1011        let action: car_ir::Action = serde_json::from_value(json!({
1012            "id": "a1",
1013            "type": "tool_call",
1014            "tool": "shell",
1015        }))
1016        .expect("minimal action should deserialize");
1017        assert_eq!(u64::from(action.max_retries), DEFAULT_PROPOSAL_MAX_RETRIES);
1018    }
1019
1020    #[test]
1021    fn classifies_methods_into_expected_buckets() {
1022        let default = Duration::from_secs(handler_default_deadline_secs());
1023        let fast = Duration::from_secs(DEADLINE_FAST_SECS);
1024
1025        // Unbounded → exempt (no deadline).
1026        for m in [
1027            "infer_stream",
1028            "runs.subscribe",
1029            "scheduler.run_loop",
1030            "voice.dispatch_turn",
1031            "voice.tts_stream.start",
1032        ] {
1033            assert_eq!(
1034                server_deadline_for(m, &Value::Null),
1035                None,
1036                "{m} should be exempt"
1037            );
1038        }
1039        for method in DAEMON_OWNED_AUTH_METHODS {
1040            assert!(
1041                is_daemon_owned_auth_method(method),
1042                "{method} must bypass connection-owned dispatch"
1043            );
1044        }
1045        // Bounded, cancellation-safe reads → fast.
1046        for m in [
1047            "memory.query",
1048            "memory.evaluate",
1049            "memory.intervene",
1050            "agents.list",
1051            "meeting.get",
1052            "runs.list",
1053            "agents.health",
1054            "server.handshake",
1055        ] {
1056            assert_eq!(
1057                server_deadline_for(m, &Value::Null),
1058                Some(fast),
1059                "{m} should be fast"
1060            );
1061        }
1062        // Mutating / heavy / agentic → generous default (NOT the tight bucket).
1063        // `proposal.submit` is in this list with `Value::Null` params: a
1064        // proposal whose budget is not parseable still takes the generous
1065        // default, exactly as before the budget derivation landed.
1066        for m in [
1067            "memory.persist",
1068            "memory.add_fact",
1069            "memory.update_status",
1070            "memory.maintain",
1071            "memory.save_knowledge",
1072            "memory.save_procedural",
1073            "memory.delete",
1074            "memory.consolidate",
1075            "proposal.submit",
1076            "workflow.run",
1077            "multi.swarm",
1078        ] {
1079            assert_eq!(
1080                server_deadline_for(m, &Value::Null),
1081                Some(default),
1082                "{m} should take the generous default"
1083            );
1084        }
1085    }
1086
1087    #[tokio::test(start_paused = true)]
1088    async fn bounded_handler_deadline_has_a_distinct_json_rpc_code() {
1089        let task = tokio::spawn(async {
1090            dispatch_with_server_deadline(
1091                "memory.query",
1092                &Value::Null,
1093                std::future::pending::<Result<Value, String>>(),
1094            )
1095            .await
1096        });
1097
1098        tokio::time::advance(Duration::from_secs(DEADLINE_FAST_SECS)).await;
1099        let failure = task
1100            .await
1101            .expect("deadline task should join")
1102            .expect_err("pending handler should exceed its deadline");
1103
1104        assert!(matches!(failure, HandlerFailure::Deadline(_)));
1105        assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1106    }
1107
1108    #[test]
1109    fn auth_state_response_bound_exceeds_every_serial_storage_component() {
1110        let component_total = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1111            + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1112            + car_auth::AUTH_STATE_OPERATION_BUDGET
1113            + car_auth::AUTH_STATE_OPERATION_BUDGET;
1114        assert!(
1115            AUTH_STATE_RESPONSE_DEADLINE > component_total,
1116            "auth state response bound must include scheduling margin"
1117        );
1118    }
1119
1120    #[test]
1121    fn one_safe_preclaim_retry_and_proof_fit_the_host_reconciliation_horizon() {
1122        // One explicit safe retry may consume the first coordinator queue
1123        // timeout before a second request reaches the claim. The worker lease
1124        // clock starts after the authoritative read and includes claim
1125        // publication plus every redemption/commit phase.
1126        let first_safe_retry_budget =
1127            car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT + car_auth::AUTH_PROCESS_LOCK_TIMEOUT;
1128        let pre_lease_claim_budget = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1129            + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1130            + car_auth::AUTH_STATE_OPERATION_BUDGET;
1131        let hydration_reserve = Duration::from_secs(30);
1132        let total_budget = first_safe_retry_budget
1133            + pre_lease_claim_budget
1134            + car_auth::LOGIN_ATTEMPT_WORKER_TTL
1135            + AUTH_STATE_RESPONSE_DEADLINE
1136            + hydration_reserve;
1137
1138        assert!(
1139            total_budget < AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON,
1140            "one safe preclaim retry must still expire, prove, and hydrate before the host stops"
1141        );
1142    }
1143
1144    #[tokio::test(start_paused = true)]
1145    async fn proof_response_timeout_reports_that_daemon_reconciliation_continues() {
1146        let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1147        let task = tokio::spawn(await_daemon_owned_auth_result(
1148            "auth.completion_status",
1149            result_rx,
1150        ));
1151
1152        tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1153        let failure = task
1154            .await
1155            .expect("proof response task should join")
1156            .expect_err("an unpublished proof must hit its explicit response bound");
1157
1158        assert!(matches!(failure, HandlerFailure::Deadline(_)));
1159        assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1160        assert!(failure.message().contains("reconciliation continues"));
1161    }
1162
1163    #[tokio::test(start_paused = true)]
1164    async fn auth_start_and_complete_have_explicit_state_response_bounds() {
1165        for method in ["auth.start", "auth.complete"] {
1166            let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1167            let task = tokio::spawn(await_daemon_owned_auth_result(method, result_rx));
1168
1169            tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1170            let failure = task
1171                .await
1172                .expect("auth response task should join")
1173                .expect_err("unpublished auth state response must hit its explicit bound");
1174
1175            assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1176            assert!(failure.message().contains("95s"), "{method}: {failure:?}");
1177        }
1178    }
1179
1180    #[test]
1181    fn coordination_deadlines_are_retryable_but_terminal_state_errors_are_not() {
1182        for (method, prefix) in [
1183            ("auth.start", AUTH_START_RETRY_PREFIX),
1184            ("auth.complete", AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX),
1185            ("auth.completion_status", AUTH_STATUS_RETRY_PREFIX),
1186        ] {
1187            let failure = classified_auth_failure(
1188                method,
1189                car_auth::AuthOperationError::CoordinationDeadline("contended".into()),
1190            );
1191            assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1192            assert!(failure.message().starts_with(prefix), "{failure:?}");
1193        }
1194
1195        let failure = classified_auth_failure(
1196            "auth.completion_status",
1197            car_auth::AuthOperationError::Terminal("corrupt state".into()),
1198        );
1199        assert_eq!(failure.json_rpc_code(), -32603);
1200        assert_eq!(failure.message(), "corrupt state");
1201    }
1202
1203    #[tokio::test(start_paused = true)]
1204    async fn auth_completion_network_phase_has_an_injectable_total_deadline() {
1205        let task = tokio::spawn(auth_completion_network_with_deadline(
1206            Duration::from_secs(1),
1207            std::future::pending::<Result<(), String>>(),
1208        ));
1209
1210        tokio::time::advance(Duration::from_secs(1)).await;
1211        let error = task
1212            .await
1213            .expect("network deadline task should join")
1214            .expect_err("stalled auth network work must time out");
1215
1216        assert_eq!(
1217            error,
1218            "Parslee login network phase timed out after 1s; no credentials were saved"
1219        );
1220    }
1221
1222    #[test]
1223    fn protocol_routing_failures_use_standard_json_rpc_codes() {
1224        let unknown =
1225            HandlerFailure::from_dispatch("unknown method: future.namespace.call".to_string());
1226        assert!(matches!(unknown, HandlerFailure::MethodNotFound(_)));
1227        assert_eq!(unknown.json_rpc_code(), -32601);
1228
1229        let invalid = HandlerFailure::from_dispatch("invalid params: key is required".to_string());
1230        assert!(matches!(invalid, HandlerFailure::InvalidParams(_)));
1231        assert_eq!(invalid.json_rpc_code(), -32602);
1232
1233        let wire =
1234            serde_json::to_value(json_rpc_response_for_handler_result(json!(7), Err(invalid)))
1235                .expect("serialize response");
1236        assert_eq!(wire["error"]["code"], -32602);
1237        assert_eq!(wire["error"]["message"], "invalid params: key is required");
1238    }
1239
1240    #[tokio::test]
1241    async fn ordinary_handler_failure_remains_internal_error() {
1242        let failure = dispatch_with_server_deadline(
1243            "auth.complete",
1244            &Value::Null,
1245            std::future::ready(Err("token exchange failed".to_string())),
1246        )
1247        .await
1248        .expect_err("handler failure should propagate");
1249
1250        assert!(matches!(failure, HandlerFailure::Dispatch(_)));
1251        assert_eq!(failure.json_rpc_code(), -32603);
1252    }
1253
1254    #[test]
1255    fn a_gateway_content_refusal_survives_the_trip_to_its_own_error_code() {
1256        let refusal = car_inference::InferenceError::ContentRefused {
1257            provider: "parslee".to_string(),
1258            kind: Some("invalid_request_error".to_string()),
1259            code: Some("content_policy_violation".to_string()),
1260            message: "The response was filtered due to the prompt triggering the content \
1261                      management policy"
1262                .to_string(),
1263        };
1264
1265        // Writer (handler) → reader (dispatcher). This round trip is what stops
1266        // the two halves of the prefix contract from drifting apart.
1267        let failure = HandlerFailure::from_dispatch(inference_dispatch_error(&refusal));
1268
1269        assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1270        assert_eq!(
1271            failure.json_rpc_code(),
1272            car_proto::CONTENT_REFUSED_ERROR_CODE
1273        );
1274        // The gateway's own classification must still be readable by an
1275        // operator — tagging adds a prefix, it does not replace the detail.
1276        assert!(
1277            failure.message().contains("code=content_policy_violation"),
1278            "{failure:?}"
1279        );
1280        assert!(
1281            failure
1282                .message()
1283                .starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
1284            "{failure:?}"
1285        );
1286    }
1287
1288    #[test]
1289    fn ordinary_inference_failures_are_not_classified_as_content_refusals() {
1290        // Over-classification is the dangerous direction: a crash mislabelled as
1291        // a policy refusal tells a benchmark to score it and a retry loop to
1292        // give up on work that would have succeeded. The first string is the
1293        // literal #796 symptom the fix was reported against.
1294        let generic: Vec<car_inference::InferenceError> = vec![
1295            car_inference::InferenceError::InferenceFailed("managed inference failed".to_string()),
1296            car_inference::InferenceError::InferenceFailed(
1297                "content refused by the operator's local policy".to_string(),
1298            ),
1299            car_inference::InferenceError::ModelNotFound("gpt-5.6-sol".to_string()),
1300            car_inference::InferenceError::TokenizationError("bad utf-8 boundary".to_string()),
1301        ];
1302
1303        // The exact string #796 was reported against must still be an internal
1304        // error, not a refusal.
1305        assert_eq!(
1306            generic[0].to_string(),
1307            "inference failed: managed inference failed"
1308        );
1309
1310        for error in &generic {
1311            let failure = HandlerFailure::from_dispatch(inference_dispatch_error(error));
1312            assert!(
1313                matches!(failure, HandlerFailure::Dispatch(_)),
1314                "must stay a generic dispatch failure: {failure:?}"
1315            );
1316            assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1317        }
1318    }
1319
1320    #[test]
1321    fn a_refusal_that_lands_mid_stream_reaches_the_same_error_code() {
1322        // What `infer_stream` actually has at this point: not a typed error, but
1323        // the flattened text `car_inference::stream` wrote, tags and all.
1324        let flattened = "The response was filtered due to the prompt triggering the content \
1325                         management policy (type=invalid_request_error, \
1326                         code=content_policy_violation)";
1327
1328        let failure = HandlerFailure::from_dispatch(stream_dispatch_error(flattened.to_string()));
1329
1330        assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1331        assert_eq!(
1332            failure.json_rpc_code(),
1333            car_proto::CONTENT_REFUSED_ERROR_CODE
1334        );
1335        // Tagging prefixes; it must not eat the gateway's own text.
1336        assert!(failure.message().contains(flattened), "{failure:?}");
1337    }
1338
1339    #[test]
1340    fn an_untagged_stream_failure_is_not_promoted_to_a_refusal() {
1341        // The safe direction: no classification tags means no verdict. A crash
1342        // mislabelled as a policy ruling tells a harness to score it and a retry
1343        // loop to give up on work that would have succeeded.
1344        for plain in [
1345            "managed inference failed",
1346            "upstream connection reset",
1347            // Prose that merely TALKS about content policy is not a tag.
1348            "the model discussed content policy at length",
1349            // A tag block that carries no refusal classification.
1350            "boom (type=server_error, code=internal)",
1351        ] {
1352            let failure = HandlerFailure::from_dispatch(stream_dispatch_error(plain.to_string()));
1353            assert!(
1354                matches!(failure, HandlerFailure::Dispatch(_)),
1355                "must stay generic: {failure:?}"
1356            );
1357            assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1358        }
1359    }
1360
1361    #[test]
1362    fn a_content_refusal_is_distinguishable_from_a_handler_deadline() {
1363        let refusal = HandlerFailure::from_dispatch(inference_dispatch_error(
1364            &car_inference::InferenceError::ContentRefused {
1365                provider: "parslee".to_string(),
1366                kind: None,
1367                code: None,
1368                message: "declined".to_string(),
1369            },
1370        ));
1371        let deadline = HandlerFailure::Deadline("handler deadline".to_string());
1372
1373        assert_eq!(
1374            refusal.json_rpc_code(),
1375            car_proto::CONTENT_REFUSED_ERROR_CODE
1376        );
1377        assert_eq!(deadline.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1378        assert_ne!(refusal.json_rpc_code(), deadline.json_rpc_code());
1379        assert_ne!(refusal.json_rpc_code(), -32603);
1380    }
1381
1382    #[test]
1383    fn deadline_failure_serializes_as_the_actual_json_rpc_envelope() {
1384        let response = json_rpc_response_for_handler_result(
1385            json!(42),
1386            Err(HandlerFailure::Deadline("handler deadline".to_string())),
1387        );
1388        let wire = serde_json::to_value(response).expect("serialize response");
1389
1390        assert_eq!(
1391            wire,
1392            json!({
1393                "jsonrpc": "2.0",
1394                "error": {
1395                    "code": HANDLER_DEADLINE_ERROR_CODE,
1396                    "message": "handler deadline",
1397                },
1398                "id": 42,
1399            })
1400        );
1401    }
1402
1403    #[test]
1404    fn attempt_completion_wire_value_carries_the_generation_and_session() {
1405        let record = car_auth::AuthCompletionRecord {
1406            attempt_id: "attempt-7".to_string(),
1407            generation: 19,
1408            account_id: Some("account-b".to_string()),
1409            session: Some(r#"{"Account":{"Id":"account-b"}}"#.to_string()),
1410        };
1411
1412        assert_eq!(
1413            auth_completion_value(&record),
1414            json!({
1415                "state": "complete",
1416                "attempt_id": "attempt-7",
1417                "generation": 19,
1418                "account_id": "account-b",
1419                "session": {"Account": {"Id": "account-b"}},
1420            })
1421        );
1422    }
1423}
1424
1425/// Transport-neutral entry point: drives the JSON-RPC dispatch loop
1426/// against an already-handshake-completed split WebSocket. Generic
1427/// over the read half (any `Stream<Item = Result<Message, WsError>>`)
1428/// and the write half (a [`WsSink`](crate::session::WsSink) — type-erased so this function
1429/// doesn't templatize every downstream consumer of `WsChannel`).
1430///
1431/// `peer` is a free-form string ("127.0.0.1:1234" for TCP,
1432/// "uds:/path/sock" for UDS, "axum:..." for embedders) — used only
1433/// for tracing fields, never for dispatch logic.
1434#[instrument(
1435    name = "ws.dispatch",
1436    skip_all,
1437    fields(client_id = tracing::field::Empty, peer = %peer),
1438)]
1439pub async fn run_dispatch<R>(
1440    mut read: R,
1441    write: crate::session::WsSink,
1442    peer: String,
1443    state: Arc<ServerState>,
1444) -> Result<(), Box<dyn std::error::Error>>
1445where
1446    R: futures::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
1447        + Unpin
1448        + Send,
1449{
1450    let client_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
1451    tracing::Span::current().record("client_id", client_id.as_str());
1452
1453    info!("New connection from {}", peer);
1454
1455    let channel = Arc::new(WsChannel {
1456        write: Mutex::new(write),
1457        pending: Mutex::new(HashMap::new()),
1458        active_actions: Mutex::new(HashMap::new()),
1459        next_id: AtomicU64::new(1),
1460    });
1461
1462    // Refusing the connection is the intended outcome of a malformed
1463    // `~/.car/policies/*.toml` (see `session::apply_project_policies`): a
1464    // client that connects anyway would be running under a rule set the
1465    // operator wrote and the daemon silently did not apply.
1466    let session = match state.create_session(&client_id, channel.clone()).await {
1467        Ok(session) => session,
1468        Err(e) => {
1469            error!("refusing connection from {}: {}", peer, e);
1470            return Err(e.into());
1471        }
1472    };
1473
1474    // car#209: per-request handlers are spawned detached (below) and
1475    // each clones `Arc<ClientSession>` → `Arc<WsChannel>`. A bare
1476    // `tokio::spawn` outlives the connection, so a slow/hung handler
1477    // pins the split sink and the inbound socket lingers in CLOSED
1478    // until the daemon hits EMFILE. Own them in a per-connection
1479    // `JoinSet` so every in-flight handler is aborted the instant the
1480    // WS drops (`abort_all` in the cleanup block; `JoinSet`'s Drop
1481    // also aborts on any early return), releasing the FD immediately.
1482    let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1483
1484    // Every authenticated client receives the same non-secret OpenRouter
1485    // authority stream. This is deliberately connection-wide rather than
1486    // flow-scoped: a mutation on client B must promptly supersede a delayed
1487    // response on client A. Hosts compare `authority_generation` before flow
1488    // correlation, so ordering remains deterministic across WebSockets.
1489    let mut openrouter_updates = crate::openrouter_auth::subscribe_authority_updates();
1490    let openrouter_session = session.clone();
1491    let openrouter_auth_required = state.auth_token.get().is_some();
1492    conn_tasks.spawn(async move {
1493        loop {
1494            if openrouter_updates.changed().await.is_err() {
1495                break;
1496            }
1497            if openrouter_auth_required
1498                && !openrouter_session
1499                    .authenticated
1500                    .load(std::sync::atomic::Ordering::Acquire)
1501            {
1502                continue;
1503            }
1504            let Some(status) = openrouter_updates.borrow_and_update().clone() else {
1505                continue;
1506            };
1507            let notification = serde_json::json!({
1508                "jsonrpc": "2.0",
1509                "method": "openrouter.auth.event",
1510                "params": status,
1511            });
1512            let Ok(text) = serde_json::to_string(&notification) else {
1513                continue;
1514            };
1515            if openrouter_session
1516                .channel
1517                .write
1518                .lock()
1519                .await
1520                .send(Message::Text(text.into()))
1521                .await
1522                .is_err()
1523            {
1524                break;
1525            }
1526        }
1527    });
1528
1529    // Parslee credential reads are process-owned and may be initiated by a
1530    // different host connection or by request-time routing. Fan their
1531    // secret-free generation/status stream only to a connection that has
1532    // completed both transport authentication and host-management
1533    // authorization. A managed non-host must learn nothing from this channel.
1534    let credential_session = session.clone();
1535    let credential_transport_auth_required = state.auth_token.get().is_some();
1536    let credential_host_role_required = state.host_token.get().is_some();
1537    let (credential_activation_tx, mut credential_activation_rx) =
1538        tokio::sync::watch::channel(false);
1539    let (credential_ready_tx, credential_ready_rx) = tokio::sync::watch::channel(false);
1540    let (credential_lag_tx, mut credential_lag_rx) = tokio::sync::oneshot::channel();
1541    conn_tasks.spawn(async move {
1542        pause_credential_fanout_for_test().await;
1543        let mut credential_lag_tx = Some(credential_lag_tx);
1544
1545        // Do not register an ordered event subscriber until this connection is
1546        // authorized to receive the stream. This makes pre-auth/non-host
1547        // traffic incapable of filling a queue whose later close could be
1548        // mistaken for an eligible-host overflow. Authentication and protocol
1549        // negotiation only move forward during a connection, and the task is
1550        // aborted by connection cleanup if eligibility is never reached.
1551        while !credential_event_is_eligible(
1552            &credential_session,
1553            credential_transport_auth_required,
1554            credential_host_role_required,
1555        ) {
1556            if credential_activation_rx.changed().await.is_err() {
1557                return;
1558            }
1559        }
1560
1561        // Atomically capture the retained process snapshot and register the
1562        // lossless stream. The credential layer linearizes every publication
1563        // as either snapshot state from before registration or queued state
1564        // from after it. Generation/state deduplication below still rejects a
1565        // duplicate or terminal-to-pending regression defensively.
1566        let car_auth::CredentialReadEventHandoff {
1567            snapshot,
1568            events: mut credential_events,
1569        } = car_auth::subscribe_credential_read_event_handoff();
1570        pause_credential_handoff_for_test().await;
1571        let mut reconciled = snapshot.into_iter();
1572        let mut last_status = None;
1573        let _ = credential_ready_tx.send(true);
1574        loop {
1575            let status = match reconciled.next() {
1576                Some(status) => status,
1577                None => {
1578                    let closed = credential_events.closed();
1579                    tokio::select! {
1580                        biased;
1581                        reason = closed => {
1582                            if let Some(sender) = credential_lag_tx.take() {
1583                                let _ = sender.send(reason);
1584                            }
1585                            break;
1586                        }
1587                        event = credential_events.recv() => match event {
1588                            Ok(status) => status,
1589                            Err(reason) => {
1590                                if let Some(sender) = credential_lag_tx.take() {
1591                                    let _ = sender.send(reason);
1592                                }
1593                                break;
1594                            }
1595                        }
1596                    }
1597                }
1598            };
1599            if !credential_status_advances(last_status, status) {
1600                continue;
1601            }
1602            let notification = serde_json::json!({
1603                "jsonrpc": "2.0",
1604                "method": "auth.credential.event",
1605                "params": status,
1606            });
1607            let Ok(text) = serde_json::to_string(&notification) else {
1608                continue;
1609            };
1610            let closed = credential_events.closed();
1611            tokio::select! {
1612                biased;
1613                reason = closed => {
1614                    if let Some(sender) = credential_lag_tx.take() {
1615                        let _ = sender.send(reason);
1616                    }
1617                    break;
1618                }
1619                result = async {
1620                    credential_session
1621                        .channel
1622                        .write
1623                        .lock()
1624                        .await
1625                        .send(Message::Text(text.into()))
1626                        .await
1627                } => {
1628                    if result.is_err() {
1629                        break;
1630                    }
1631                    last_status = Some(status);
1632                }
1633            }
1634        }
1635    });
1636
1637    // Server-side WebSocket keepalive. See KEEPALIVE_* consts: tokio-tungstenite
1638    // emits no automatic pings, so a SILENTLY dead connection (TCP half-open, no
1639    // close frame) on a QUIET stream is never noticed — its drain task parks on
1640    // recv() and its registry entries leak until the daemon restarts. We ping on
1641    // an interval; a live client (even a quiet one) answers with a pong, which —
1642    // like any inbound frame — refreshes `last_inbound`. No frame for
1643    // KEEPALIVE_DEAD_AFTER, or a failed/timed-out ping write, means the peer is
1644    // gone → break into the SINGLE cleanup path below (deregisters subscribers,
1645    // aborts in-flight handlers). This is the "ping timeout" case the cleanup
1646    // comment anticipates.
1647    let mut keepalive = tokio::time::interval(KEEPALIVE_PING_INTERVAL);
1648    keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1649    // `interval`'s first tick is immediate; consume it so the first ping fires a
1650    // full interval after connect (never racing a client's opening request).
1651    keepalive.tick().await;
1652    let mut last_inbound = tokio::time::Instant::now();
1653    loop {
1654        // Reap finished handlers so a long-lived connection doesn't
1655        // retain their `JoinHandle`s unbounded (memory, not FDs).
1656        while conn_tasks.try_join_next().is_some() {}
1657
1658        // INVARIANT: every arm yields a `Message` or diverges (break/continue).
1659        // `read.next()` and `keepalive.tick()` are both cancellation-safe, so the
1660        // loser of each select is simply re-polled next iteration with no loss.
1661        let msg = tokio::select! {
1662            closed = &mut credential_lag_rx => {
1663                match closed {
1664                    Ok(reason) => info!(
1665                        client = %client_id,
1666                        ?reason,
1667                        "credential event subscriber closed; terminating connection"
1668                    ),
1669                    Err(_) => info!(
1670                        client = %client_id,
1671                        "credential event monitor ended; terminating connection"
1672                    ),
1673                }
1674                break;
1675            }
1676            maybe = read.next() => match maybe {
1677                // Any inbound frame — text, binary, ping, or the pong our
1678                // keepalive provokes — proves the peer is alive.
1679                Some(Ok(m)) => {
1680                    last_inbound = tokio::time::Instant::now();
1681                    m
1682                }
1683                // car#209: on an abrupt transport error (peer reset / network
1684                // drop — the trader crash-loop case) `read.next()` yields
1685                // `Some(Err(_))`. The old `let msg = msg?;` propagated that
1686                // out of the function, *skipping the cleanup block below*, so
1687                // `state.sessions` (+ the host/a2ui/chat registries) kept the
1688                // session + channel — and thus the socket FD — forever. Break
1689                // instead: every disconnect, clean or not, runs the single
1690                // cleanup path. `None` = the stream ended cleanly.
1691                Some(Err(e)) => {
1692                    info!("read error from {}: {}; closing", client_id, e);
1693                    break;
1694                }
1695                None => break,
1696            },
1697            _ = keepalive.tick() => {
1698                if last_inbound.elapsed() >= KEEPALIVE_DEAD_AFTER {
1699                    info!(
1700                        "keepalive: no frames from {} for {}s; closing",
1701                        client_id,
1702                        last_inbound.elapsed().as_secs()
1703                    );
1704                    break;
1705                }
1706                // Provoke a pong from a quiet-but-alive client. The timeout wraps
1707                // the lock acquisition AND the send: `channel.write` is shared
1708                // with the dispatch handlers' `send_response` (an unbounded
1709                // write), so a handler wedged on a full TCP buffer can hold the
1710                // lock until its own 180s deadline — bounding only the send would
1711                // let the read loop park on `.lock().await` and defeat the 90s
1712                // liveness contract. Elapsed here covers a stuck lock or a stuck
1713                // socket; either way the peer is treated as gone.
1714                let sent = tokio::time::timeout(KEEPALIVE_WRITE_TIMEOUT, async {
1715                    let mut guard = channel.write.lock().await;
1716                    guard.send(Message::Ping(Vec::new().into())).await
1717                })
1718                .await;
1719                if !matches!(sent, Ok(Ok(()))) {
1720                    info!("keepalive: ping to {} failed/timed out; closing", client_id);
1721                    break;
1722                }
1723                continue;
1724            }
1725        };
1726        if msg.is_text() {
1727            let text = match msg.to_text() {
1728                Ok(t) => t,
1729                Err(e) => {
1730                    info!("non-text frame from {}: {}; closing", client_id, e);
1731                    break;
1732                }
1733            };
1734            let parsed: JsonRpcMessage = match serde_json::from_str(text) {
1735                Ok(m) => m,
1736                Err(e) => {
1737                    send_response(
1738                        &session.channel,
1739                        JsonRpcResponse::error(Value::Null, -32700, &format!("Parse error: {}", e)),
1740                    )
1741                    .await
1742                    .ok();
1743                    continue;
1744                }
1745            };
1746
1747            // Is this a response to a pending tool callback?
1748            if parsed.method.is_none() && (parsed.result.is_some() || parsed.error.is_some()) {
1749                if let Some(id_str) = parsed.id.as_str() {
1750                    let mut pending = session.channel.pending.lock().await;
1751                    if let Some(tx) = pending.remove(id_str) {
1752                        let tool_resp = if let Some(result) = parsed.result {
1753                            ToolExecuteResponse {
1754                                action_id: id_str.to_string(),
1755                                output: Some(result),
1756                                error: None,
1757                                terminal: false,
1758                            }
1759                        } else {
1760                            let err_msg = parsed
1761                                .error
1762                                .as_ref()
1763                                .and_then(|e| e.get("message"))
1764                                .and_then(|m| m.as_str())
1765                                .unwrap_or("unknown error")
1766                                .to_string();
1767                            // Terminality is an additive typed bit in error.data,
1768                            // never a word inferred from the human message.
1769                            let terminal = parsed
1770                                .error
1771                                .as_ref()
1772                                .and_then(|error| error.get("data"))
1773                                .and_then(|data| data.get("terminal"))
1774                                .and_then(Value::as_bool)
1775                                .unwrap_or(false);
1776                            ToolExecuteResponse {
1777                                action_id: id_str.to_string(),
1778                                output: None,
1779                                error: Some(err_msg),
1780                                terminal,
1781                            }
1782                        };
1783                        let _ = tx.send(tool_resp);
1784                        continue;
1785                    }
1786                }
1787            }
1788
1789            // Per-agent daemon-method scope. This is the ONE admission point:
1790            // it runs before notification interceptors, the handshake-gated
1791            // dispatch, host-management admission, approval admission, and the
1792            // method handler table. A denied request therefore cannot trigger
1793            // any method-specific side effect. Notifications are dropped
1794            // without a response, as JSON-RPC requires.
1795            if let Some(method) = parsed.method.as_deref() {
1796                if !agent_method_scope_allows(&session, method) {
1797                    if !parsed.id.is_null() {
1798                        let response = JsonRpcResponse::error(
1799                            parsed.id.clone(),
1800                            AGENT_METHOD_NOT_ALLOWED_ERROR_CODE,
1801                            &format!("{AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX}`{method}`"),
1802                        );
1803                        let _ = send_response(&session.channel, response).await;
1804                    }
1805                    info!(
1806                        client = %client_id,
1807                        method = %method,
1808                        "agent_method_not_allowed: scoped supervised-agent session denied before dispatch"
1809                    );
1810                    continue;
1811                }
1812            }
1813
1814            // Agent → host chat-event interceptor. `agent.chat.event`
1815            // notifications coming up the WS from a connected agent
1816            // are forwarded to the originating host's channel as
1817            // `agents.chat.event`. Lives ahead of the regular method
1818            // dispatch so the dispatcher doesn't reply with
1819            // "method-not-found" on what is a fire-and-forget
1820            // notification (no id). See
1821            // `docs/proposals/agent-chat-surface.md`.
1822            if try_forward_agent_chat_event(&parsed, &state).await {
1823                continue;
1824            }
1825
1826            // Agent → drawer browser pushes (`browser.producer.presentation`,
1827            // `browser.producer.frame`). Notifications too, for the same
1828            // reason: they carry no id, so they must be consumed here rather
1829            // than answered with a method-not-found nobody is listening for.
1830            if crate::browser_relay::try_handle_producer_push(&parsed, &state, &session).await {
1831                continue;
1832            }
1833
1834            // Otherwise it's a client request
1835            if let Some(method) = &parsed.method {
1836                info!(method = %method, "dispatching JSON-RPC method");
1837
1838                // Auth gate (Parslee-ai/car-releases#32). When the
1839                // server has an auth token installed, every method
1840                // other than `session.auth` is rejected on
1841                // unauthenticated sessions and the connection is
1842                // closed after the error response goes out. When no
1843                // token is installed (default), this branch never
1844                // fires — preserves pre-#32 behaviour.
1845                if state.auth_token.get().is_some()
1846                    && !session
1847                        .authenticated
1848                        .load(std::sync::atomic::Ordering::Acquire)
1849                    && method != "session.auth"
1850                {
1851                    let resp =
1852                        JsonRpcResponse::error(parsed.id.clone(), -32001, &auth_required_message());
1853                    let _ = send_response(&session.channel, resp).await;
1854                    info!(client = %client_id, method = %method,
1855                        "rejecting non-auth method on unauthenticated session; closing");
1856                    break;
1857                }
1858
1859                // Supervised-agent auth binds a token-specific method scope.
1860                // Run that form inline so a second already-buffered frame
1861                // cannot race the scope write on an auth-disabled daemon and
1862                // observe the legacy unrestricted `None`. Generic daemon-token
1863                // and host-token auth keep the existing spawned path below.
1864                if method == "session.auth"
1865                    && parsed
1866                        .params
1867                        .get("agent_id")
1868                        .and_then(Value::as_str)
1869                        .is_some()
1870                {
1871                    let result = dispatch_with_server_deadline(
1872                        method,
1873                        &parsed.params,
1874                        handle_session_auth(&parsed, &session, &state),
1875                    )
1876                    .await;
1877                    if result.is_ok()
1878                        && credential_event_is_eligible(
1879                            &session,
1880                            credential_transport_auth_required,
1881                            credential_host_role_required,
1882                        )
1883                    {
1884                        let _ = credential_activation_tx.send(true);
1885                        let mut ready = credential_ready_rx.clone();
1886                        while !*ready.borrow_and_update() {
1887                            if ready.changed().await.is_err() {
1888                                break;
1889                            }
1890                        }
1891                    }
1892                    let response = json_rpc_response_for_handler_result(parsed.id, result);
1893                    let _ = send_response(&session.channel, response).await;
1894                    continue;
1895                }
1896
1897                // Protocol negotiation is connection-scoped and follows
1898                // transport auth: an auth-enabled daemon still requires
1899                // `session.auth` as frame #1, then `server.handshake`, then
1900                // host/auth application calls. Handle negotiation inline so
1901                // the version state is committed before its success response
1902                // is observable; the client may safely send `host.subscribe`
1903                // as soon as it receives that response.
1904                if method == "server.handshake" {
1905                    let (response, handshake_succeeded) =
1906                        match handle_server_handshake(&parsed, &session) {
1907                            Ok(result) => {
1908                                (JsonRpcResponse::success(parsed.id.clone(), result), true)
1909                            }
1910                            Err(error) => (
1911                                JsonRpcResponse::error(
1912                                    parsed.id.clone(),
1913                                    error.code,
1914                                    &error.message,
1915                                ),
1916                                false,
1917                            ),
1918                        };
1919                    if handshake_succeeded
1920                        && credential_event_is_eligible(
1921                            &session,
1922                            credential_transport_auth_required,
1923                            credential_host_role_required,
1924                        )
1925                    {
1926                        let _ = credential_activation_tx.send(true);
1927                        let mut ready = credential_ready_rx.clone();
1928                        while !*ready.borrow_and_update() {
1929                            if ready.changed().await.is_err() {
1930                                break;
1931                            }
1932                        }
1933                    }
1934                    let _ = send_response(&session.channel, response).await;
1935                    continue;
1936                }
1937
1938                if requires_protocol_handshake(method)
1939                    && session
1940                        .negotiated_protocol_version
1941                        .load(std::sync::atomic::Ordering::Acquire)
1942                        != car_proto::PROTOCOL_VERSION
1943                {
1944                    let message = format!(
1945                        "{} call `server.handshake` with {{\"protocol_version\":{}}} \
1946                         and wait for an exact-version success before `{}`",
1947                        car_proto::PROTOCOL_HANDSHAKE_REQUIRED_MESSAGE_PREFIX,
1948                        car_proto::PROTOCOL_VERSION,
1949                        method,
1950                    );
1951                    let response = JsonRpcResponse::error(
1952                        parsed.id.clone(),
1953                        car_proto::PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE,
1954                        &message,
1955                    );
1956                    let _ = send_response(&session.channel, response).await;
1957                    info!(
1958                        client = %client_id,
1959                        method = %method,
1960                        "rejecting handshake-gated method before negotiation"
1961                    );
1962                    continue;
1963                }
1964
1965                // Apply the exported host-management set at the dispatcher
1966                // boundary before any handler can run. Individual handlers
1967                // retain their local authority checks as defense in depth; the
1968                // capability generator derives the set from those real call
1969                // sites and tests it against this shared client/daemon list.
1970                if crate::HOST_MANAGEMENT_METHODS.contains(&method.as_str()) {
1971                    if let Err(error) = require_approval_authority(&session, &state) {
1972                        let response = json_rpc_response_for_handler_result(
1973                            parsed.id.clone(),
1974                            Err(HandlerFailure::Dispatch(error)),
1975                        );
1976                        let _ = send_response(&session.channel, response).await;
1977                        continue;
1978                    }
1979                }
1980
1981                // Approval gate (audit 2026-05). High-risk methods —
1982                // anything that drives macOS automation or sends
1983                // messages on the user's behalf — must be acked by
1984                // the user via `host.resolve_approval` before they
1985                // dispatch. The gate raises an `approval.requested`
1986                // host event the local UI can render approve/deny on,
1987                // then parks until resolved or the configured
1988                // timeout fires. Returns a JSON-RPC error and
1989                // continues the dispatch loop on deny / timeout —
1990                // no connection close, since the caller may want to
1991                // retry with revised parameters.
1992                if state.approval_gate.requires_approval(method.as_str()) {
1993                    match gate_high_risk_method(method.as_str(), &parsed.params, &state).await {
1994                        Ok(()) => {}
1995                        Err(reason) => {
1996                            let resp = JsonRpcResponse::error(parsed.id.clone(), -32003, &reason);
1997                            let _ = send_response(&session.channel, resp).await;
1998                            info!(
1999                                client = %client_id,
2000                                method = %method,
2001                                reason = %reason,
2002                                "approval gate blocked dispatch"
2003                            );
2004                            continue;
2005                        }
2006                    }
2007                }
2008
2009                // Credential-backed auth operations own durable coordinator /
2010                // keychain state, so the connection must never own their core
2011                // futures. Authorize while this session is present, then move
2012                // only owned request/state data into the ServerState task set.
2013                // The oneshot waiter (when there is one) is connection-owned and
2014                // is aborted on disconnect, so no late response can retain or
2015                // leak into a later WebSocket.
2016                if is_daemon_owned_auth_method(method) {
2017                    let response_id = parsed.id.clone();
2018                    if let Err(error) = require_approval_authority(&session, &state) {
2019                        let response = json_rpc_response_for_handler_result(
2020                            response_id,
2021                            Err(HandlerFailure::Dispatch(error)),
2022                        );
2023                        let _ = send_response(&session.channel, response).await;
2024                        continue;
2025                    }
2026
2027                    let method_owned = method.clone();
2028                    let method_for_waiter = method_owned.clone();
2029                    let parsed_owned = parsed;
2030                    let state_owned = state.clone();
2031                    let result_rx = state
2032                        .spawn_durable_operation(method_owned.clone(), async move {
2033                            dispatch_daemon_owned_auth(
2034                                method_owned.as_str(),
2035                                &parsed_owned,
2036                                &state_owned,
2037                            )
2038                            .await
2039                        })
2040                        .await;
2041                    let response_channel = session.channel.clone();
2042                    conn_tasks.spawn(async move {
2043                        let result =
2044                            await_daemon_owned_auth_result(method_for_waiter.as_str(), result_rx)
2045                                .await;
2046                        let response = json_rpc_response_for_handler_result(response_id, result);
2047                        let _ = send_response(&response_channel, response).await;
2048                    });
2049                    continue;
2050                }
2051
2052                // Spawn the per-method dispatch in a task so the read
2053                // loop keeps reading frames. Without this, methods
2054                // that trigger server-initiated `tools.execute`
2055                // callbacks (`proposal.submit`, `workflow.run`,
2056                // `multi.*` paths that fire a registered tool)
2057                // deadlock the connection: the handler awaits the
2058                // callback response on a oneshot, but the response is
2059                // another frame on this same read half — which the
2060                // synchronous `.await` here would prevent the loop
2061                // from ever picking up. Surfaced by the
2062                // `executeProposal: echo tool via JS callback` smoke
2063                // (#173). Response ordering becomes id-keyed (the
2064                // JSON-RPC demuxing contract) rather than
2065                // arrival-ordered.
2066                let session_task = session.clone();
2067                let state_task = state.clone();
2068                let method_owned = method.clone();
2069                let parsed_task = parsed;
2070                let credential_activation_tx_task = credential_activation_tx.clone();
2071                let credential_ready_rx_task = credential_ready_rx.clone();
2072                // car#209: owned by the per-connection JoinSet so it's
2073                // aborted on disconnect instead of leaking the channel.
2074                conn_tasks.spawn(async move {
2075                    let session = session_task;
2076                    let state = state_task;
2077                    let parsed = parsed_task;
2078                    // The identity this connection authenticated as, resolved
2079                    // ONCE per frame. Read here rather than inside each arm for
2080                    // two reasons: a `session.agent_id.lock().await` in argument
2081                    // position keeps the guard alive for the whole arm body —
2082                    // including the handler's own `await`, which for
2083                    // `agents.wait` is caller-controlled — and one read per
2084                    // frame cannot disagree with itself the way thirteen
2085                    // independent acquisitions can. The six mutating
2086                    // `agents.*` handlers deliberately receive the session and
2087                    // clone this identity again inside their short authority
2088                    // check: that keeps the host/agent dependency explicit at
2089                    // the handler call site and drops the guard before any
2090                    // supervisor await.
2091                    let bound_agent = session.agent_id.lock().await.clone();
2092                    let bound_agent = bound_agent.as_deref();
2093                    // INVARIANT: arms must be pure tail expressions — no `?`,
2094                    // `return`, or `continue` — since they now live inside this
2095                    // `async` block, not the surrounding loop/fn.
2096                    let dispatch = async {
2097                        match method_owned.as_str() {
2098                            "session.auth" => handle_session_auth(&parsed, &session, &state).await,
2099                            "capabilities.list" => Ok(handle_capabilities_list(
2100                                bound_agent,
2101                                session.is_host.load(std::sync::atomic::Ordering::Acquire),
2102                            )),
2103                            "parslee.auth" => handle_parslee_auth(&state).await,
2104                            "parslee.capabilities" => crate::parslee_capabilities::discover().await,
2105                            "parslee.m365.generate_document" => {
2106                                crate::parslee_m365::generate_document(&parsed.params).await
2107                            }
2108                            "openrouter.status" => handle_openrouter_status(&session, &state),
2109                            "openrouter.auth_start" => {
2110                                handle_openrouter_auth_start(&parsed, &session, &state).await
2111                            }
2112                            "openrouter.auth_cancel" => {
2113                                handle_openrouter_auth_cancel(&parsed, &session, &state)
2114                            }
2115                            "openrouter.disconnect" => {
2116                                handle_openrouter_disconnect(&session, &state).await
2117                            }
2118                            "session.init" => handle_session_init(&parsed, &session).await,
2119                            "session.bindSandbox" => {
2120                                handle_session_bind_sandbox(&parsed, &session, &state).await
2121                            }
2122                            "session.bindSubstrate" => {
2123                                handle_session_bind_substrate(&parsed, &session, &state).await
2124                            }
2125                            "session.clear_halt" => handle_session_clear_halt(&session).await,
2126                            "host.subscribe" => handle_host_subscribe(&session, &state).await,
2127                            "supervision.subscribe" => {
2128                                handle_supervision_subscribe(&parsed, &session, &state).await
2129                            }
2130                            "supervision.unsubscribe" => {
2131                                handle_supervision_unsubscribe(&session, &state).await
2132                            }
2133                            "supervision.pending" => handle_supervision_pending(&state).await,
2134                            "supervision.decide" => {
2135                                handle_supervision_decide(&parsed, &state).await
2136                            }
2137                            "host.agents" => handle_host_agents(&session, &state).await,
2138                            "host.events" => handle_host_events(&parsed, &session).await,
2139                            "host.approvals" => handle_host_approvals(&session).await,
2140                            "host.register_agent" => {
2141                                handle_host_register_agent(&parsed, &session).await
2142                            }
2143                            "host.unregister_agent" => {
2144                                handle_host_unregister_agent(&parsed, &session).await
2145                            }
2146                            "host.set_status" => handle_host_set_status(&parsed, &session).await,
2147                            "host.register_device" => {
2148                                handle_host_register_device(&parsed, &session).await
2149                            }
2150                            "host.update_device" => {
2151                                handle_host_update_device(&parsed, &session).await
2152                            }
2153                            "host.devices" => handle_host_devices(&session).await,
2154                            "mobile.runtime" => handle_mobile_runtime(&state).await,
2155                            "host.notify" => handle_host_notify(&parsed, &session).await,
2156                            "host.request_approval" => {
2157                                handle_host_request_approval(&parsed, &session).await
2158                            }
2159                            "host.resolve_approval" => {
2160                                handle_host_resolve_approval(&parsed, &session).await
2161                            }
2162                            "tools.register" => handle_tools_register(&parsed, &session).await,
2163                            "tools.list" => handle_tools_list(&session).await,
2164                            "tools.unregister" => handle_tools_unregister(&parsed, &session).await,
2165                            "tools.poll" => handle_tools_poll(&parsed, &session).await,
2166                            "tools.cancel" => handle_tools_cancel(&parsed, &session).await,
2167                            "tools.stream.subscribe" => {
2168                                handle_tools_stream_subscribe(&session).await
2169                            }
2170                            "proposal.submit" => {
2171                                handle_proposal_submit(&parsed, &session, &state).await
2172                            }
2173                            "policy.register" => handle_policy_register(&parsed, &session).await,
2174                            "policy.unregister" => {
2175                                handle_policy_unregister(&parsed, &session).await
2176                            }
2177                            "policy.list" => handle_policy_list(&parsed, &session).await,
2178                            "session.policy.open" => handle_session_policy_open(&session).await,
2179                            "session.policy.close" => {
2180                                handle_session_policy_close(&parsed, &session).await
2181                            }
2182                            "verify" => handle_verify(&parsed, &session).await,
2183                            "verify.monte_carlo" => {
2184                                handle_verify_monte_carlo(&parsed, &session).await
2185                            }
2186                            "permission.get_tier" => handle_permission_get_tier(&session).await,
2187                            "permission.set_tier" => {
2188                                handle_permission_set_tier(&parsed, &session, &state).await
2189                            }
2190                            "permission.classify" => {
2191                                handle_permission_classify(&parsed, &session).await
2192                            }
2193                            "permission.evaluate" => {
2194                                handle_permission_evaluate(&parsed, &session, &state).await
2195                            }
2196                            "permission.pending" => {
2197                                handle_permission_pending(&parsed, &session, &state).await
2198                            }
2199                            "permission.approve" => {
2200                                handle_permission_decision(&parsed, &session, &state, true).await
2201                            }
2202                            "permission.reject" => {
2203                                handle_permission_decision(&parsed, &session, &state, false).await
2204                            }
2205                            "agent_permissions.get" => {
2206                                crate::agent_permissions::handle_get(&parsed)
2207                            }
2208                            "agent_permissions.set" => {
2209                                require_agent_permissions_authority(&session, &state).await?;
2210                                crate::agent_permissions::handle_set(&parsed)
2211                            }
2212                            "agent_permissions.set_default" => {
2213                                require_agent_permissions_authority(&session, &state).await?;
2214                                crate::agent_permissions::handle_set_default(&parsed)
2215                            }
2216                            "agent_permissions.reset" => {
2217                                require_agent_permissions_authority(&session, &state).await?;
2218                                crate::agent_permissions::handle_reset(&parsed)
2219                            }
2220                            "agent_permissions.evaluate" => {
2221                                crate::agent_permissions::handle_evaluate(&parsed)
2222                            }
2223                            "agent_permissions.set_tool" => {
2224                                require_agent_permissions_authority(&session, &state).await?;
2225                                require_agent_tool_override_capability(&session)?;
2226                                handle_agent_permission_set_tool(&parsed, &state).await
2227                            }
2228                            "agent_permissions.reset_tool" => {
2229                                require_agent_permissions_authority(&session, &state).await?;
2230                                require_agent_tool_override_capability(&session)?;
2231                                crate::agent_permissions::handle_reset_tool(&parsed)
2232                            }
2233                            "agent_permissions.evaluate_tool" => {
2234                                require_agent_permissions_authority(&session, &state).await?;
2235                                require_agent_tool_override_capability(&session)?;
2236                                handle_agent_permission_evaluate_tool(&parsed, &state).await
2237                            }
2238                            // Multi-device sync + execution lease (B6).
2239                            "sync.status" => handle_sync_status(&state).await,
2240                            "sync.append" => handle_sync_append(&parsed, &state).await,
2241                            "sync.record_turn" => handle_sync_record_turn(&parsed, &state).await,
2242                            "sync.record_intent" => {
2243                                handle_sync_record_intent(&parsed, &state, &session).await
2244                            }
2245                            "sync.pump" => handle_sync_pump(&state).await,
2246                            "sync.knowledge" => handle_sync_knowledge(&parsed, &state).await,
2247                            "sync.checkpoint" => handle_sync_checkpoint(&state).await,
2248                            "sync.rebase" => handle_sync_rebase(&state).await,
2249                            "sync.transcript" => handle_sync_transcript(&parsed, &state).await,
2250                            "sync.resume" => handle_sync_resume(&parsed, &state).await,
2251                            "sync.assistant_checkpoint.put" => {
2252                                handle_sync_assistant_checkpoint_put(&parsed, &state).await
2253                            }
2254                            "sync.assistant_checkpoint.get" => {
2255                                handle_sync_assistant_checkpoint_get(&parsed, &state).await
2256                            }
2257                            "sync.assistant_action.put" => {
2258                                handle_sync_assistant_action_put(&parsed, &state).await
2259                            }
2260                            "sync.assistant_action.get" => {
2261                                handle_sync_assistant_action_get(&parsed, &state).await
2262                            }
2263                            "sync.fence_check" => {
2264                                handle_sync_fence_check(&parsed, &state, &session).await
2265                            }
2266                            "lease.acquire" => {
2267                                handle_lease_acquire(&parsed, &state, &session).await
2268                            }
2269                            "lease.renew" => handle_lease_renew(&parsed, &state, &session).await,
2270                            "lease.release" => {
2271                                handle_lease_release(&parsed, &state, &session).await
2272                            }
2273                            "lease.status" => {
2274                                handle_lease_status(&parsed, &state, bound_agent).await
2275                            }
2276                            "messaging.config.get" => {
2277                                handle_messaging_config_get(&parsed, &session, &state).await
2278                            }
2279                            "messaging.config.set" => {
2280                                handle_messaging_config_set(&parsed, &session, &state).await
2281                            }
2282                            "messaging.pairing.start" => {
2283                                handle_messaging_pairing_start(&parsed, &session, &state).await
2284                            }
2285                            "messaging.pairing.status" => {
2286                                handle_messaging_pairing_status(&parsed, &session, &state).await
2287                            }
2288                            "messaging.status" => {
2289                                handle_messaging_status(&parsed, &session, &state).await
2290                            }
2291                            "messaging.test_send" => {
2292                                handle_messaging_test_send(&parsed, &session, &state).await
2293                            }
2294                            "state.get" => handle_state_get(&parsed, &session).await,
2295                            "state.set" => handle_state_set(&parsed, &session).await,
2296                            "state.exists" => handle_state_exists(&parsed, &session).await,
2297                            "state.keys" => handle_state_keys(&parsed, &session).await,
2298                            "state.snapshot" => handle_state_snapshot(&parsed, &session).await,
2299                            "memory.add_fact" => handle_memory_add_fact(&parsed, &session).await,
2300                            "memory.set_admission_table" => {
2301                                handle_memory_set_admission_table(&parsed, &session).await
2302                            }
2303                            "memory.admission_table" => {
2304                                handle_memory_admission_table(&parsed, &session).await
2305                            }
2306                            "memory.update_status" => {
2307                                handle_memory_update_status(&parsed, &session).await
2308                            }
2309                            "memory.maintain" => handle_memory_maintain(&parsed, &session).await,
2310                            "memory.save_knowledge" => {
2311                                handle_memory_save_knowledge(&parsed, &session).await
2312                            }
2313                            "memory.save_procedural" => {
2314                                handle_memory_save_procedural(&parsed, &session).await
2315                            }
2316                            "memory.delete" => handle_memory_delete(&parsed, &session).await,
2317                            "memory.query" => handle_memory_query(&parsed, &session).await,
2318                            "memory.evaluate" => handle_memory_evaluate(&parsed, &session).await,
2319                            "memory.intervene" => handle_memory_intervene(&parsed, &session).await,
2320                            "memory.build_context" => {
2321                                handle_memory_build_context(&parsed, &session).await
2322                            }
2323                            "memory.build_context_fast" => {
2324                                handle_memory_build_context_fast(&parsed, &session).await
2325                            }
2326                            "memory.consolidate" => handle_memory_consolidate(&session).await,
2327                            "memory.utility_get" => handle_memory_utility_get(&session).await,
2328                            "memory.utility_set" => {
2329                                handle_memory_utility_set(&parsed, &session).await
2330                            }
2331                            "cascade.run" => handle_cascade_run(&parsed, &session, &state).await,
2332                            "evolution.plan" => {
2333                                handle_evolution_plan(&parsed, &session, &state).await
2334                            }
2335                            "evolution.run" => {
2336                                handle_evolution_run(&parsed, &session, &state).await
2337                            }
2338                            "selfheal.status" => handle_selfheal_status(&state).await,
2339                            "selfheal.detections" => {
2340                                handle_selfheal_detections(&parsed, &state).await
2341                            }
2342                            "selfheal.dismiss" => handle_selfheal_dismiss(&parsed, &state).await,
2343                            "selfheal.fix" => handle_selfheal_fix(&parsed, &state).await,
2344                            "selfheal.run" => handle_selfheal_run(&state).await,
2345                            "heal.status" => handle_heal_status(&state).await,
2346                            "heal.run" => handle_heal_run(&state).await,
2347                            "memory.fact_count" => handle_memory_fact_count(&session).await,
2348                            "memory.persist" => handle_memory_persist(&parsed, &session).await,
2349                            "memory.load" => handle_memory_load(&parsed, &session).await,
2350                            "skill.ingest" => handle_skill_ingest(&parsed, &session).await,
2351                            "skill.find" => handle_skill_find(&parsed, &session).await,
2352                            "skill.report" => handle_skill_report(&parsed, &session).await,
2353                            "skill.gate_deployment" => {
2354                                handle_skill_gate_deployment(&parsed, &session).await
2355                            }
2356                            "skill.enforce_deployment" => {
2357                                handle_skill_enforce_deployment(&parsed, &session, &state).await
2358                            }
2359                            "skill.ingest_governed" => {
2360                                handle_skill_ingest_governed(&parsed, &session, &state).await
2361                            }
2362                            "skill.adopt_pack" => {
2363                                handle_skill_adopt_pack(&parsed, &session, &state).await
2364                            }
2365                            "skill.repair" => handle_skill_repair(&parsed, &session).await,
2366                            "skills.ingest_distilled" => {
2367                                handle_skills_ingest_distilled(&parsed, &session).await
2368                            }
2369                            "skills.evolve" => handle_skills_evolve(&parsed, &session).await,
2370                            "skills.domains_needing_evolution" => {
2371                                handle_skills_domains_needing_evolution(&parsed, &session).await
2372                            }
2373                            "skills.ingest_provisional" => {
2374                                handle_skills_ingest_provisional(&parsed, &session).await
2375                            }
2376                            "skills.gate" => handle_skills_gate(&parsed, &session).await,
2377                            "skill.meta" => handle_skill_meta(&parsed, &session).await,
2378                            "skill.export" => handle_skill_export(&parsed, &session).await,
2379                            "skill.import" => handle_skill_import(&parsed, &session).await,
2380                            "multi.swarm" => handle_multi_swarm(&parsed, &session).await,
2381                            "multi.pipeline" => handle_multi_pipeline(&parsed, &session).await,
2382                            "multi.supervisor" => handle_multi_supervisor(&parsed, &session).await,
2383                            "multi.map_reduce" => handle_multi_map_reduce(&parsed, &session).await,
2384                            "multi.vote" => handle_multi_vote(&parsed, &session).await,
2385                            "multi.tournament" => handle_multi_tournament(&parsed, &session).await,
2386                            "multi.subtask" => handle_multi_subtask(&parsed, &session).await,
2387                            "scheduler.create" => handle_scheduler_create(&parsed),
2388                            "scheduler.run" => handle_scheduler_run(&parsed, &session).await,
2389                            "scheduler.run_loop" => {
2390                                handle_scheduler_run_loop(&parsed, &session).await
2391                            }
2392                            "scheduler.os_render" => handle_scheduler_os_render(&parsed),
2393                            "scheduler.os_install" => handle_scheduler_os_install(&parsed),
2394                            "scheduler.os_uninstall" => handle_scheduler_os_uninstall(&parsed),
2395                            "scheduler.os_list" => handle_scheduler_os_list(&parsed),
2396                            "scheduler.os_reconcile" => handle_scheduler_os_reconcile(&parsed),
2397                            "tasks.schedule" => handle_tasks_schedule(&parsed, &session, &state),
2398                            "tasks.list" => handle_tasks_list(&parsed),
2399                            "tasks.unschedule" => {
2400                                handle_tasks_unschedule(&parsed, &session, &state)
2401                            }
2402                            "diagnostics.secret_store_activity" => {
2403                                handle_secret_store_activity(&session, &state)
2404                            }
2405                            // In-app feedback (PR A wave 2). compose_preview
2406                            // and submit share ONE compose path (PREV-2);
2407                            // submit is the only spool writer (PRIV-2). Every
2408                            // arm is gated on the negotiated `feedback.v1`
2409                            // capability — protocol version alone is not
2410                            // consent to this surface.
2411                            "feedback.compose_preview" => {
2412                                require_feedback_capability(&session)?;
2413                                crate::feedback::handle_compose_preview(&parsed, &state, &session)
2414                                    .await
2415                            }
2416                            "feedback.submit" => {
2417                                require_feedback_capability(&session)?;
2418                                crate::feedback::handle_submit(&parsed, &state, &session).await
2419                            }
2420                            "feedback.status" => {
2421                                require_feedback_capability(&session)?;
2422                                crate::feedback::handle_status(&parsed, &state).await
2423                            }
2424                            "feedback.list" => {
2425                                require_feedback_capability(&session)?;
2426                                crate::feedback::handle_list(&parsed, &state).await
2427                            }
2428                            "infer" => handle_infer(&parsed, state.clone(), session.clone()).await,
2429                            "infer.cancel" => handle_infer_cancel(&parsed, &session).await,
2430                            "infer.deadline" => handle_infer_deadline(&parsed, &session).await,
2431                            "image.generate" => handle_image_generate(&parsed, &state).await,
2432                            "video.generate" => handle_video_generate(&parsed, &state).await,
2433                            "embed" => handle_embed(&parsed, &state).await,
2434                            "classify" => handle_classify(&parsed, &state).await,
2435                            "tokenize" => handle_tokenize(&parsed, &state).await,
2436                            "detokenize" => handle_detokenize(&parsed, &state).await,
2437                            "rerank" => handle_rerank(&parsed, &state).await,
2438                            "transcribe" => handle_transcribe(&parsed, &state).await,
2439                            "synthesize" => handle_synthesize(&parsed, &state).await,
2440                            "search" => handle_search(&parsed, &state).await,
2441                            "web_fetch" => handle_web_fetch(&parsed, &state).await,
2442                            "infer_stream" => handle_infer_stream(&parsed, &session, &state).await,
2443                            "speech.prepare" => handle_speech_prepare(&state).await,
2444                            "models.route" => handle_models_route(&parsed, &state).await,
2445                            "models.stats" => handle_models_stats(&state).await,
2446                            "outcomes.scoreboard" => handle_outcomes_scoreboard(&state).await,
2447                            "outcomes.resolve_pending" => {
2448                                handle_outcomes_resolve_pending(&parsed, &state).await
2449                            }
2450                            "events.count" => handle_events_count(&session).await,
2451                            "events.stats" => handle_events_stats(&session).await,
2452                            "events.truncate" => handle_events_truncate(&parsed, &session).await,
2453                            "events.clear" => handle_events_clear(&session).await,
2454                            "events.query" => handle_events_query(&parsed, &session).await,
2455                            "events.retention" => handle_events_retention(&parsed, &session).await,
2456                            "events.cost_by_agent" => handle_events_cost_by_agent(&session).await,
2457                            "events.chain.enable" => handle_events_chain_enable(&session).await,
2458                            "events.chain.verify" => handle_events_chain_verify(&session).await,
2459                            "metrics.summary" => handle_metrics_summary(&session).await,
2460                            "metrics.alerts" => handle_metrics_alerts(&parsed, &session).await,
2461                            "nlp.identify_language" => handle_nlp(&parsed, NlpOp::IdentifyLanguage),
2462                            "nlp.tokenize" => handle_nlp(&parsed, NlpOp::Tokenize),
2463                            "nlp.extract_entities" => handle_nlp(&parsed, NlpOp::ExtractEntities),
2464                            // Agent run tracing, U1 — run lifecycle bracket.
2465                            // `runs.start` mints a run_id and tags the
2466                            // session's current run BEFORE responding (KTD3);
2467                            // `runs.complete` records the terminal outcome.
2468                            // The per-turn recorder (U2) and the read/
2469                            // subscribe RPCs (U4/U5) build on these.
2470                            "runs.start" => handle_runs_start(&parsed, &session, &state).await,
2471                            "runs.resume" => handle_runs_resume(&parsed, &session, &state).await,
2472                            "runs.complete" => {
2473                                handle_runs_complete(&parsed, &session, &state).await
2474                            }
2475                            "runs.cancel" => handle_runs_cancel(&parsed, &session, &state).await,
2476                            // Client-narrated turn append (feedback-agent
2477                            // A2UI/runs plan, U1). WS-only (no FFI method, like
2478                            // runs.subscribe) — an out-of-pipeline agent whose
2479                            // work happens inside its own subprocess pushes full
2480                            // RunTurns it built itself. Authorized to the run's
2481                            // OWNING agent only (host-token/unbound writers are
2482                            // rejected as forgery); appends through the shared
2483                            // `record_run_turns` (index re-stamp, persist, fanout).
2484                            "runs.record_turns" => {
2485                                handle_runs_record_turns(&parsed, &session, &state).await
2486                            }
2487                            // Live run-trace subscribe/unsubscribe (U4). WS-only
2488                            // (no FFI method) — CarHost consumes the
2489                            // `runs.trace.event` notification. Authorization
2490                            // gated per-run (R16/KTD10).
2491                            "runs.subscribe" => {
2492                                handle_runs_subscribe(&parsed, &session, &state).await
2493                            }
2494                            "runs.unsubscribe" => {
2495                                handle_runs_unsubscribe(&parsed, &session, &state).await
2496                            }
2497                            // Replay reads (U5). WS-only (no FFI method, like
2498                            // `runs.subscribe`) — CarHost lists an agent's runs
2499                            // and fetches a completed run's full trace from the
2500                            // disk store (works across restart / client_id
2501                            // churn). Authorization gated per-agent/run (R16).
2502                            "runs.list" => handle_runs_list(&parsed, &session, &state).await,
2503                            "runs.get_trace" => {
2504                                handle_runs_get_trace(&parsed, &session, &state).await
2505                            }
2506                            "replan.set_config" => {
2507                                handle_replan_set_config(&parsed, &session).await
2508                            }
2509                            "models.list" => handle_models_list(&state),
2510                            "models.register" => handle_models_register(&parsed, &state).await,
2511                            "models.unregister" => handle_models_unregister(&parsed, &state).await,
2512                            "models.list_unified" => {
2513                                handle_models_list_unified(&parsed, &state).await
2514                            }
2515                            "models.catalog_snapshot" => {
2516                                handle_models_catalog_snapshot(&session, &state)
2517                            }
2518                            "models.resource_policy.get" => {
2519                                handle_models_resource_policy_get(&parsed, &state)
2520                            }
2521                            "models.resource_policy.set" => {
2522                                handle_models_resource_policy_set(&parsed, &session, &state)
2523                            }
2524                            "models.preflight" => handle_models_preflight(&parsed, &state),
2525                            "models.storage_roots" => {
2526                                handle_models_storage_roots(&parsed, &session, &state)
2527                            }
2528                            "models.remove" => {
2529                                handle_models_remove(&parsed, &session, &state).await
2530                            }
2531                            "models.adopt" => handle_models_adopt(&parsed, &session, &state).await,
2532                            "models.route_provenance" => {
2533                                handle_models_route_provenance(&parsed, &state).await
2534                            }
2535                            "models.search" => handle_models_search(&parsed, &state).await,
2536                            "models.recommend" => handle_models_recommend(&parsed, &state),
2537                            "models.setup_plan" => handle_models_setup_plan(&parsed, &state),
2538                            "models.upgrades" => handle_models_upgrades(&state),
2539                            "models.detect_upgrades" => handle_models_detect_upgrades(&state).await,
2540                            "models.check_upgrade_nudge" => {
2541                                handle_models_check_upgrade_nudge(&parsed, &state).await
2542                            }
2543                            "models.dismiss_upgrade" => {
2544                                handle_models_dismiss_upgrade(&parsed, &state)
2545                            }
2546                            "models.check_concierge" => {
2547                                handle_models_check_concierge(&parsed, &state).await
2548                            }
2549                            "models.dismiss_suggestion" => {
2550                                handle_models_dismiss_suggestion(&parsed, &state)
2551                            }
2552                            // Concierge (Phase C1) — ambient status + labeled
2553                            // dismissal. WS-only; CarHost's Model Health pane +
2554                            // friction card consume these.
2555                            "concierge.status" => handle_concierge_status(&parsed, &state).await,
2556                            "concierge.dismiss" => handle_concierge_dismiss(&parsed, &state),
2557                            "concierge.defaults" => handle_concierge_defaults(&state),
2558                            "concierge.set_default" => {
2559                                handle_concierge_set_default(&parsed, &state).await
2560                            }
2561                            "concierge.clear_default" => {
2562                                handle_concierge_clear_default(&parsed, &state).await
2563                            }
2564                            "concierge.apply" => handle_concierge_apply(&parsed, &state).await,
2565                            "concierge.rollback" => {
2566                                handle_concierge_rollback(&parsed, &state).await
2567                            }
2568                            "concierge.actions" => handle_concierge_actions(&parsed, &state),
2569                            "concierge.refresh_catalog" => {
2570                                handle_concierge_refresh_catalog(&state).await
2571                            }
2572                            "concierge.ask" => handle_concierge_ask(&parsed, &state).await,
2573                            "models.update_prefs_get" => handle_models_update_prefs_get(&state),
2574                            "models.update_prefs_set" => {
2575                                handle_models_update_prefs_set(&parsed, &state)
2576                            }
2577                            "models.pull" | "models.install" => {
2578                                handle_models_pull(&parsed, &session, &state).await
2579                            }
2580                            "skills.distill" => handle_skills_distill(&parsed, &state).await,
2581                            "skills.list" => handle_skills_list(&parsed, &session).await,
2582                            "browser.run" => handle_browser_run(&parsed, &session).await,
2583                            "browser.close" => handle_browser_close(&session).await,
2584                            // The browser DRAWER surface (`browser.view.*`).
2585                            // Separate from `browser.run` above, which is the
2586                            // per-connection scripted browser and is untouched:
2587                            // this one watches and drives the ASSISTANT's
2588                            // browser (or the shared standing session) for a
2589                            // human at the Command Deck. Host-management client
2590                            // only — see `crate::browser_view`'s module docs for
2591                            // why that is stricter than `runs.subscribe`.
2592                            "browser.view.subscribe" => {
2593                                crate::browser_view::handle_subscribe(&parsed, &session, &state)
2594                                    .await
2595                            }
2596                            "browser.view.unsubscribe" => {
2597                                crate::browser_view::handle_unsubscribe(&parsed, &session, &state)
2598                                    .await
2599                            }
2600                            "browser.view.take_control" => {
2601                                crate::browser_view::handle_take_control(&parsed, &session, &state)
2602                                    .await
2603                            }
2604                            "browser.view.hand_back" => {
2605                                crate::browser_view::handle_hand_back(&parsed, &session, &state)
2606                                    .await
2607                            }
2608                            "browser.view.navigate" => {
2609                                crate::browser_view::handle_input(
2610                                    crate::browser_view::InputOp::Navigate,
2611                                    &parsed,
2612                                    &session,
2613                                    &state,
2614                                )
2615                                .await
2616                            }
2617                            "browser.view.click" => {
2618                                crate::browser_view::handle_input(
2619                                    crate::browser_view::InputOp::Click,
2620                                    &parsed,
2621                                    &session,
2622                                    &state,
2623                                )
2624                                .await
2625                            }
2626                            "browser.view.type" => {
2627                                crate::browser_view::handle_input(
2628                                    crate::browser_view::InputOp::Type,
2629                                    &parsed,
2630                                    &session,
2631                                    &state,
2632                                )
2633                                .await
2634                            }
2635                            "browser.view.keypress" => {
2636                                crate::browser_view::handle_input(
2637                                    crate::browser_view::InputOp::Keypress,
2638                                    &parsed,
2639                                    &session,
2640                                    &state,
2641                                )
2642                                .await
2643                            }
2644                            "browser.view.scroll" => {
2645                                crate::browser_view::handle_input(
2646                                    crate::browser_view::InputOp::Scroll,
2647                                    &parsed,
2648                                    &session,
2649                                    &state,
2650                                )
2651                                .await
2652                            }
2653                            // Paste carries the TEXT: the clipboard belongs
2654                            // to the host's OS, and CDP's injected key events
2655                            // cannot reach one, so a synthesised Cmd+V would
2656                            // deliver a key event and nothing would arrive.
2657                            "browser.view.paste" => {
2658                                crate::browser_view::handle_input(
2659                                    crate::browser_view::InputOp::Paste,
2660                                    &parsed,
2661                                    &session,
2662                                    &state,
2663                                )
2664                                .await
2665                            }
2666                            // The nav bar's history buttons. No params of
2667                            // their own — which page they act on is the active
2668                            // tab's own history.
2669                            "browser.view.back" => {
2670                                crate::browser_view::handle_input(
2671                                    crate::browser_view::InputOp::Back,
2672                                    &parsed,
2673                                    &session,
2674                                    &state,
2675                                )
2676                                .await
2677                            }
2678                            "browser.view.forward" => {
2679                                crate::browser_view::handle_input(
2680                                    crate::browser_view::InputOp::Forward,
2681                                    &parsed,
2682                                    &session,
2683                                    &state,
2684                                )
2685                                .await
2686                            }
2687                            "browser.view.reload" => {
2688                                crate::browser_view::handle_input(
2689                                    crate::browser_view::InputOp::Reload,
2690                                    &parsed,
2691                                    &session,
2692                                    &state,
2693                                )
2694                                .await
2695                            }
2696                            "browser.view.tab_open" => {
2697                                crate::browser_view::handle_input(
2698                                    crate::browser_view::InputOp::TabOpen,
2699                                    &parsed,
2700                                    &session,
2701                                    &state,
2702                                )
2703                                .await
2704                            }
2705                            "browser.view.tab_close" => {
2706                                crate::browser_view::handle_input(
2707                                    crate::browser_view::InputOp::TabClose,
2708                                    &parsed,
2709                                    &session,
2710                                    &state,
2711                                )
2712                                .await
2713                            }
2714                            "browser.view.tab_switch" => {
2715                                crate::browser_view::handle_input(
2716                                    crate::browser_view::InputOp::TabSwitch,
2717                                    &parsed,
2718                                    &session,
2719                                    &state,
2720                                )
2721                                .await
2722                            }
2723                            // The AGENT side of the drawer (`browser.producer.*`):
2724                            // a supervised agent process publishing ITS OWN
2725                            // browser so `browser.view.*` above can serve it.
2726                            // Agent sessions only — see `crate::browser_relay`.
2727                            "browser.producer.register" => {
2728                                crate::browser_relay::handle_producer_register(
2729                                    &parsed, &session, &state,
2730                                )
2731                                .await
2732                            }
2733                            "secret.put" => handle_secret_put(&parsed).await,
2734                            "secret.get" => handle_secret_get(&parsed, &session, &state).await,
2735                            "secret.delete" => handle_secret_delete(&parsed).await,
2736                            "secret.status" => handle_secret_status(&parsed),
2737                            "secret.available" => Ok(car_ffi_common::secrets::is_available()),
2738                            "secret.list" => car_ffi_common::secrets::list(),
2739                            "permissions.status" => handle_perm_status(&parsed),
2740                            "permissions.request" => handle_perm_request(&parsed),
2741                            "permissions.explain" => handle_perm_explain(&parsed),
2742                            "permissions.domains" => Ok(car_ffi_common::permissions::domains()),
2743                            "accounts.list" => car_ffi_common::accounts::list(),
2744                            "accounts.open" => {
2745                                #[derive(serde::Deserialize, Default)]
2746                                struct OpenParams {
2747                                    #[serde(default)]
2748                                    account_id: Option<String>,
2749                                }
2750                                let p: OpenParams = serde_json::from_value(parsed.params.clone())
2751                                    .unwrap_or_default();
2752                                car_ffi_common::accounts::open_settings(p.account_id.as_deref())
2753                            }
2754                            "calendar.list" => car_ffi_common::integrations::calendar_list(),
2755                            "calendar.events" => handle_calendar_events(&parsed),
2756                            "calendar.create_event" => handle_calendar_create_event(&parsed),
2757                            "calendar.update_event" => handle_calendar_update_event(&parsed),
2758                            "calendar.delete_event" => handle_calendar_delete_event(&parsed),
2759                            "contacts.containers" => {
2760                                car_ffi_common::integrations::contacts_containers()
2761                            }
2762                            "contacts.find" => handle_contacts_find(&parsed),
2763                            "mail.accounts" => car_ffi_common::integrations::mail_accounts(),
2764                            "mail.inbox" => handle_mail_inbox(&parsed),
2765                            "mail.mailboxes" => handle_mail_mailboxes(&parsed),
2766                            "mail.messages" => handle_mail_messages(&parsed),
2767                            "mail.message_body" => handle_mail_message_body(&parsed),
2768                            "mail.send" => handle_mail_send(&parsed),
2769                            "messages.services" => {
2770                                car_ffi_common::integrations::messages_services()
2771                            }
2772                            "messages.chats" => handle_messages_chats(&parsed),
2773                            "messages.read" => handle_messages_read(&parsed),
2774                            "messages.send" => handle_messages_send(&parsed),
2775                            "notes.accounts" => car_ffi_common::integrations::notes_accounts(),
2776                            "notes.find" => handle_notes_find(&parsed),
2777                            "reminders.lists" => car_ffi_common::integrations::reminders_lists(),
2778                            "reminders.items" => handle_reminders_items(&parsed),
2779                            "photos.albums" => car_ffi_common::integrations::photos_albums(),
2780                            "bookmarks.list" => handle_bookmarks_list(&parsed),
2781                            "files.locations" => car_ffi_common::integrations::files_locations(),
2782                            "keychain.status" => car_ffi_common::integrations::keychain_status(),
2783                            "health.status" => car_ffi_common::health::status(),
2784                            "health.sleep" => handle_health_sleep(&parsed),
2785                            "health.workouts" => handle_health_workouts(&parsed),
2786                            "health.activity" => handle_health_activity(&parsed),
2787                            "voice.transcribe_stream.start" => {
2788                                handle_voice_transcribe_stream_start(&parsed, &state, &session)
2789                                    .await
2790                            }
2791                            "voice.transcribe_stream.stop" => {
2792                                handle_voice_transcribe_stream_stop(&parsed, &state).await
2793                            }
2794                            "voice.transcribe_stream.push" => {
2795                                handle_voice_transcribe_stream_push(&parsed, &state).await
2796                            }
2797                            "voice.tts_stream.start" => {
2798                                handle_voice_tts_stream_start(&parsed, &session).await
2799                            }
2800                            "voice.tts_stream.cancel" => {
2801                                handle_voice_tts_stream_cancel(&parsed).await
2802                            }
2803                            "voice.tts_stream.list" => Ok(handle_voice_tts_stream_list()),
2804                            "voice.sessions.list" => Ok(handle_voice_sessions_list(&state)),
2805                            "voice.dispatch_turn" => {
2806                                handle_voice_dispatch_turn(&parsed, &state, &session).await
2807                            }
2808                            "voice.cancel_turn" => handle_voice_cancel_turn().await,
2809                            "voice.prewarm_turn" => handle_voice_prewarm_turn(&state).await,
2810                            "inference.register_runner" => {
2811                                handle_inference_register_runner(&session).await
2812                            }
2813                            "inference.runner.event" => {
2814                                handle_inference_runner_event(&parsed).await
2815                            }
2816                            "inference.runner.complete" => {
2817                                handle_inference_runner_complete(&parsed).await
2818                            }
2819                            "inference.runner.fail" => handle_inference_runner_fail(&parsed).await,
2820                            "voice.providers.list" => {
2821                                // Stateless: enumerates STT/TTS providers compiled into
2822                                // this build. Runtime readiness (API key, permission,
2823                                // model download) is reported via per-provider errors.
2824                                serde_json::from_str::<serde_json::Value>(
2825                                    &car_voice::list_voice_providers_json(),
2826                                )
2827                                .map_err(|e| e.to_string())
2828                            }
2829                            "voice.prepare_parakeet" => car_ffi_common::voice::prepare_parakeet()
2830                                .await
2831                                .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2832                            "voice.prepare_diarizer" => car_ffi_common::voice::prepare_diarizer()
2833                                .await
2834                                .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2835                            "voice.enroll_speaker" => handle_enroll_speaker(&parsed).await,
2836                            "voice.list_enrollments" => car_ffi_common::voice::list_enrollments()
2837                                .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2838                            "voice.remove_enrollment" => handle_remove_enrollment(&parsed),
2839                            "workflow.run" => handle_workflow_run(&parsed, &session).await,
2840                            "workflow.chain" => handle_workflow_chain(&parsed, &session).await,
2841                            "workflow.resume" => handle_workflow_resume(&parsed, &session).await,
2842                            "workflow.list_paused" => handle_workflow_list_paused().await,
2843                            "builder.build" => {
2844                                handle_builder_build(&parsed, &state, &session).await
2845                            }
2846                            "workflow.verify" => handle_workflow_verify(&parsed),
2847                            "workflow.build_automation" => {
2848                                handle_workflow_build_automation(&parsed)
2849                            }
2850                            "meeting.start" => {
2851                                handle_meeting_start(&parsed, &state, &session).await
2852                            }
2853                            "meeting.stop" => handle_meeting_stop(&parsed, &state, &session).await,
2854                            "meeting.list" => handle_meeting_list(&parsed),
2855                            "meeting.get" => handle_meeting_get(&parsed),
2856                            "registry.register" => handle_registry_register(&parsed),
2857                            "registry.heartbeat" => handle_registry_heartbeat(&parsed),
2858                            "registry.unregister" => handle_registry_unregister(&parsed),
2859                            "registry.list" => handle_registry_list(&parsed),
2860                            "registry.reap" => handle_registry_reap(&parsed),
2861                            "admission.status" => handle_admission_status(&state),
2862                            "a2a.start" => handle_a2a_start(&parsed, &state, &session).await,
2863                            "a2a.stop" => handle_a2a_stop(),
2864                            "a2a.status" => handle_a2a_status(),
2865                            "a2a.send" => handle_a2a_send(&parsed, &state).await,
2866                            "a2a.peers.add" => handle_a2a_peers_add(&parsed),
2867                            "a2a.peers.list" => handle_a2a_peers_list(),
2868                            "a2a.peers.remove" => handle_a2a_peers_remove(&parsed),
2869                            "a2ui.apply" => handle_a2ui_apply(&parsed, &state).await,
2870                            "a2ui.ingest" => handle_a2ui_ingest(&parsed, &state).await,
2871                            "a2ui.capabilities" => handle_a2ui_capabilities(&state),
2872                            "a2ui.reap" => handle_a2ui_reap(&state).await,
2873                            "a2ui.surfaces" => handle_a2ui_surfaces(&state).await,
2874                            "a2ui.get" => handle_a2ui_get(&parsed, &state).await,
2875                            "a2ui.action" => handle_a2ui_action(&parsed, &state).await,
2876                            "a2ui.render_report" => {
2877                                handle_a2ui_render_report(&parsed, &state).await
2878                            }
2879                            "a2ui/subscribe" => handle_a2ui_subscribe(&session, &state).await,
2880                            "a2ui/unsubscribe" => handle_a2ui_unsubscribe(&session, &state).await,
2881                            "a2ui/replay" => handle_a2ui_replay(&parsed, &state).await,
2882                            "automation.run_applescript" => handle_run_applescript(&parsed).await,
2883                            "automation.run_powershell" => handle_run_powershell(&parsed).await,
2884                            "automation.shortcuts.list" => handle_list_shortcuts(&parsed).await,
2885                            "automation.shortcuts.run" => handle_run_shortcut(&parsed).await,
2886                            "notifications.local" => handle_local_notification(&parsed).await,
2887                            "vision.ocr" => handle_vision_ocr(&parsed).await,
2888                            "coder.start" => {
2889                                crate::coder::rpc::handle_coder_start(&parsed, &state, &session)
2890                                    .await
2891                            }
2892                            "coder.projects.list" => {
2893                                crate::coder::rpc::handle_coder_projects_list(&state).await
2894                            }
2895                            "coder.projects.create" => {
2896                                crate::coder::rpc::handle_coder_projects_create(&parsed, &state)
2897                                    .await
2898                            }
2899                            "coder.projects.get" => {
2900                                crate::coder::rpc::handle_coder_projects_get(&parsed, &state).await
2901                            }
2902                            "coder.confirm_contract" => {
2903                                crate::coder::rpc::handle_coder_confirm_contract(&parsed, &state)
2904                                    .await
2905                            }
2906                            "coder.list" => crate::coder::rpc::handle_coder_list(&state).await,
2907                            "coder.get" => {
2908                                crate::coder::rpc::handle_coder_get(&parsed, &state).await
2909                            }
2910                            "coder.subscribe" => {
2911                                crate::coder::rpc::handle_coder_subscribe(&parsed, &state, &session)
2912                                    .await
2913                            }
2914                            "coder.unsubscribe" => {
2915                                crate::coder::rpc::handle_coder_unsubscribe(
2916                                    &parsed, &state, &session,
2917                                )
2918                                .await
2919                            }
2920                            "coder.respond" => {
2921                                crate::coder::rpc::handle_coder_respond(&parsed, &state).await
2922                            }
2923                            "coder.watch" => {
2924                                crate::coder::rpc::handle_coder_watch(&parsed, &state, &session)
2925                                    .await
2926                            }
2927                            "coder.unwatch" => {
2928                                crate::coder::rpc::handle_coder_unwatch(&state, &session).await
2929                            }
2930                            "coder.revise_contract" => {
2931                                crate::coder::rpc::handle_coder_revise_contract(&parsed, &state)
2932                                    .await
2933                            }
2934                            "coder.approve_merge" => {
2935                                crate::coder::rpc::handle_coder_approve_merge(&parsed, &state).await
2936                            }
2937                            "coder.cancel" => {
2938                                crate::coder::rpc::handle_coder_cancel(&parsed, &state).await
2939                            }
2940                            "coder.discuss.start" => {
2941                                crate::coder::discuss::handle_discuss_start(
2942                                    &parsed, &state, &session,
2943                                )
2944                                .await
2945                            }
2946                            "coder.discuss.send" => {
2947                                crate::coder::discuss::handle_discuss_send(
2948                                    &parsed, &state, &session,
2949                                )
2950                                .await
2951                            }
2952                            "coder.discuss.subscribe" => {
2953                                crate::coder::discuss::handle_discuss_subscribe(
2954                                    &parsed, &state, &session,
2955                                )
2956                                .await
2957                            }
2958                            "coder.discuss.unsubscribe" => {
2959                                crate::coder::discuss::handle_discuss_unsubscribe(
2960                                    &parsed, &state, &session,
2961                                )
2962                                .await
2963                            }
2964                            "coder.discuss.promote" => {
2965                                crate::coder::discuss::handle_discuss_promote(
2966                                    &parsed, &state, &session,
2967                                )
2968                                .await
2969                            }
2970                            "coder.discuss.close" => {
2971                                crate::coder::discuss::handle_discuss_close(
2972                                    &parsed, &state, &session,
2973                                )
2974                                .await
2975                            }
2976                            "coder.discuss.list" => {
2977                                crate::coder::discuss::handle_discuss_list(&state, &session).await
2978                            }
2979                            "declagents.list" => {
2980                                crate::coder::rpc::handle_declagents_list(&state).await
2981                            }
2982                            "declagents.get" => {
2983                                crate::coder::rpc::handle_declagents_get(&parsed, &state).await
2984                            }
2985                            "declagents.remove" => {
2986                                require_host_lifecycle_authority(&session, &state).await?;
2987                                crate::coder::rpc::handle_declagents_remove(
2988                                    &parsed, &state, &session,
2989                                )
2990                                .await
2991                            }
2992                            "declagents.set_enabled" => {
2993                                require_host_lifecycle_authority(&session, &state).await?;
2994                                crate::coder::rpc::handle_declagents_set_enabled(
2995                                    &parsed, &state, &session,
2996                                )
2997                                .await
2998                            }
2999                            "declagents.invoke" => {
3000                                crate::coder::rpc::handle_declagents_invoke(
3001                                    &parsed, &state, &session,
3002                                )
3003                                .await
3004                            }
3005                            "declagents.route" => {
3006                                crate::coder::rpc::handle_declagents_route(
3007                                    &parsed, &state, &session,
3008                                )
3009                                .await
3010                            }
3011                            "declagents.route_split" => {
3012                                crate::coder::rpc::handle_declagents_route_split(
3013                                    &parsed, &state, &session,
3014                                )
3015                                .await
3016                            }
3017                            "declagents.routing_stats" => {
3018                                crate::coder::rpc::handle_declagents_routing_stats(&state).await
3019                            }
3020                            "discovery.resolve" => {
3021                                crate::coder::rpc::handle_discovery_resolve(&parsed, &state).await
3022                            }
3023                            "discovery.route_compose" => {
3024                                crate::coder::rpc::handle_discovery_route_compose(&parsed, &state)
3025                                    .await
3026                            }
3027                            "discovery.report" => {
3028                                crate::coder::rpc::handle_discovery_report(&parsed, &state).await
3029                            }
3030                            "agents.list" => handle_agents_list(&state).await,
3031                            "agents.health" => handle_agents_health(&state).await,
3032                            "agents.upsert" => {
3033                                handle_agents_upsert(&parsed, &state, &session).await
3034                            }
3035                            "agents.install" => {
3036                                handle_agents_install(&parsed, &state, &session).await
3037                            }
3038                            "agents.remove" => {
3039                                handle_agents_remove(&parsed, &state, &session).await
3040                            }
3041                            "agents.start" => handle_agents_start(&parsed, &state, &session).await,
3042                            "agents.stop" => handle_agents_stop(&parsed, &state, &session).await,
3043                            "agents.restart" => {
3044                                handle_agents_restart(&parsed, &state, &session).await
3045                            }
3046                            "agents.wait" => handle_agents_wait(&parsed, &state, bound_agent).await,
3047                            "agents.tail_log" => {
3048                                handle_agents_tail_log(&parsed, &state, bound_agent).await
3049                            }
3050                            "agents.list_external" => handle_agents_list_external(&parsed).await,
3051                            "agents.detect_external" => {
3052                                handle_agents_detect_external(&parsed).await
3053                            }
3054                            "agents.health_external" => {
3055                                handle_agents_health_external(&parsed).await
3056                            }
3057                            "assistant.identity.get" => handle_assistant_identity_get(),
3058                            "assistant.identity.set" => {
3059                                handle_assistant_identity_set(&parsed, &session, &state).await
3060                            }
3061                            "assistants.invoke" => {
3062                                handle_assistants_invoke(&parsed, &state, &session).await
3063                            }
3064                            "agents.invoke_external" => {
3065                                handle_agents_invoke_external(&parsed, &state, &session).await
3066                            }
3067                            "agents.chat" => handle_agents_chat(&parsed, &state, &session).await,
3068                            "agents.peers" => {
3069                                crate::peers::handle_agents_peers(&state, &session).await
3070                            }
3071                            "agents.message" => {
3072                                crate::peers::handle_agents_message(&parsed, &state, &session).await
3073                            }
3074                            "agents.message.pending" => {
3075                                crate::peers::handle_agents_message_pending(&state, &session).await
3076                            }
3077                            "agents.message.approve" => {
3078                                crate::peers::handle_agents_message_approve(
3079                                    &parsed, &state, &session,
3080                                )
3081                                .await
3082                            }
3083                            "agents.chat.cancel" => {
3084                                handle_agents_chat_cancel(
3085                                    &parsed,
3086                                    &state,
3087                                    &session.client_id,
3088                                    session.is_host.load(std::sync::atomic::Ordering::Acquire),
3089                                )
3090                                .await
3091                            }
3092                            "agents.chat.approve" => {
3093                                handle_agents_chat_approve(
3094                                    &parsed,
3095                                    &state,
3096                                    &session.client_id,
3097                                    session.is_host.load(std::sync::atomic::Ordering::Acquire),
3098                                )
3099                                .await
3100                            }
3101                            "goal.suggest" => handle_goal_suggest(&parsed, &state).await,
3102                            "goal.set" => handle_goal_set(&parsed, &state).await,
3103                            "goal.status" => handle_goal_status(&parsed, &state).await,
3104                            "goal.clear" => handle_goal_clear(&parsed, &state).await,
3105                            // Foreman: decompose a coding goal into a footprint-
3106                            // annotated, scheduled subtask plan (B6). Execution
3107                            // (foreman.run) farms the plan to external CLIs and gates
3108                            // the union — added once the run surface lands.
3109                            "foreman.plan" => handle_foreman_plan(&parsed, &state).await,
3110                            "foreman.run" => handle_foreman_run(&parsed, &state, &session).await,
3111                            // fleet.* — what every reachable CAR instance can do,
3112                            // and whether this one takes farmed-out work.
3113                            "fleet.inventory" => {
3114                                crate::fleet::handle_fleet_inventory(&state, &session).await
3115                            }
3116                            "fleet.composite" => {
3117                                crate::fleet::handle_fleet_composite(&parsed, &state, &session)
3118                                    .await
3119                            }
3120                            "fleet.worker.get" => crate::fleet::handle_fleet_worker_get().await,
3121                            "fleet.worker.set" => {
3122                                crate::fleet::handle_fleet_worker_set(&parsed, &session).await
3123                            }
3124                            // Remote MCP connectors (CAR as MCP client). WS-only
3125                            // (no FFI surface in Phase 1) — connector lifecycle is
3126                            // daemon-shared and interactive. See
3127                            // docs/proposals/remote-mcp-connectors.md.
3128                            "connectors.add" => handle_connectors_add(&parsed, &state).await,
3129                            "connectors.add_stdio" => {
3130                                handle_connectors_add_stdio(&parsed, &state).await
3131                            }
3132                            "connectors.authenticate" => {
3133                                handle_connectors_authenticate(&parsed, &state).await
3134                            }
3135                            "connectors.complete_authentication" => {
3136                                handle_connectors_complete_authentication(&parsed, &state).await
3137                            }
3138                            "connectors.list" => handle_connectors_list(&state).await,
3139                            "connectors.tools" => handle_connectors_tools(&parsed, &state).await,
3140                            "connectors.enable_tools" => {
3141                                handle_connectors_enable_tools(&parsed, &state).await
3142                            }
3143                            "connectors.disable_tools" => {
3144                                handle_connectors_disable_tools(&parsed, &state).await
3145                            }
3146                            "connectors.refresh" => {
3147                                handle_connectors_refresh(&parsed, &state).await
3148                            }
3149                            "connectors.remove" => handle_connectors_remove(&parsed, &state).await,
3150                            // A2A v1.0 (PascalCase) + v0.3 (slash form) — both
3151                            // alias to the same in-core dispatcher per
3152                            // Parslee-ai/car-releases#28. Embedders that need a
3153                            // custom AgentCardSource / TaskStore plug them in
3154                            // via ServerStateConfig::with_a2a_card_source /
3155                            // with_a2a_store before any handler runs.
3156                            "message/send"
3157                            | "SendMessage"
3158                            | "message/stream"
3159                            | "SendStreamingMessage"
3160                            | "tasks/get"
3161                            | "GetTask"
3162                            | "tasks/list"
3163                            | "ListTasks"
3164                            | "tasks/cancel"
3165                            | "CancelTask"
3166                            | "tasks/resubscribe"
3167                            | "SubscribeToTask"
3168                            | "tasks/pushNotificationConfig/set"
3169                            | "CreateTaskPushNotificationConfig"
3170                            | "tasks/pushNotificationConfig/get"
3171                            | "GetTaskPushNotificationConfig"
3172                            | "tasks/pushNotificationConfig/list"
3173                            | "ListTaskPushNotificationConfigs"
3174                            | "tasks/pushNotificationConfig/delete"
3175                            | "DeleteTaskPushNotificationConfig"
3176                            | "agent/getAuthenticatedExtendedCard"
3177                            | "GetExtendedAgentCard" => {
3178                                handle_a2a_dispatch(method_owned.as_str(), &parsed, &state).await
3179                            }
3180                            _ => Err(format!("unknown method: {}", method_owned)),
3181                        }
3182                    };
3183
3184                    // Per-request deadline so a wedged handler can't hang the
3185                    // client forever. None = exempt (unbounded streams/subs/loops).
3186                    // On timeout the handler future is dropped. That DOES now
3187                    // fire the tools.cancel host-abort and release the `pending`
3188                    // entry for any in-flight tool callback: the cleanup hangs
3189                    // off a `Drop` guard (`PendingToolCall` in session.rs), so
3190                    // it survives the future being dropped rather than needing
3191                    // code to run after the await (car#264).
3192                    // `parsed.params` rides along because `proposal.submit`'s
3193                    // deadline is derived from the submitted proposal's own
3194                    // action budgets, not from the method name alone (#265).
3195                    let result = dispatch_with_server_deadline(
3196                        method_owned.as_str(),
3197                        &parsed.params,
3198                        dispatch,
3199                    )
3200                    .await;
3201
3202                    if method_owned == "session.auth"
3203                        && result.is_ok()
3204                        && credential_event_is_eligible(
3205                            &session,
3206                            credential_transport_auth_required,
3207                            credential_host_role_required,
3208                        )
3209                    {
3210                        let _ = credential_activation_tx_task.send(true);
3211                        let mut ready = credential_ready_rx_task;
3212                        while !*ready.borrow_and_update() {
3213                            if ready.changed().await.is_err() {
3214                                break;
3215                            }
3216                        }
3217                    }
3218
3219                    let resp = json_rpc_response_for_handler_result(parsed.id, result);
3220                    let _ = send_response(&session.channel, resp).await;
3221                });
3222            }
3223        } else if msg.is_binary() {
3224            // CAR binary frame transport — see `car_ffi_common::voice::binary`
3225            // for the canonical header definition. 26-byte fixed header
3226            // followed by an opaque payload. Inbound type 0x01 carries
3227            // 16-bit signed LE PCM into a `pcm_push` session; other
3228            // types (0x02 TTS chunk, 0x03 final marker, 0x04 error)
3229            // are server-emitted and rejected here.
3230            let bytes = msg.into_data();
3231            let parsed = match car_ffi_common::voice::binary::parse_frame(&bytes) {
3232                Ok(p) => p,
3233                Err(e) => {
3234                    tracing::warn!("binary frame from {} rejected: {}", client_id, e);
3235                    continue;
3236                }
3237            };
3238            match parsed.frame_type {
3239                car_ffi_common::voice::binary::FRAME_TYPE_INBOUND_PCM => {
3240                    let registry = state.voice_sessions.clone();
3241                    let payload_owned = parsed.payload.to_vec();
3242                    let session_id_owned = parsed.session_id_hex.clone();
3243                    conn_tasks.spawn(async move {
3244                        if let Err(e) = car_ffi_common::voice::transcribe_stream_push(
3245                            &session_id_owned,
3246                            &payload_owned,
3247                            registry,
3248                        )
3249                        .await
3250                        {
3251                            tracing::warn!(
3252                                "binary PCM push to session {} failed: {}",
3253                                session_id_owned,
3254                                e
3255                            );
3256                        }
3257                    });
3258                }
3259                other => {
3260                    tracing::debug!(
3261                        "binary frame type {:#04x} from {} not accepted server-side",
3262                        other,
3263                        client_id
3264                    );
3265                }
3266            }
3267        } else if msg.is_close() {
3268            info!("Client {} disconnected", client_id);
3269            break;
3270        }
3271    }
3272
3273    // car#209: abort every in-flight handler for this connection
3274    // *first* — they each hold an `Arc<ClientSession>` → `Arc<WsChannel>`
3275    // clone; until they're gone the split sink (and the inbound socket
3276    // FD) can't drop, even after the registries below are cleared.
3277    conn_tasks.abort_all();
3278    // Inference backend wrappers outlive their response waiter after a
3279    // controlled terminal. Abort every retained active/orphan task when the
3280    // owning socket disconnects so no provider or delegated runner detaches.
3281    session.inference_control.abort_all();
3282
3283    session.host.unsubscribe(&client_id).await;
3284    // Drop this connection's supervisor registration too. Intents it left
3285    // parked are deliberately NOT released here — they run out their timeout
3286    // and fail closed, so a supervisor cannot convert a pending deny into an
3287    // allow by dropping the socket.
3288    state.supervision.unsubscribe(&client_id).await;
3289    // Auto-cancel this session's pending approvals so the queue stays
3290    // in sync with what's actually decidable — covers graceful
3291    // unregister+close, hard crash (TCP reset), and ping timeout in
3292    // one place. System-level gate approvals (client_id None) are not
3293    // touched. car-releases#48.
3294    session.host.reap_session_approvals(&client_id).await;
3295    state.a2ui_subscribers.lock().await.remove(&client_id);
3296
3297    // Fix for MULTI-4 / WS-3: drop the session from the registry and
3298    // drain any pending tool callbacks. Without this, every connection
3299    // we ever accepted keeps an `Arc<ClientSession>` alive in
3300    // `state.sessions`, and outstanding `oneshot::Sender`s in
3301    // `session.channel.pending` outlive the closed connection until
3302    // their per-call timeout (the action budget, or `DEFAULT_TOOL_TIMEOUT_MS`
3303    // — no longer a hardcoded 60s, see car#259). Dropping the senders here causes any
3304    // awaiting `recv()` in `WsToolExecutor::execute` to return
3305    // `RecvError` immediately, which the existing error-handler path
3306    // already maps to "callback channel closed" — same shape as the
3307    // timeout path, just faster.
3308    let _removed = state.remove_session(&client_id).await;
3309    {
3310        let mut pending = session.channel.pending.lock().await;
3311        pending.clear();
3312    }
3313
3314    Ok(())
3315}
3316
3317async fn send_response(
3318    channel: &WsChannel,
3319    resp: JsonRpcResponse,
3320) -> Result<(), Box<dyn std::error::Error>> {
3321    use futures::SinkExt;
3322    let json = serde_json::to_string(&resp)?;
3323    channel
3324        .write
3325        .lock()
3326        .await
3327        .send(Message::Text(json.into()))
3328        .await?;
3329    Ok(())
3330}
3331
3332// --- Request handlers ---
3333
3334async fn handle_host_subscribe(
3335    session: &crate::session::ClientSession,
3336    state: &Arc<ServerState>,
3337) -> Result<Value, String> {
3338    session
3339        .host
3340        .subscribe(&session.client_id, session.channel.clone())
3341        .await;
3342    // Capture the ordering boundary BEFORE reading browser state. If a live
3343    // sign-in event races this snapshot, it receives a larger sequence and a
3344    // client that already applied it can reject this older response.
3345    let event_sequence = session.host.event_sequence();
3346    let snapshot = serde_json::to_value(HostSnapshot {
3347        subscribed: true,
3348        agents: session.host.agents().await,
3349        devices: session.host.devices().await,
3350        approvals: session.host.approvals().await,
3351        events: session.host.events(50).await,
3352        pending_signins: state.browser_views.pending_signins().await,
3353        event_sequence,
3354        identity: Some(daemon_identity(state)),
3355    })
3356    .map_err(|e| e.to_string())?;
3357    Ok(snapshot)
3358}
3359
3360/// `supervision.subscribe` — register this connection as a supervisor.
3361///
3362/// `{ filter?: { tools?, sessions?, min_reversibility? } }` →
3363/// `{ subscribed, decision_timeout_ms, supervisors }`.
3364///
3365/// Re-subscribing replaces the filter, so a supervisor narrows or widens its
3366/// view without a disconnect. The connection's own channel is the delivery
3367/// path, exactly as `host.subscribe` works.
3368async fn handle_supervision_subscribe(
3369    request: &JsonRpcMessage,
3370    session: &crate::session::ClientSession,
3371    state: &Arc<ServerState>,
3372) -> Result<Value, String> {
3373    let filter: crate::supervision::SupervisionFilter = match request.params.get("filter") {
3374        Some(Value::Null) | None => Default::default(),
3375        Some(v) => serde_json::from_value(v.clone())
3376            .map_err(|e| format!("invalid supervision filter: {e}"))?,
3377    };
3378    state
3379        .supervision
3380        .subscribe(&session.client_id, filter, session.channel.clone())
3381        .await;
3382    Ok(serde_json::json!({
3383        "subscribed": true,
3384        "decision_timeout_ms": state.supervision.timeout().as_millis() as u64,
3385        "supervisors": state.supervision.subscriber_count().await,
3386    }))
3387}
3388
3389/// `supervision.unsubscribe` — stop supervising. `{}` → `{ subscribed: false,
3390/// was_subscribed }`.
3391async fn handle_supervision_unsubscribe(
3392    session: &crate::session::ClientSession,
3393    state: &Arc<ServerState>,
3394) -> Result<Value, String> {
3395    let was = state.supervision.unsubscribe(&session.client_id).await;
3396    Ok(serde_json::json!({ "subscribed": false, "was_subscribed": was }))
3397}
3398
3399/// `supervision.pending` — every intent currently parked on a verdict.
3400/// `{}` → `{ intents: [...] }`.
3401///
3402/// This is the batching half of the design: one model call can cover every
3403/// parked intent, which is what makes supervision affordable at all
3404/// (Shepherd Appendix E — batched and trimmed, not a cheap meta-model).
3405async fn handle_supervision_pending(state: &Arc<ServerState>) -> Result<Value, String> {
3406    Ok(serde_json::json!({ "intents": state.supervision.pending().await }))
3407}
3408
3409/// `supervision.decide` — answer one intent.
3410/// `{ intent_id, decision: { kind: "allow" | "deny" | "escalate", reason? } }`
3411/// → `{ decided: true }`.
3412///
3413/// Errors when the intent is unknown — already decided, already timed out, or
3414/// never existed. That is deliberate: a supervisor that believes it denied
3415/// something needs to hear when the denial did not land.
3416async fn handle_supervision_decide(
3417    request: &JsonRpcMessage,
3418    state: &Arc<ServerState>,
3419) -> Result<Value, String> {
3420    let intent_id = request
3421        .params
3422        .get("intent_id")
3423        .and_then(|v| v.as_str())
3424        .ok_or("supervision.decide requires 'intent_id'")?;
3425    let decision_value = request
3426        .params
3427        .get("decision")
3428        .ok_or("supervision.decide requires 'decision'")?;
3429    let decision: crate::supervision::SupervisionDecision =
3430        serde_json::from_value(decision_value.clone())
3431            .map_err(|e| format!("invalid supervision decision: {e}"))?;
3432    state.supervision.decide(intent_id, decision).await?;
3433    Ok(serde_json::json!({ "decided": true }))
3434}
3435
3436/// Snapshot the daemon-identity facts for a fresh subscriber.
3437/// Cheap: non-acquiring reads on `OnceLock`s + a single
3438/// `to_string_lossy` on the manifest path. Critically uses
3439/// [`ServerState::supervisor_if_installed`] — not the lazy-init
3440/// `supervisor()` — so a Heisenberg subscribe can't *cause* the
3441/// daemon to acquire the manifest lock just by asking whether it
3442/// owns one.
3443fn daemon_identity(state: &Arc<ServerState>) -> car_proto::HostIdentity {
3444    // Observer takes precedence: when both supervisor and observer
3445    // markers are set (currently unreachable through the standalone
3446    // binary, but an embedder could install both racily), the
3447    // observer marker is the authoritative role since the
3448    // supervisor handle is only installed when this daemon owns
3449    // the lock.
3450    let (manifest_path, manifest_role) = if let Some(p) = state.observer_manifest_path() {
3451        (
3452            Some(p.to_string_lossy().into_owned()),
3453            car_proto::HostManifestRole::Observer,
3454        )
3455    } else if let Some(s) = state.supervisor_if_installed() {
3456        (
3457            Some(s.manifest_path().to_string_lossy().into_owned()),
3458            car_proto::HostManifestRole::Owner,
3459        )
3460    } else {
3461        (None, car_proto::HostManifestRole::None)
3462    };
3463    car_proto::HostIdentity {
3464        version: env!("CARGO_PKG_VERSION").to_string(),
3465        pid: std::process::id(),
3466        manifest_path,
3467        manifest_role,
3468        parslee: state
3469            .parslee_session
3470            .get()
3471            .map(|session| session.identity.clone()),
3472    }
3473}
3474
3475/// Return the Parslee cloud credential for an authenticated CAR
3476/// connection. Managed agents already receive `CAR_AUTH_TOKEN` and
3477/// authenticate to the local daemon with `session.auth`; this method is
3478/// their supported bridge from local CAR auth to the user's Parslee
3479/// backend auth.
3480///
3481/// The bearer token is intentionally not injected into every managed
3482/// child process environment. Agents ask for it only when they need it,
3483/// through the same local auth gate that protects the rest of the
3484/// daemon.
3485async fn handle_parslee_auth(state: &ServerState) -> Result<Value, String> {
3486    let (session, api_base) = crate::parslee_auth::load_or_refresh_with_authority()
3487        .await?
3488        .ok_or_else(|| "Parslee account not authenticated; run `car auth login`".to_string())?;
3489    activate_parslee_session(state, session.clone(), api_base);
3490    Ok(serde_json::json!({
3491        "authenticated": true,
3492        "token_type": "Bearer",
3493        "access_token": session.access_token,
3494        "authorization_header": format!("Bearer {}", session.access_token),
3495        "identity": session.identity,
3496    }))
3497}
3498
3499// --- auth.* : GUI-driven Parslee sign-in (CAR Host.app).
3500// Shares one implementation with `car auth login` via the car-auth
3501// crate. The trusted in-process GUI holds the PKCE verifier + state;
3502// the daemon serializes process-global operations and persists only
3503// an attempt-bound completion proof beside the credentials so a lost
3504// reply can be reconciled without replaying the one-time code.
3505// --- auth.* : host-gated Parslee login management (car#661) ---
3506//
3507// Same trust root as `openrouter.*` (car#650) and `messaging.*`. These methods
3508// own the daemon's Parslee identity: `logout` clears the active login's tokens,
3509// `switch_org`/`switch_account` silently repoint which identity subsequent
3510// inference runs and bills against, and `remove_account` drops a stored login.
3511// They took only the request params, so they could not check the caller's role
3512// and did not — any authenticated local connection (a registered supervised
3513// agent, or any process that read the auth token from `GET /auth-token`) could
3514// sign the user out of every Parslee-routed model with one call.
3515//
3516// The reads are gated too: `status`/`accounts` enumerate which identities exist
3517// and which is active; `snapshot`/`completion_status` expose authentication and
3518// attempt state.
3519//
3520// Gating does NOT cost the CLI anything, which is the thing worth checking
3521// before copying this pattern: `car auth login`/`logout`/`orgs`/`switch-org`/
3522// `accounts` never touch this surface — they call `car_auth::` in-process
3523// (main.rs: `exchange_code`/`store_tokens`/`clear_tokens`). The only WS consumer
3524// is CarHost.app's `ParsleeAccount`, which drives `HostEventsClient` and already
3525// presents `session.auth { host_token }`.
3526//
3527// DEGRADED-MODE CAVEAT (inherited, not specific to this surface):
3528// `require_approval_authority` is a no-op when no host token is configured
3529// (pure-dev / `--no-auth`), where the connection is the authority. Identical to
3530// `permission.*`, `messaging.*`, and `openrouter.*`.
3531
3532async fn dispatch_daemon_owned_auth(
3533    method: &str,
3534    req: &JsonRpcMessage,
3535    state: &ServerState,
3536) -> Result<Value, HandlerFailure> {
3537    match method {
3538        "auth.authority_hint" => serde_json::to_value(car_auth::credential_authority_hint())
3539            .map_err(|error| HandlerFailure::Dispatch(error.to_string())),
3540        "auth.start" => handle_auth_start(req)
3541            .await
3542            .map_err(|error| classified_auth_failure(method, error)),
3543        "auth.complete" => accept_auth_completion(req, state)
3544            .await
3545            .map_err(|error| classified_auth_failure(method, error)),
3546        "auth.completion_status" => handle_auth_completion_status(req, state)
3547            .await
3548            .map_err(|error| classified_auth_failure(method, error)),
3549        "auth.snapshot" => handle_auth_snapshot()
3550            .await
3551            .map_err(HandlerFailure::from_dispatch),
3552        "auth.status" => handle_auth_status(req, state)
3553            .await
3554            .map_err(HandlerFailure::from_dispatch),
3555        "auth.switch_org" => handle_auth_switch_org(req)
3556            .await
3557            .map_err(HandlerFailure::from_dispatch),
3558        "auth.accounts" => handle_auth_accounts(req)
3559            .await
3560            .map_err(HandlerFailure::from_dispatch),
3561        "auth.switch_account" => handle_auth_switch_account(req)
3562            .await
3563            .map_err(HandlerFailure::from_dispatch),
3564        "auth.remove_account" => handle_auth_remove_account(req)
3565            .await
3566            .map_err(HandlerFailure::from_dispatch),
3567        "auth.logout" => handle_auth_logout()
3568            .await
3569            .map_err(HandlerFailure::from_dispatch),
3570        _ => Err(HandlerFailure::Dispatch(format!(
3571            "unknown daemon-owned auth method: {method}"
3572        ))),
3573    }
3574}
3575
3576async fn handle_auth_start(req: &JsonRpcMessage) -> Result<Value, car_auth::AuthOperationError> {
3577    // Choosing the browser endpoint must never inspect existing credentials.
3578    // The durable attempt reservation below is a separate state mutation.
3579    let environment_api_base = std::env::var(car_auth::PARSLEE_API_BASE_KEY).ok();
3580    let api_base = select_auth_start_api_base(&req.params, environment_api_base.as_deref());
3581    let client_id = str_or(&req.params, "client_id", "parslee-car");
3582    let redirect_uri =
3583        require_str(&req.params, "redirect_uri").map_err(car_auth::AuthOperationError::Terminal)?;
3584    let provider = opt_str(&req.params, "provider");
3585    // `prompt=select_account` (sent by the add-account path) forces the account
3586    // chooser so a second Parslee login can be added alongside the current one.
3587    let prompt = opt_str(&req.params, "prompt");
3588    let state = car_auth::new_state();
3589    let attempt_id = uuid::Uuid::new_v4().simple().to_string();
3590    let verifier = car_auth::pkce_verifier();
3591    let challenge = car_auth::pkce_challenge(&verifier);
3592    let url = car_auth::authorize_url(
3593        &api_base,
3594        client_id,
3595        redirect_uri,
3596        &state,
3597        &challenge,
3598        provider,
3599        prompt,
3600    )
3601    .map_err(car_auth::AuthOperationError::Terminal)?;
3602    // The reservation is the durable ordering point. A later auth.start,
3603    // logout, or identity switch fences this attempt before auth.complete can
3604    // touch the one-time authorization code.
3605    let lease = car_auth::reserve_login_attempt_classified(&attempt_id).await?;
3606    Ok(serde_json::json!({
3607        "authorize_url": url,
3608        "state": state,
3609        "verifier": verifier,
3610        "attempt_id": attempt_id,
3611        "expires_at_unix_ms": lease.attempt_expires_at_unix_ms,
3612    }))
3613}
3614
3615fn select_auth_start_api_base(params: &Value, environment_api_base: Option<&str>) -> String {
3616    opt_str(params, "api_base")
3617        .map(str::trim)
3618        .filter(|value| !value.is_empty())
3619        .map(str::to_string)
3620        .or_else(|| {
3621            environment_api_base
3622                .map(str::trim)
3623                .filter(|value| !value.is_empty())
3624                .map(str::to_string)
3625        })
3626        .unwrap_or_else(|| car_auth::DEFAULT_API_BASE.to_string())
3627        .trim_end_matches('/')
3628        .to_string()
3629}
3630
3631fn auth_completion_value(record: &car_auth::AuthCompletionRecord) -> Value {
3632    let session = record
3633        .session
3634        .as_deref()
3635        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
3636    serde_json::json!({
3637        "state": "complete",
3638        "attempt_id": record.attempt_id,
3639        "generation": record.generation,
3640        "account_id": record.account_id,
3641        "session": session,
3642    })
3643}
3644
3645#[derive(Clone)]
3646struct AuthCompletionRequest {
3647    api_base: String,
3648    client_id: String,
3649    redirect_uri: String,
3650    code: String,
3651    verifier: String,
3652    attempt_id: String,
3653}
3654
3655fn parse_auth_completion_request(req: &JsonRpcMessage) -> Result<AuthCompletionRequest, String> {
3656    Ok(AuthCompletionRequest {
3657        api_base: car_auth::api_base(opt_str(&req.params, "api_base")),
3658        client_id: str_or(&req.params, "client_id", "parslee-car").to_string(),
3659        redirect_uri: require_str(&req.params, "redirect_uri")?.to_string(),
3660        code: require_str(&req.params, "code")?.to_string(),
3661        verifier: require_str(&req.params, "verifier")?.to_string(),
3662        attempt_id: require_str(&req.params, "attempt_id")?.to_string(),
3663    })
3664}
3665
3666async fn accept_auth_completion(
3667    req: &JsonRpcMessage,
3668    state: &ServerState,
3669) -> Result<Value, car_auth::AuthOperationError> {
3670    let request =
3671        parse_auth_completion_request(req).map_err(car_auth::AuthOperationError::Terminal)?;
3672    let attempt_id = request.attempt_id.clone();
3673    let task_attempt_id = attempt_id.clone();
3674    let lease =
3675        car_auth::claim_login_attempt_classified(&attempt_id, &state.auth_completion_owner_id)
3676            .await?;
3677    state
3678        .spawn_durable_task(format!("auth.complete:{attempt_id}"), async move {
3679            if let Err(error) = complete_auth_completion(request, lease).await {
3680                tracing::warn!(attempt_id = %task_attempt_id, error = %error, "Parslee login completion failed");
3681            }
3682        })
3683        .await;
3684    Ok(serde_json::json!({
3685        "state": "accepted",
3686        "attempt_id": attempt_id,
3687    }))
3688}
3689
3690async fn complete_auth_completion(
3691    request: AuthCompletionRequest,
3692    lease: car_auth::LoginAttemptLease,
3693) -> Result<Value, String> {
3694    // The direct RPC response is produced only after this lease was durably
3695    // claimed and this daemon-owned redemption task was spawned. A truthful
3696    // `accepted` receipt therefore always has a redeeming proof to reconcile.
3697    let worker_lease = lease.clone();
3698    let result = match tokio::spawn(complete_claimed_auth_completion(request, worker_lease)).await {
3699        Ok(result) => result,
3700        Err(error) => Err(format!(
3701            "Parslee login worker stopped unexpectedly: {error}"
3702        )),
3703    };
3704    if result.is_err() {
3705        match car_auth::fail_login_attempt(
3706            &lease,
3707            car_auth::AuthAttemptFailure::completion_failed(),
3708        )
3709        .await
3710        {
3711            Ok(true) => {}
3712            Ok(false) => {
3713                tracing::debug!(
3714                    attempt_id = %lease.attempt_id,
3715                    "stale auth worker exit could not replace a newer attempt"
3716                );
3717            }
3718            Err(error) => {
3719                tracing::warn!(
3720                    attempt_id = %lease.attempt_id,
3721                    error = %error,
3722                    "failed to persist terminal auth worker lifecycle"
3723                );
3724            }
3725        }
3726    }
3727    result
3728}
3729
3730async fn complete_claimed_auth_completion(
3731    request: AuthCompletionRequest,
3732    lease: car_auth::LoginAttemptLease,
3733) -> Result<Value, String> {
3734    let (token, completion_session) =
3735        auth_completion_network_with_deadline(car_auth::AUTH_COMPLETION_NETWORK_DEADLINE, async {
3736            let token = car_auth::exchange_code(
3737                &request.api_base,
3738                &request.client_id,
3739                &request.redirect_uri,
3740                &request.code,
3741                &request.verifier,
3742            )
3743            .await?;
3744            let session = fetch_completion_session(&request.api_base, &token.access_token).await?;
3745            Ok((token, session))
3746        })
3747        .await?;
3748
3749    // Identity is known and all network work is finished. This lease-checked
3750    // local publication deliberately sits outside the cancellable network cap.
3751    let record =
3752        car_auth::commit_login(&request.api_base, &token, &completion_session, Some(lease)).await?;
3753
3754    Ok(auth_completion_value(&record))
3755}
3756
3757async fn auth_completion_network_with_deadline<T, F>(
3758    deadline: std::time::Duration,
3759    operation: F,
3760) -> Result<T, String>
3761where
3762    F: std::future::Future<Output = Result<T, String>>,
3763{
3764    tokio::time::timeout(deadline, operation)
3765        .await
3766        .map_err(|_| {
3767            format!(
3768                "Parslee login network phase timed out after {}s; no credentials were saved",
3769                deadline.as_secs()
3770            )
3771        })?
3772}
3773
3774/// A successful token exchange consumes the authorization code, but the session
3775/// read is a safe bearer GET and occurs before any credential persistence. Give
3776/// one transient session failure a bounded retry without ever re-exchanging the
3777/// code or publishing an unattributed token set.
3778async fn fetch_completion_session(api_base: &str, access_token: &str) -> Result<String, String> {
3779    match car_auth::fetch_status_with_access(api_base, access_token).await {
3780        Ok(session) => Ok(session),
3781        Err(first_error) => {
3782            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3783            car_auth::fetch_status_with_access(api_base, access_token)
3784                .await
3785                .map_err(|second_error| {
3786                    format!(
3787                        "OAuth authorization code was consumed, but CAR could not validate the signed-in account after two bounded session checks. No credentials were saved; check connectivity and sign in again. First check: {first_error}; second check: {second_error}"
3788                    )
3789                })
3790        }
3791    }
3792}
3793
3794/// Read the result of one exact browser attempt from the already-published V2
3795/// authority. It never refreshes, calls the network, or migrates legacy slots.
3796/// A later identity mutation advances the durable generation and supersedes the
3797/// proof.
3798async fn handle_auth_completion_status(
3799    req: &JsonRpcMessage,
3800    state: &ServerState,
3801) -> Result<Value, car_auth::AuthOperationError> {
3802    let attempt_id =
3803        require_str(&req.params, "attempt_id").map_err(car_auth::AuthOperationError::Terminal)?;
3804    let mut value = serde_json::to_value(
3805        car_auth::auth_completion_status_classified(attempt_id, &state.auth_completion_owner_id)
3806            .await?,
3807    )
3808    .map_err(|error| {
3809        car_auth::AuthOperationError::Terminal(format!("serialize auth completion status: {error}"))
3810    })?;
3811    if let Some(raw_session) = value.get("session").and_then(Value::as_str) {
3812        let decoded = serde_json::from_str::<Value>(raw_session).unwrap_or(Value::Null);
3813        value["session"] = decoded;
3814    }
3815    Ok(value)
3816}
3817
3818/// Local-only, non-refreshing pre-browser baseline. The first V2 read may
3819/// migrate attributable legacy state; ambiguous legacy state fails closed.
3820async fn handle_auth_snapshot() -> Result<Value, String> {
3821    serde_json::to_value(car_auth::local_auth_snapshot().await?).map_err(|e| e.to_string())
3822}
3823
3824async fn handle_auth_status(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
3825    let mode = if req
3826        .params
3827        .get("retry_keychain_access")
3828        .and_then(Value::as_bool)
3829        == Some(true)
3830    {
3831        car_auth::CredentialReadMode::Retry
3832    } else {
3833        car_auth::CredentialReadMode::Use
3834    };
3835    let Some(mut credential) = car_auth::resolve_credential(mode)
3836        .await
3837        .map_err(|error| error.to_string())?
3838    else {
3839        return Ok(serde_json::json!({ "authenticated": false }));
3840    };
3841
3842    // The endpoint and bearer are one coordinator-resolved authoritative
3843    // bundle. No request parameter can redirect this session check.
3844    let session_json =
3845        match car_auth::fetch_status_with_access(&credential.api_base, &credential.access_token)
3846            .await
3847        {
3848            Ok(session) => session,
3849            Err(error) if error.contains("HTTP 401") => {
3850                let Some(refreshed) = car_auth::refresh_credential()
3851                    .await
3852                    .map_err(|refresh_error| refresh_error.to_string())?
3853                else {
3854                    return Err(error);
3855                };
3856                let session = car_auth::fetch_status_with_access(
3857                    &refreshed.api_base,
3858                    &refreshed.access_token,
3859                )
3860                .await?;
3861                credential = refreshed;
3862                session
3863            }
3864            Err(error) => return Err(error),
3865        };
3866    if let Ok(session) =
3867        crate::parslee_auth::session_from_status(credential.access_token, &session_json)
3868    {
3869        activate_parslee_session(state, session, credential.api_base);
3870    }
3871    let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
3872    Ok(serde_json::json!({ "authenticated": true, "session": session }))
3873}
3874
3875fn activate_parslee_session(
3876    state: &ServerState,
3877    session: crate::parslee_auth::ParsleeSession,
3878    api_base: String,
3879) {
3880    let email = session
3881        .identity
3882        .email
3883        .clone()
3884        .unwrap_or_else(|| "<unknown>".to_string());
3885    let org = session
3886        .identity
3887        .active_organization
3888        .clone()
3889        .unwrap_or_else(|| "<none>".to_string());
3890    let access_token = session.access_token.clone();
3891    if state.install_parslee_session(session).is_err() {
3892        return;
3893    }
3894
3895    info!(
3896        email = %email,
3897        active_org = %org,
3898        "Parslee account auth activated for this CAR daemon"
3899    );
3900    let Some(runtime_url) = state.mobile_registration_url.get().cloned() else {
3901        return;
3902    };
3903    let token = state.auth_token.get().cloned();
3904    tokio::spawn(async move {
3905        let registration =
3906            crate::mobile_runtime::MobileRuntimeRegistration::new(runtime_url, token);
3907        match crate::mobile_runtime::register(&api_base, &access_token, &registration).await {
3908            Ok(()) => info!("registered this CAR machine for Parslee mobile discovery"),
3909            Err(error) => tracing::warn!(
3910                error = %error,
3911                "failed to register this CAR machine for Parslee mobile discovery; \
3912                 local Parslee Core runtime remains available"
3913            ),
3914        }
3915    });
3916}
3917
3918async fn handle_auth_logout() -> Result<Value, String> {
3919    car_auth::logout().await?;
3920    Ok(serde_json::json!({ "ok": true }))
3921}
3922
3923// --- openrouter.* : host-gated OAuth connection management (car#650) ---
3924//
3925// The OpenRouter OAuth credential is deliberately daemon-private: the generic
3926// secret surface fail-closes on it (`secret.put`/`delete` reject the reserved
3927// slot), so `openrouter.disconnect` is the ONLY path that can delete it. That
3928// made it the one unguarded door in an otherwise closed design — any
3929// authenticated local connection (a supervised agent, or any process that
3930// fetched the auth token from `GET /auth-token`) could log the user out of
3931// OpenRouter, or cancel the host's in-progress connect.
3932//
3933// All four are gated on the same trust root as every other config-mutation
3934// surface (`require_approval_authority`), including the read: `status` carries
3935// credential-presence and pending-flow metadata for whichever principal owns
3936// the connection, which is not another client's business.
3937//
3938// DEGRADED-MODE CAVEAT (inherited, not specific to this surface):
3939// `require_approval_authority` is a no-op when no host token is configured
3940// (pure-dev / `--no-auth`) — the connection governs itself, the developer being
3941// the authority. Identical to `permission.*` and `messaging.*`; not diverged
3942// from here.
3943
3944// --- assistant.identity.* : the name the flagship agent answers to (car#1013) ---
3945//
3946// One record (`~/.car/identity.json`, via `car_identity`) feeds the system
3947// prompt, the voice wake gate, and every host's addressing copy. Before this,
3948// the same idea was four disagreeing string literals in three languages, and
3949// none of them was settable.
3950//
3951// The READ is deliberately ungated, unlike `openrouter.status` next door. A
3952// name is not a credential — it is what every connected surface has to render
3953// to address the assistant at all, and iPhone/Android hosts reach the daemon
3954// without approval authority. It also ships in the `server.handshake` reply, so
3955// gating the RPC would protect nothing while breaking mobile.
3956//
3957// The WRITE is gated on the same trust root as every other config mutation
3958// (`require_approval_authority`). A rename repoints the voice wake word: an
3959// ungated write would let any authenticated local connection make the
3960// assistant stop answering to the name its user knows.
3961//
3962// DEGRADED-MODE CAVEAT (inherited): `require_approval_authority` is a no-op
3963// when no host token is configured (pure-dev / `--no-auth`). Same as
3964// `permission.*`, `messaging.*`, and `openrouter.*`.
3965
3966/// `assistant.identity.get` → `{ name, spellings, aliases, user_name, brand }`.
3967///
3968/// A malformed record is an ERROR here, not a silent fall back to the default
3969/// name — this is the surface a user checks when the assistant stopped
3970/// answering to the name they set, and "everything is fine, it's called
3971/// Parslee" is the least useful possible answer.
3972fn handle_assistant_identity_get() -> Result<Value, String> {
3973    let identity = car_identity::IdentityStore::from_home().load()?;
3974    Ok(assistant_identity_wire(&identity))
3975}
3976
3977/// `assistant.identity.set` `{ name?, spellings?, user_name? }` → the new record.
3978///
3979/// Read-modify-write: every field is optional, so a host that only knows about
3980/// the name cannot wipe spellings a voice-settings pane wrote, and vice versa.
3981/// Broadcasts `host.event { kind: "assistant.identity.changed" }` so a live
3982/// rename reaches open sessions without a reconnect.
3983async fn handle_assistant_identity_set(
3984    req: &JsonRpcMessage,
3985    session: &crate::session::ClientSession,
3986    state: &Arc<ServerState>,
3987) -> Result<Value, String> {
3988    require_approval_authority(session, state)?;
3989
3990    let store = car_identity::IdentityStore::from_home();
3991    let mut identity = store.load()?;
3992
3993    // `set_name` drops spellings that belonged to the OLD name; an explicit
3994    // `spellings` below then sets the new one's. Without that, renaming Jarvis
3995    // to Friday would leave it waking on "jervis".
3996    if let Some(name) = req.params.get("name").and_then(Value::as_str) {
3997        identity.set_name(name)?;
3998    }
3999    if let Some(Value::Array(items)) = req.params.get("spellings") {
4000        identity.spellings = car_identity::validate_spellings(
4001            items
4002                .iter()
4003                .filter_map(|v| v.as_str().map(str::to_string))
4004                .collect(),
4005        )?;
4006    }
4007    match req.params.get("user_name") {
4008        Some(Value::Null) => identity = identity.with_user_name(None)?,
4009        Some(Value::String(user)) => {
4010            identity = identity.with_user_name(Some(user.clone()))?;
4011        }
4012        _ => {}
4013    }
4014    identity.touch();
4015    store.save(&identity)?;
4016
4017    let wire = assistant_identity_wire(&identity);
4018    session
4019        .host
4020        .record_event(
4021            "assistant.identity.changed",
4022            None,
4023            format!("the assistant now answers to {}", identity.name),
4024            wire.clone(),
4025        )
4026        .await;
4027    Ok(wire)
4028}
4029
4030/// The wire shape shared by `assistant.identity.*` and `server.handshake`.
4031///
4032/// `aliases` is derived, not stored: hosts match against it locally (their wake
4033/// matchers must work before the daemon answers), and deriving it here is what
4034/// keeps Swift, Kotlin, and Rust from drifting into three different lists —
4035/// which is the exact failure this whole change exists to fix.
4036fn assistant_identity_wire(identity: &car_identity::AssistantIdentity) -> Value {
4037    serde_json::json!({
4038        "name": identity.name,
4039        "spellings": identity.spellings,
4040        "aliases": identity.aliases(),
4041        "user_name": identity.user_name,
4042        "brand": car_identity::BRAND_NAME,
4043        "updated_at_unix": identity.updated_at_unix,
4044    })
4045}
4046
4047fn handle_openrouter_status(
4048    session: &crate::session::ClientSession,
4049    state: &ServerState,
4050) -> Result<Value, String> {
4051    require_approval_authority(session, state)?;
4052    serde_json::to_value(crate::openrouter_auth::status()).map_err(|e| e.to_string())
4053}
4054
4055async fn handle_openrouter_auth_start(
4056    req: &JsonRpcMessage,
4057    session: &crate::session::ClientSession,
4058    state: &ServerState,
4059) -> Result<Value, String> {
4060    require_approval_authority(session, state)?;
4061    let authorization_base_url = opt_str(&req.params, "authorization_base_url");
4062    let exchange_url = opt_str(&req.params, "exchange_url");
4063    let timeout_seconds = req.params.get("timeout_seconds").and_then(Value::as_u64);
4064    let started =
4065        crate::openrouter_auth::start(authorization_base_url, exchange_url, timeout_seconds)
4066            .await?;
4067    serde_json::to_value(started).map_err(|e| e.to_string())
4068}
4069
4070fn handle_openrouter_auth_cancel(
4071    req: &JsonRpcMessage,
4072    session: &crate::session::ClientSession,
4073    state: &ServerState,
4074) -> Result<Value, String> {
4075    require_approval_authority(session, state)?;
4076    let status = crate::openrouter_auth::cancel(opt_str(&req.params, "flow_id"));
4077    serde_json::to_value(status).map_err(|e| e.to_string())
4078}
4079
4080async fn handle_openrouter_disconnect(
4081    session: &crate::session::ClientSession,
4082    state: &ServerState,
4083) -> Result<Value, String> {
4084    require_approval_authority(session, state)?;
4085    serde_json::to_value(crate::openrouter_auth::disconnect().await?).map_err(|e| e.to_string())
4086}
4087
4088/// List every stored Parslee login (`active` marks the current one). Migrates a
4089/// pre-multi-login session into the registry on first call.
4090async fn handle_auth_accounts(req: &JsonRpcMessage) -> Result<Value, String> {
4091    let api_base = opt_str(&req.params, "api_base");
4092    let accounts = car_auth::list_accounts(api_base).await?;
4093    Ok(serde_json::json!({ "accounts": accounts }))
4094}
4095
4096/// Switch which stored login is active (swaps the keychain token slots), then
4097/// returns the refreshed session for the newly-active login.
4098async fn handle_auth_switch_account(req: &JsonRpcMessage) -> Result<Value, String> {
4099    let account_id = req
4100        .params
4101        .get("account_id")
4102        .and_then(|v| v.as_str())
4103        .ok_or("invalid params: account_id is required")?;
4104    let api_base = opt_str(&req.params, "api_base");
4105    car_auth::switch_account(account_id).await?;
4106    // The swapped-in token may be near expiry; refresh before reporting.
4107    let _ = car_auth::access_token_refreshing().await;
4108    match car_auth::fetch_status(api_base).await? {
4109        Some(session_json) => {
4110            let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4111            Ok(serde_json::json!({ "authenticated": true, "session": session }))
4112        }
4113        None => Ok(serde_json::json!({ "authenticated": false })),
4114    }
4115}
4116
4117/// Remove a stored login. If it was active, another remaining login becomes
4118/// active (or the session is cleared when none remain). Returns the new list.
4119async fn handle_auth_remove_account(req: &JsonRpcMessage) -> Result<Value, String> {
4120    let account_id = req
4121        .params
4122        .get("account_id")
4123        .and_then(|v| v.as_str())
4124        .ok_or("invalid params: account_id is required")?;
4125    let accounts = car_auth::remove_account(account_id).await?;
4126    Ok(serde_json::json!({ "ok": true, "accounts": accounts }))
4127}
4128
4129/// Switch the signed-in account's active organization. Silent — mints a fresh
4130/// token scoped to `organization_id` via the refresh grant (the backend
4131/// validates membership), so inference immediately follows the new org.
4132/// Returns the refreshed session so the caller can render the new active org.
4133async fn handle_auth_switch_org(req: &JsonRpcMessage) -> Result<Value, String> {
4134    let org_id = req
4135        .params
4136        .get("organization_id")
4137        .and_then(|v| v.as_str())
4138        .ok_or("invalid params: organization_id is required")?;
4139    let api_base = opt_str(&req.params, "api_base");
4140    car_auth::switch_org(api_base, org_id).await?;
4141    match car_auth::fetch_status(api_base).await? {
4142        Some(session_json) => {
4143            let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4144            Ok(serde_json::json!({ "authenticated": true, "session": session }))
4145        }
4146        None => Ok(serde_json::json!({ "authenticated": false })),
4147    }
4148}
4149
4150async fn handle_host_agents(
4151    session: &crate::session::ClientSession,
4152    state: &Arc<ServerState>,
4153) -> Result<Value, String> {
4154    // Base: agents that explicitly registered into HostState (callback clients
4155    // via `host.register_agent`, multi-agent runners via `WsAgentRunner`).
4156    let mut agents = session.host.agents().await;
4157
4158    // Merge: registry-supervised agents that ADVERTISE capabilities (e.g.
4159    // `car-assistant` with `["chat"]`). Those attach via `session.auth
4160    // { agent_id }`, which never registers into HostState, so their
4161    // `AgentSpec.capabilities` — present in `agents.list` — were invisible to
4162    // `host.agents` (issue #483). We read them from the supervisor at request
4163    // time rather than registering on attach: it's self-correcting (reflects
4164    // live supervisor status, can't leak a stale record across a disconnect the
4165    // attach path doesn't clean up). Scoped to capability-bearing agents so the
4166    // existing `host.agents` contents are unchanged for the common case. An
4167    // explicit HostState registration for the same id wins (skip on dup).
4168    if let Ok(supervisor) = state.supervisor() {
4169        use car_registry::supervisor::AgentStatus;
4170        let have: std::collections::HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
4171        for m in supervisor.list().await {
4172            if m.spec.capabilities.is_empty() || have.contains(&m.spec.id) {
4173                continue;
4174            }
4175            let status = match m.status {
4176                AgentStatus::Running => car_proto::HostAgentStatus::Running,
4177                AgentStatus::Starting => car_proto::HostAgentStatus::Idle,
4178                AgentStatus::Backoff | AgentStatus::Errored => car_proto::HostAgentStatus::Errored,
4179                AgentStatus::Stopped => car_proto::HostAgentStatus::Stopped,
4180            };
4181            agents.push(car_proto::HostAgent {
4182                id: m.spec.id.clone(),
4183                name: m.spec.name.clone(),
4184                kind: "supervised".to_string(),
4185                capabilities: m.spec.capabilities.clone(),
4186                project: None,
4187                session_id: None,
4188                status,
4189                current_task: None,
4190                pid: m.pid,
4191                display: Default::default(),
4192                updated_at: chrono::Utc::now(),
4193                metadata: Value::Null,
4194            });
4195        }
4196    }
4197
4198    serde_json::to_value(agents).map_err(|e| e.to_string())
4199}
4200
4201async fn handle_host_events(
4202    req: &JsonRpcMessage,
4203    session: &crate::session::ClientSession,
4204) -> Result<Value, String> {
4205    let limit = req
4206        .params
4207        .get("limit")
4208        .and_then(|v| v.as_u64())
4209        .unwrap_or(100) as usize;
4210    serde_json::to_value(session.host.events(limit).await).map_err(|e| e.to_string())
4211}
4212
4213async fn handle_host_approvals(session: &crate::session::ClientSession) -> Result<Value, String> {
4214    serde_json::to_value(session.host.approvals().await).map_err(|e| e.to_string())
4215}
4216
4217async fn handle_a2ui_apply(
4218    req: &JsonRpcMessage,
4219    state: &Arc<ServerState>,
4220) -> Result<Value, String> {
4221    #[derive(Deserialize)]
4222    struct Params {
4223        #[serde(default)]
4224        envelope: Option<car_a2ui::A2uiEnvelope>,
4225        #[serde(default)]
4226        message: Option<car_a2ui::A2uiEnvelope>,
4227    }
4228
4229    let envelope = if req.params.get("createSurface").is_some()
4230        || req.params.get("updateComponents").is_some()
4231        || req.params.get("updateDataModel").is_some()
4232        || req.params.get("deleteSurface").is_some()
4233    {
4234        serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4235            .map_err(|e| e.to_string())?
4236    } else {
4237        match serde_json::from_value::<Params>(req.params.clone()) {
4238            Ok(params) => params
4239                .envelope
4240                .or(params.message)
4241                .ok_or_else(|| "`a2ui.apply` requires an A2UI envelope".to_string())?,
4242            Err(_) => serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4243                .map_err(|e| e.to_string())?,
4244        }
4245    };
4246
4247    apply_a2ui_envelope(state, envelope, None, None).await
4248}
4249
4250async fn handle_a2ui_ingest(
4251    req: &JsonRpcMessage,
4252    state: &Arc<ServerState>,
4253) -> Result<Value, String> {
4254    #[derive(Deserialize)]
4255    #[serde(rename_all = "camelCase")]
4256    struct Params {
4257        #[serde(default)]
4258        endpoint: Option<String>,
4259        #[serde(default)]
4260        a2a_endpoint: Option<String>,
4261        #[serde(default)]
4262        owner: Option<car_a2ui::A2uiSurfaceOwner>,
4263        #[serde(default)]
4264        route_auth: Option<A2aRouteAuth>,
4265        #[serde(default)]
4266        allow_untrusted_endpoint: bool,
4267    }
4268
4269    let params = serde_json::from_value::<Params>(req.params.clone()).unwrap_or(Params {
4270        endpoint: None,
4271        a2a_endpoint: None,
4272        owner: None,
4273        route_auth: None,
4274        allow_untrusted_endpoint: false,
4275    });
4276    let payload = req.params.get("payload").unwrap_or(&req.params);
4277    state
4278        .a2ui
4279        .validate_payload(payload)
4280        .map_err(|e| e.to_string())?;
4281    let envelopes = car_a2ui::envelopes_from_value(payload).map_err(|e| e.to_string())?;
4282    if envelopes.is_empty() {
4283        return Err("no A2UI envelopes found in payload".into());
4284    }
4285    let endpoint = params.endpoint.or(params.a2a_endpoint);
4286    let endpoint = trusted_route_endpoint(endpoint, params.allow_untrusted_endpoint);
4287    let owner = params
4288        .owner
4289        .or_else(|| car_a2ui::owner_from_value(payload))
4290        .map(|owner| match endpoint.clone() {
4291            Some(endpoint) => owner.with_endpoint(Some(endpoint)),
4292            None => owner,
4293        });
4294
4295    let mut results = Vec::new();
4296    for envelope in envelopes {
4297        let value =
4298            apply_a2ui_envelope(state, envelope, owner.clone(), params.route_auth.clone()).await?;
4299        results.push(value);
4300    }
4301    Ok(serde_json::json!({ "applied": results }))
4302}
4303
4304async fn apply_a2ui_envelope(
4305    state: &Arc<ServerState>,
4306    envelope: car_a2ui::A2uiEnvelope,
4307    owner: Option<car_a2ui::A2uiSurfaceOwner>,
4308    route_auth: Option<A2aRouteAuth>,
4309) -> Result<Value, String> {
4310    let result = state
4311        .a2ui
4312        .apply_with_owner(envelope, owner)
4313        .await
4314        .map_err(|e| e.to_string())?;
4315    update_a2ui_route_auth(state, &result, route_auth).await;
4316    let kind = if result.deleted {
4317        "a2ui.surface_deleted"
4318    } else {
4319        "a2ui.surface_updated"
4320    };
4321    let message = if result.deleted {
4322        format!("A2UI surface {} deleted", result.surface_id)
4323    } else {
4324        format!("A2UI surface {} updated", result.surface_id)
4325    };
4326    let payload = serde_json::to_value(&result).map_err(|e| e.to_string())?;
4327    state
4328        .host
4329        .record_event(kind, None, message, payload.clone())
4330        .await;
4331    // Push the envelope result to every WS subscriber as an
4332    // `a2ui.event` notification — Parslee-ai/car-releases#29. Late
4333    // joiners catch up via `a2ui/replay` (or `a2ui.surfaces`).
4334    broadcast_a2ui_event(state, kind, &payload).await;
4335    serde_json::to_value(result).map_err(|e| e.to_string())
4336}
4337
4338async fn broadcast_a2ui_event(state: &Arc<ServerState>, kind: &str, result: &Value) {
4339    use futures::SinkExt;
4340    use tokio_tungstenite::tungstenite::Message;
4341    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
4342        .a2ui_subscribers
4343        .lock()
4344        .await
4345        .values()
4346        .cloned()
4347        .collect();
4348    if subscribers.is_empty() {
4349        return;
4350    }
4351    let Ok(json) = serde_json::to_string(&serde_json::json!({
4352        "jsonrpc": "2.0",
4353        "method": "a2ui.event",
4354        "params": {
4355            "kind": kind,
4356            "result": result,
4357        },
4358    })) else {
4359        return;
4360    };
4361    for channel in subscribers {
4362        let _ = channel
4363            .write
4364            .lock()
4365            .await
4366            .send(Message::Text(json.clone().into()))
4367            .await;
4368    }
4369}
4370
4371async fn update_a2ui_route_auth(
4372    state: &Arc<ServerState>,
4373    result: &car_a2ui::A2uiApplyResult,
4374    route_auth: Option<A2aRouteAuth>,
4375) {
4376    let mut auth = state.a2ui_route_auth.lock().await;
4377    if result.deleted {
4378        auth.remove(&result.surface_id);
4379        return;
4380    }
4381
4382    let has_route_endpoint = result
4383        .surface
4384        .as_ref()
4385        .and_then(|surface| surface.owner.as_ref())
4386        .and_then(|owner| owner.endpoint.as_ref())
4387        .is_some();
4388    match (has_route_endpoint, route_auth) {
4389        (true, Some(route_auth)) => {
4390            auth.insert(result.surface_id.clone(), route_auth);
4391        }
4392        _ => {
4393            auth.remove(&result.surface_id);
4394        }
4395    }
4396}
4397
4398fn handle_a2ui_capabilities(state: &Arc<ServerState>) -> Result<Value, String> {
4399    serde_json::to_value(state.a2ui.capabilities()).map_err(|e| e.to_string())
4400}
4401
4402async fn handle_a2ui_reap(state: &Arc<ServerState>) -> Result<Value, String> {
4403    let removed = state.a2ui.reap_expired(chrono::Utc::now()).await;
4404    if !removed.is_empty() {
4405        let mut auth = state.a2ui_route_auth.lock().await;
4406        for surface_id in &removed {
4407            auth.remove(surface_id);
4408        }
4409    }
4410    Ok(serde_json::json!({ "removed": removed }))
4411}
4412
4413async fn handle_a2ui_surfaces(state: &Arc<ServerState>) -> Result<Value, String> {
4414    serde_json::to_value(state.a2ui.list().await).map_err(|e| e.to_string())
4415}
4416
4417async fn handle_a2ui_get(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
4418    let surface_id = req
4419        .params
4420        .get("surface_id")
4421        .or_else(|| req.params.get("surfaceId"))
4422        .and_then(Value::as_str)
4423        .ok_or_else(|| "`a2ui.get` requires surface_id".to_string())?;
4424    serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4425}
4426
4427/// `a2ui/subscribe` — opt this WS connection into `a2ui.event`
4428/// notifications. Subscribers receive every `apply_a2ui_envelope`
4429/// result for as long as they're connected; the cleanup hook in
4430/// `run_dispatch` removes them on disconnect. Closes
4431/// Parslee-ai/car-releases#29.
4432async fn handle_a2ui_subscribe(
4433    session: &crate::session::ClientSession,
4434    state: &Arc<ServerState>,
4435) -> Result<Value, String> {
4436    state
4437        .a2ui_subscribers
4438        .lock()
4439        .await
4440        .insert(session.client_id.clone(), session.channel.clone());
4441    Ok(serde_json::json!({ "subscribed": true }))
4442}
4443
4444/// `a2ui/unsubscribe` — opt out of `a2ui.event` notifications.
4445/// Idempotent: returns `{ subscribed: false }` regardless of prior
4446/// state.
4447async fn handle_a2ui_unsubscribe(
4448    session: &crate::session::ClientSession,
4449    state: &Arc<ServerState>,
4450) -> Result<Value, String> {
4451    state
4452        .a2ui_subscribers
4453        .lock()
4454        .await
4455        .remove(&session.client_id);
4456    Ok(serde_json::json!({ "subscribed": false }))
4457}
4458
4459/// `a2ui/replay` — fetch the current state of one surface. Intended
4460/// for late joiners and reconnect: a client calls `subscribe`, then
4461/// `replay` once per surface it's tracking, and from then on
4462/// notifications keep it in sync. Equivalent to `a2ui.get` on the
4463/// surface store; lives in the subscribe namespace for
4464/// discoverability.
4465async fn handle_a2ui_replay(
4466    req: &JsonRpcMessage,
4467    state: &Arc<ServerState>,
4468) -> Result<Value, String> {
4469    let surface_id = req
4470        .params
4471        .get("surface_id")
4472        .or_else(|| req.params.get("surfaceId"))
4473        .and_then(Value::as_str)
4474        .ok_or_else(|| "`a2ui/replay` requires surface_id".to_string())?;
4475    serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4476}
4477
4478async fn handle_a2ui_action(
4479    req: &JsonRpcMessage,
4480    state: &Arc<ServerState>,
4481) -> Result<Value, String> {
4482    let action: car_a2ui::ClientAction =
4483        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4484    let owner = state.a2ui.owner(&action.surface_id).await;
4485
4486    // Deliver the interaction to A2UI subscribers on the same channel that
4487    // carries surface updates — Parslee-ai/car-releases#58. The owning agent
4488    // listens for `a2ui.event` (that's how it gets `surface_updated`); before
4489    // this it saw nothing on click, so its surfaces were display-only.
4490    // Broadcast BEFORE the A2A route below: the local notification must not be
4491    // gated behind a (possibly slow) outbound A2A round-trip, and `route` is
4492    // deliberately excluded so an A2A endpoint URL/response isn't fanned out to
4493    // unrelated subscribers — it stays in the `host.event` record and the RPC
4494    // return for the privileged consumers that already see it.
4495    let action_result = serde_json::json!({
4496        "surfaceId": action.surface_id,
4497        "action": action,
4498        "owner": owner,
4499    });
4500    broadcast_a2ui_event(state, "a2ui.action", &action_result).await;
4501
4502    let route = route_a2ui_action(state, &action, owner.clone()).await;
4503    let payload = serde_json::json!({
4504        "action": action,
4505        "owner": owner,
4506        "route": route,
4507    });
4508    let event = state
4509        .host
4510        .record_event(
4511            "a2ui.action",
4512            None,
4513            format!(
4514                "A2UI action {} from {}",
4515                action.name, action.source_component_id
4516            ),
4517            payload,
4518        )
4519        .await;
4520    Ok(serde_json::json!({
4521        "event": event,
4522        "route": route,
4523    }))
4524}
4525
4526/// `a2ui.render_report` — renderer-emitted telemetry envelope
4527/// (Parslee-ai/car#180). Fire-and-forget; we record an event in
4528/// the host log (so dev tools and the conversation log see it) and
4529/// broadcast as `a2ui.event { kind: "a2ui.render_report", result }`
4530/// to every WS subscriber. The improvement agent reads the report
4531/// and decides whether to issue a follow-up `patchComponents`.
4532async fn handle_a2ui_render_report(
4533    req: &JsonRpcMessage,
4534    state: &Arc<ServerState>,
4535) -> Result<Value, String> {
4536    // Parse into the typed struct to enforce the schema; we
4537    // re-serialize for the event/broadcast payload so downstream
4538    // consumers don't have to defensively re-validate.
4539    let report: car_a2ui::RenderReport =
4540        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4541    let payload = serde_json::to_value(&report).map_err(|e| e.to_string())?;
4542    let kind = "a2ui.render_report";
4543    let message = format!("A2UI render report for surface {}", report.surface_id);
4544    let event = state
4545        .host
4546        .record_event(kind, None, message, payload.clone())
4547        .await;
4548    broadcast_a2ui_event(state, kind, &payload).await;
4549
4550    // Hand the report to the in-process UI-improvement agent. The
4551    // agent is sync but cheap; we await the surface lookup before
4552    // calling it so the strategies see the same surface state the
4553    // renderer saw at report-emit time. Best-effort: surface lookup
4554    // misses (the surface might have been deleted between
4555    // report-emit and report-receive) are logged and skipped, never
4556    // surfaced as JSON-RPC errors — render_report is fire-and-forget.
4557    if let Some(surface) = state.a2ui.get(&report.surface_id).await {
4558        // Iteration budget — runaway-loop backstop. try_consume
4559        // claims the slot atomically; if the surface is already at
4560        // the cap, we short-circuit before consulting the agent.
4561        // The slot stays consumed only if the patch actually
4562        // applies — failure paths refund.
4563        if !state.ui_agent_budget.try_consume(&report.surface_id) {
4564            tracing::warn!(
4565                surface_id = %report.surface_id,
4566                count = state.ui_agent_budget.count(&report.surface_id),
4567                max = state.ui_agent_budget.max(),
4568                "ui-agent iteration budget exhausted; skipping agent invocation"
4569            );
4570            return Ok(serde_json::json!({ "event": event }));
4571        }
4572        // From here on, every non-applied branch must `refund` the
4573        // slot we just claimed. Only the successful-apply branch
4574        // keeps it consumed.
4575        match state.ui_agent.on_render_report(&report, &surface) {
4576            car_ui_agent::Decision::Patch {
4577                envelope,
4578                strategy_id,
4579                patch_hash,
4580                elapsed_ns,
4581            } => {
4582                // Runtime-side convergence monitor — neo's deferred
4583                // ask. The agent's no-double-patch guard catches
4584                // same-sequence repeats; this catches A→B→A across
4585                // sequences by tracking recent patch hashes per
4586                // surface. When a proposal would repeat one in the
4587                // window, drop it; the loop waits for the next
4588                // signature change.
4589                if !state
4590                    .ui_agent_oscillation
4591                    .check_and_record(&report.surface_id, patch_hash)
4592                {
4593                    tracing::warn!(
4594                        surface_id = %report.surface_id,
4595                        strategy = %strategy_id,
4596                        patch_hash,
4597                        "ui-agent oscillation detected; suppressing patch"
4598                    );
4599                    // Suppressed patch never applied → release the
4600                    // budget slot we claimed above.
4601                    state.ui_agent_budget.refund(&report.surface_id);
4602                    return Ok(serde_json::json!({ "event": event }));
4603                }
4604                let a2ui_envelope = car_a2ui::A2uiEnvelope {
4605                    patch_components: Some(envelope),
4606                    ..Default::default()
4607                };
4608                if let Err(e) = apply_a2ui_envelope(state, a2ui_envelope, None, None).await {
4609                    tracing::warn!(
4610                        surface_id = %report.surface_id,
4611                        strategy = %strategy_id,
4612                        patch_hash,
4613                        elapsed_ns,
4614                        error = %e,
4615                        "ui-agent patch apply failed",
4616                    );
4617                    // Apply failed → release the budget slot.
4618                    state.ui_agent_budget.refund(&report.surface_id);
4619                } else {
4620                    tracing::debug!(
4621                        surface_id = %report.surface_id,
4622                        strategy = %strategy_id,
4623                        patch_hash,
4624                        elapsed_ns,
4625                        iteration = state.ui_agent_budget.count(&report.surface_id),
4626                        "ui-agent patch applied",
4627                    );
4628                    // Memgine trace: one Conversation node per
4629                    // successful patch, tagged "ui-agent/<surface>".
4630                    // Spreading activation can surface these on
4631                    // future renders of related surfaces. Spawned
4632                    // off the render-report hot path — ingest walks
4633                    // the graph for spreading activation and can
4634                    // take real time; we don't want two surfaces
4635                    // churning patches to serialize through the
4636                    // memgine mutex behind the WS handler.
4637                    if let Some(memgine) = state.shared_memgine.clone() {
4638                        let speaker = format!("ui-agent/{}", report.surface_id);
4639                        let text = format!("strategy applied: {}", strategy_id);
4640                        tokio::spawn(async move {
4641                            let mut guard = memgine.lock().await;
4642                            guard.ingest_conversation(&speaker, &text, chrono::Utc::now());
4643                        });
4644                    }
4645                }
4646            }
4647            car_ui_agent::Decision::StableNoChange => {
4648                // No patch this round → release the slot.
4649                state.ui_agent_budget.refund(&report.surface_id);
4650            }
4651            car_ui_agent::Decision::HardStop { reason } => {
4652                state.ui_agent_budget.refund(&report.surface_id);
4653                // Renderer painted unknown components — contract
4654                // violation between server and renderer. Per
4655                // types.rs docs: "loud failure" + "MUST pause."
4656                // `error!` not `warn!` so it surfaces in production
4657                // logs at the right severity.
4658                tracing::error!(
4659                    surface_id = %report.surface_id,
4660                    reason = %reason,
4661                    "ui-agent hard-stopped improvement loop",
4662                );
4663            }
4664        }
4665    } else {
4666        tracing::debug!(
4667            surface_id = %report.surface_id,
4668            "ui-agent skipped — surface not found in store",
4669        );
4670    }
4671
4672    Ok(serde_json::json!({ "event": event }))
4673}
4674
4675async fn route_a2ui_action(
4676    state: &Arc<ServerState>,
4677    action: &car_a2ui::ClientAction,
4678    owner: Option<car_a2ui::A2uiSurfaceOwner>,
4679) -> Value {
4680    let Some(owner) = owner else {
4681        return serde_json::json!({ "delivered": false, "reason": "surface has no owner" });
4682    };
4683    if owner.kind != "a2a" {
4684        return serde_json::json!({ "delivered": false, "reason": "unsupported owner kind", "owner": owner });
4685    }
4686    let Some(endpoint) = owner.endpoint.clone() else {
4687        return serde_json::json!({
4688            "delivered": false,
4689            "reason": "surface owner has no endpoint",
4690            "owner": owner
4691        });
4692    };
4693
4694    let message = car_a2a::Message {
4695        message_id: format!("a2ui-action-{}", uuid::Uuid::new_v4().simple()),
4696        role: car_a2a::MessageRole::User,
4697        parts: vec![car_a2a::Part::Data(car_a2a::types::DataPart {
4698            data: serde_json::json!({
4699                "a2uiAction": action,
4700            }),
4701            metadata: Default::default(),
4702        })],
4703        task_id: owner.task_id.clone(),
4704        context_id: owner.context_id.clone(),
4705        metadata: Default::default(),
4706    };
4707
4708    let auth = state
4709        .a2ui_route_auth
4710        .lock()
4711        .await
4712        .get(&action.surface_id)
4713        .cloned()
4714        .map(client_auth_from_route_auth)
4715        .unwrap_or(car_a2a::ClientAuth::None);
4716
4717    match car_a2a::A2aClient::new(endpoint.clone())
4718        .with_auth(auth)
4719        .send_message(message, false)
4720        .await
4721    {
4722        Ok(result) => serde_json::json!({
4723            "delivered": true,
4724            "owner": owner,
4725            "endpoint": endpoint,
4726            "result": result,
4727        }),
4728        Err(error) => serde_json::json!({
4729            "delivered": false,
4730            "owner": owner,
4731            "endpoint": endpoint,
4732            "error": error.to_string(),
4733        }),
4734    }
4735}
4736
4737fn client_auth_from_route_auth(auth: A2aRouteAuth) -> car_a2a::ClientAuth {
4738    match auth {
4739        A2aRouteAuth::None => car_a2a::ClientAuth::None,
4740        A2aRouteAuth::Bearer { token } => car_a2a::ClientAuth::Bearer(token),
4741        A2aRouteAuth::Header { name, value } => car_a2a::ClientAuth::Header { name, value },
4742    }
4743}
4744
4745fn trusted_route_endpoint(endpoint: Option<String>, allow_untrusted: bool) -> Option<String> {
4746    let endpoint = endpoint?;
4747    if allow_untrusted || is_loopback_http_endpoint(&endpoint) {
4748        Some(endpoint)
4749    } else {
4750        None
4751    }
4752}
4753
4754fn is_loopback_http_endpoint(endpoint: &str) -> bool {
4755    endpoint == "http://localhost"
4756        || endpoint.starts_with("http://localhost:")
4757        || endpoint.starts_with("http://localhost/")
4758        || endpoint == "http://127.0.0.1"
4759        || endpoint.starts_with("http://127.0.0.1:")
4760        || endpoint.starts_with("http://127.0.0.1/")
4761        || endpoint == "http://[::1]"
4762        || endpoint.starts_with("http://[::1]:")
4763        || endpoint.starts_with("http://[::1]/")
4764}
4765
4766async fn handle_host_register_agent(
4767    req: &JsonRpcMessage,
4768    session: &crate::session::ClientSession,
4769) -> Result<Value, String> {
4770    let request: RegisterHostAgentRequest =
4771        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4772    serde_json::to_value(
4773        session
4774            .host
4775            .register_agent(&session.client_id, request)
4776            .await?,
4777    )
4778    .map_err(|e| e.to_string())
4779}
4780
4781async fn handle_host_unregister_agent(
4782    req: &JsonRpcMessage,
4783    session: &crate::session::ClientSession,
4784) -> Result<Value, String> {
4785    let agent_id = req
4786        .params
4787        .get("agent_id")
4788        .and_then(|v| v.as_str())
4789        .ok_or("missing agent_id")?;
4790    session
4791        .host
4792        .unregister_agent(&session.client_id, agent_id)
4793        .await?;
4794    Ok(serde_json::json!({"ok": true}))
4795}
4796
4797async fn handle_host_set_status(
4798    req: &JsonRpcMessage,
4799    session: &crate::session::ClientSession,
4800) -> Result<Value, String> {
4801    let request: SetHostAgentStatusRequest =
4802        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4803    serde_json::to_value(session.host.set_status(&session.client_id, request).await?)
4804        .map_err(|e| e.to_string())
4805}
4806
4807async fn handle_host_register_device(
4808    req: &JsonRpcMessage,
4809    session: &crate::session::ClientSession,
4810) -> Result<Value, String> {
4811    let request: RegisterHostDeviceRequest =
4812        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4813    serde_json::to_value(
4814        session
4815            .host
4816            .register_device(&session.client_id, request)
4817            .await?,
4818    )
4819    .map_err(|e| e.to_string())
4820}
4821
4822async fn handle_host_update_device(
4823    req: &JsonRpcMessage,
4824    session: &crate::session::ClientSession,
4825) -> Result<Value, String> {
4826    let request: UpdateHostDeviceRequest =
4827        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4828    serde_json::to_value(
4829        session
4830            .host
4831            .update_device(&session.client_id, request)
4832            .await?,
4833    )
4834    .map_err(|e| e.to_string())
4835}
4836
4837async fn handle_host_devices(session: &crate::session::ClientSession) -> Result<Value, String> {
4838    serde_json::to_value(session.host.devices().await).map_err(|e| e.to_string())
4839}
4840
4841async fn handle_mobile_runtime(state: &Arc<ServerState>) -> Result<Value, String> {
4842    let url = state
4843        .mobile_runtime_url
4844        .get()
4845        .cloned()
4846        .ok_or_else(|| "mobile Parslee Core connection is not configured".to_string())?;
4847    let token = state.auth_token.get().cloned();
4848    Ok(serde_json::json!({
4849        "name": "Parslee Core",
4850        "source": "car",
4851        "url": url,
4852        "token": token,
4853    }))
4854}
4855
4856async fn handle_host_notify(
4857    req: &JsonRpcMessage,
4858    session: &crate::session::ClientSession,
4859) -> Result<Value, String> {
4860    let kind = req
4861        .params
4862        .get("kind")
4863        .and_then(|v| v.as_str())
4864        .unwrap_or("host.notification");
4865    let agent_id = req
4866        .params
4867        .get("agent_id")
4868        .and_then(|v| v.as_str())
4869        .map(str::to_string);
4870    let message = req
4871        .params
4872        .get("message")
4873        .and_then(|v| v.as_str())
4874        .unwrap_or("");
4875    let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
4876    serde_json::to_value(
4877        session
4878            .host
4879            .record_event(kind, agent_id, message, payload)
4880            .await,
4881    )
4882    .map_err(|e| e.to_string())
4883}
4884
4885async fn handle_host_request_approval(
4886    req: &JsonRpcMessage,
4887    session: &crate::session::ClientSession,
4888) -> Result<Value, String> {
4889    let mut request: CreateHostApprovalRequest =
4890        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4891    // Stamp the requester from the session's AUTHENTICATED agent binding rather
4892    // than trusting the caller's claim — the same stance
4893    // `handle_permission_decision` takes for `reviewer` ("an audit log whose
4894    // 'who approved' is forgeable by the approver undercuts §5.2.5"). The
4895    // binding is not self-declared: `session.auth` validates `agent_id` against
4896    // the per-agent token the supervisor minted before setting it.
4897    //
4898    // Without this the row's `agent_id` was whatever the requester typed, so it
4899    // was neither an audit record nor usable as the requester half of the
4900    // requester-vs-resolver comparison in `car_server_types::host::Resolver`.
4901    request.agent_id = stamped_requester(
4902        authenticated_bound_agent_id(session).await,
4903        request.agent_id.take(),
4904    );
4905    if let Some(agent_id) = &request.agent_id {
4906        // Best-effort. If the caller doesn't own this agent the
4907        // ACL added 2026-05 will refuse the status update — that
4908        // is the correct semantics; we still want the approval row
4909        // itself to land so the UI can render the request.
4910        let _ = session
4911            .host
4912            .set_status(
4913                &session.client_id,
4914                SetHostAgentStatusRequest {
4915                    agent_id: agent_id.clone(),
4916                    status: HostAgentStatus::WaitingForApproval,
4917                    current_task: None,
4918                    message: Some("Waiting for approval".to_string()),
4919                    payload: Value::Null,
4920                },
4921            )
4922            .await;
4923    }
4924    // `system_level: true` opts the approval out of per-session
4925    // ownership. The host-side ACL then allows any authenticated
4926    // session (typically CarHost or `car-host approve`) to resolve.
4927    // Agents requesting user approval should always set this — the
4928    // session-owned mode is only correct when the requesting session
4929    // is also the resolving session, which approval-via-UI never is.
4930    let owner_client_id = if request.system_level {
4931        None
4932    } else {
4933        Some(session.client_id.as_str())
4934    };
4935    serde_json::to_value(
4936        session
4937            .host
4938            .create_approval(owner_client_id, request)
4939            .await?,
4940    )
4941    .map_err(|e| e.to_string())
4942}
4943
4944async fn handle_host_resolve_approval(
4945    req: &JsonRpcMessage,
4946    session: &crate::session::ClientSession,
4947) -> Result<Value, String> {
4948    let request: ResolveHostApprovalRequest =
4949        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4950    // The agent identity only counts when the session is authenticated AND
4951    // bound to one — an unauthenticated claim must not launder itself into an
4952    // identity the self-resolution check would then trust.
4953    let bound_agent = authenticated_bound_agent_id(session).await;
4954    let resolver =
4955        car_server_types::host::Resolver::agent_session(&session.client_id, bound_agent.as_deref());
4956    serde_json::to_value(session.host.resolve_approval(resolver, request).await?)
4957        .map_err(|e| e.to_string())
4958}
4959
4960/// Method-not-found-class denial for a method absent from a supervised-agent
4961/// token's scope. The stable message prefix is the named authorization error;
4962/// callers can distinguish it from the ordinary `unknown method: ...` response
4963/// while receiving the same non-disclosure-oriented JSON-RPC class.
4964const AGENT_METHOD_NOT_ALLOWED_ERROR_CODE: i32 = -32601;
4965const AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX: &str =
4966    "agent_method_not_allowed: supervised agent token does not allow daemon method ";
4967
4968/// Transport-establishment calls are necessarily implicit: the daemon cannot
4969/// know a token's scope until `session.auth` succeeds, and a client must be able
4970/// to negotiate the protocol before invoking its first scoped application
4971/// method. Every other request/notification is checked at the dispatch boundary.
4972fn agent_method_scope_allows(session: &crate::session::ClientSession, method: &str) -> bool {
4973    if matches!(method, "session.auth" | "server.handshake")
4974        || session.is_host.load(std::sync::atomic::Ordering::Acquire)
4975    {
4976        return true;
4977    }
4978    match session.agent_method_allowlist.read() {
4979        Ok(scope) => scope
4980            .as_ref()
4981            .is_none_or(|methods| methods.contains(method)),
4982        // Poisoning means a prior scope write panicked. Failing closed is the
4983        // only safe interpretation for a bound credential.
4984        Err(_) => false,
4985    }
4986}
4987
4988/// `session.auth` — present the per-launch token to unlock the
4989/// connection. When `state.auth_token` is unset, this method is a
4990/// no-op success (auth is disabled). When set, the supplied token
4991/// must equal it (constant-time comparison) — a successful auth
4992/// flips `session.authenticated` to `true` so subsequent methods
4993/// pass the gate. Wrong token returns an error AND leaves the
4994/// session unauthenticated; the dispatcher loop's gate then closes
4995/// the connection on the next non-auth method.
4996///
4997/// Closes Parslee-ai/car-releases#32.
4998async fn handle_session_auth(
4999    req: &JsonRpcMessage,
5000    session: &crate::session::ClientSession,
5001    state: &Arc<ServerState>,
5002) -> Result<Value, String> {
5003    // C-4: optional `tenant_id` binds the connection to one tenant
5004    // namespace. Parsed + validated up front, bound only on auth
5005    // success. Once bound, tenant-scoped handlers use this identity
5006    // and reject a conflicting per-request `tenant_id`.
5007    let tenant_binding = match req.params.get("tenant_id").and_then(Value::as_str) {
5008        Some(t) if !t.is_empty() => {
5009            validate_tenant_id(t)?;
5010            Some(t.to_string())
5011        }
5012        _ => None,
5013    };
5014
5015    // car-releases#79: an optional host-declared memory namespace. A SEPARATE
5016    // axis from `agent_id` — one agent may span namespaces, and two hosts may
5017    // share a namespace without sharing an identity — so it binds its own
5018    // engine from its own registry. Absent, the session keeps the daemon's
5019    // shared graph, which is what makes MCP-ingested facts visible over WS.
5020    //
5021    // Read BEFORE the host-token branch: that branch returns early, and a host
5022    // client is exactly the caller that wants per-project memory.
5023    let memory_namespace = req
5024        .params
5025        .get("memory_namespace")
5026        .and_then(Value::as_str)
5027        .filter(|s| !s.trim().is_empty())
5028        .map(str::to_string);
5029
5030    // #254: optional `host_token` elevates the connection to the
5031    // host-management role. It is a *distinct* credential from the
5032    // daemon `token` — only readable from the `0600` host-token file,
5033    // never served over `GET /auth-token` — so a generic authenticated
5034    // client (or another local user who scraped the auth token via the
5035    // HTTP endpoint) cannot self-elevate to host and read other agents'
5036    // run traces. Validated first, before the `token` requirement,
5037    // because presenting a valid host token both authenticates and
5038    // elevates the session (a host client sends only `host_token`).
5039    if let Some(host_supplied) = req.params.get("host_token").and_then(Value::as_str) {
5040        let expected = state.host_token.get().ok_or_else(|| {
5041            "host auth unavailable: this daemon has no host token (started with --no-auth?)"
5042                .to_string()
5043        })?;
5044        if !constant_time_eq(host_supplied.as_bytes(), expected.as_bytes()) {
5045            return Err("auth failed: host token mismatch".to_string());
5046        }
5047        session
5048            .authenticated
5049            .store(true, std::sync::atomic::Ordering::Release);
5050        session
5051            .is_host
5052            .store(true, std::sync::atomic::Ordering::Release);
5053        // A host just appeared. Supervised agent processes cache this answer
5054        // — they have no read of the daemon's session set — and it decides
5055        // whether `browser_await_signin` points at the drawer or tells the
5056        // user to open the CAR app. Push the transition rather than leaving
5057        // them to find out at their next registration.
5058        state.browser_views.broadcast_host_connected(true).await;
5059        *session.tenant.lock().await = tenant_binding;
5060        if let Some(ns) = memory_namespace.as_deref() {
5061            bind_memory_namespace(state, session, ns).await?;
5062        }
5063        return Ok(serde_json::json!({
5064            "ok": true,
5065            "auth_enabled": true,
5066            "role": "host",
5067            "memory_namespace": memory_namespace,
5068        }));
5069    }
5070
5071    let supplied = req
5072        .params
5073        .get("token")
5074        .and_then(Value::as_str)
5075        .ok_or_else(|| "session.auth requires { token: string }".to_string())?;
5076    // #169: optional `agent_id` binds the WS connection to a
5077    // supervised lifecycle agent. When present, the supplied token
5078    // must equal the per-agent token the supervisor minted at upsert
5079    // (NOT the daemon-wide auth token). When absent, fall back to
5080    // the daemon-wide token — preserves the legacy unbound-token
5081    // path for browser/host/CLI clients.
5082    let agent_id = req
5083        .params
5084        .get("agent_id")
5085        .and_then(Value::as_str)
5086        .map(str::to_string);
5087
5088    if let Some(id) = agent_id {
5089        let supervisor = state.supervisor()?;
5090        let binding = supervisor
5091            .authenticate_agent_token(&id, supplied)
5092            .await
5093            .map_err(|error| format!("auth failed for agent_id `{id}`: {error}"))?
5094            .ok_or_else(|| {
5095                format!("auth failed: agent_id `{id}` is not supervised, or token mismatch")
5096            })?;
5097        // Bind the token's method scope before publishing the agent identity.
5098        // On an auth-enabled daemon the read-loop auth gate also serializes
5099        // this handshake; the ordering closes the dominant auth-disabled
5100        // pipelining window in the same way the identity-first ordering below
5101        // closes the sandbox-bind window.
5102        *session
5103            .agent_method_allowlist
5104            .write()
5105            .map_err(|_| "auth failed: agent method allowlist lock poisoned".to_string())? =
5106            binding
5107                .method_allowlist
5108                .clone()
5109                .map(|methods| methods.into_iter().collect());
5110        // Commit the bound agent identity FIRST, before the attached-agents
5111        // lock and the (disk-I/O) memgine load below (Parslee-ai/car#480 review).
5112        // Frames on one connection are dispatched concurrently, so on an
5113        // auth-DISABLED daemon a pipelined `session.bindSandbox` could otherwise
5114        // race this handler and observe `agent_id == None` during that I/O
5115        // window, escaping its supervised-agent gate. (On an auth-token daemon —
5116        // the deployment that cares about confinement — the read-loop auth gate
5117        // already serializes the handshake, so the gate is sound by
5118        // construction; this ordering closes the dominant auth-disabled window
5119        // too.)
5120        *session.agent_id.lock().await = Some(id.clone());
5121        // Single-claim: only one connection at a time per
5122        // agent_id. A second claim is rejected so the daemon-side
5123        // per-agent state stays unambiguous.
5124        {
5125            let mut attached = state.attached_agents.lock().await;
5126            if let Some(prior) = attached.get(&id) {
5127                if prior != &session.client_id {
5128                    // Supersede a stale claim rather than reject it. The
5129                    // supervisor runs a single instance per `agent_id`, and this
5130                    // connection just passed `validate_agent_token`, so a fresh
5131                    // valid attach is authoritative — it's the live process.
5132                    //
5133                    // Why this matters (Parslee-ai/car Windows bring-up): when a
5134                    // supervised agent is hard-killed and respawned, the dead
5135                    // process's socket can linger half-open (no FIN on a
5136                    // TerminateProcess), so the disconnect teardown that frees
5137                    // this claim (session.rs) hasn't run yet. The old
5138                    // reject-on-conflict then failed EVERY respawn with
5139                    // "already attached", and the agent flapped forever
5140                    // (running→errored→backoff), so no chat turn could route.
5141                    // Last-valid-attach-wins is exactly what the teardown code's
5142                    // own comment intends ("supervisor-respawned replacement can
5143                    // take the slot"); this makes it robust to delayed teardown.
5144                    tracing::warn!(
5145                        agent_id = %id,
5146                        prior_client = %prior,
5147                        new_client = %session.client_id,
5148                        "superseding stale agent attach claim (respawn took over)"
5149                    );
5150                }
5151            }
5152            attached.insert(id.clone(), session.client_id.clone());
5153        }
5154        // #170: attach the daemon-owned persistent memgine for
5155        // this agent. Lazy-loaded on first connection per id from
5156        // `~/.car/memory/agents/<id>.jsonl`; retained across
5157        // disconnect so the next session sees the same state.
5158        let agent_eng = get_or_load_agent_memgine(state, &id).await?;
5159        // An explicit namespace wins for the memory scope: it is the more
5160        // specific request, and it is what a single agent working across two
5161        // projects needs. The agent binding still governs identity, tokens and
5162        // the attach claim above — only the graph differs.
5163        let bound = match memory_namespace.as_deref() {
5164            Some(ns) => {
5165                // Record the namespace here too — this path binds the graph
5166                // directly rather than through bind_memory_namespace, because
5167                // it falls back to the agent engine when no namespace is given.
5168                *session.memory_namespace.lock().await = Some(ns.to_string());
5169                get_or_load_namespace_memgine(state, ns).await?
5170            }
5171            None => agent_eng,
5172        };
5173        *session.bound_memgine.lock().await = Some(bound);
5174        session
5175            .authenticated
5176            .store(true, std::sync::atomic::Ordering::Release);
5177        *session.tenant.lock().await = tenant_binding;
5178        let mut response = serde_json::json!({
5179            "ok": true,
5180            "auth_enabled": true,
5181            "agent_id": id,
5182        });
5183        if let Some(methods) = binding.method_allowlist {
5184            response["method_allowlist"] = serde_json::json!(methods);
5185        }
5186        return Ok(response);
5187    }
5188
5189    let expected = match state.auth_token.get() {
5190        Some(t) => t,
5191        None => {
5192            // Auth disabled — accept any token politely so callers
5193            // that always include a session.auth handshake (e.g. the
5194            // FFI proxy) don't fail when the daemon happens to be
5195            // unauthed. Mark the session authenticated anyway so the
5196            // gate is a no-op below.
5197            if let Some(ns) = memory_namespace.as_deref() {
5198                bind_memory_namespace(state, session, ns).await?;
5199            }
5200            session
5201                .authenticated
5202                .store(true, std::sync::atomic::Ordering::Release);
5203            *session.tenant.lock().await = tenant_binding;
5204            return Ok(serde_json::json!({
5205                "ok": true,
5206                "auth_enabled": false,
5207                "memory_namespace": memory_namespace,
5208            }));
5209        }
5210    };
5211    if !constant_time_eq(supplied.as_bytes(), expected.as_bytes()) {
5212        return Err("auth failed: token mismatch".to_string());
5213    }
5214    if let Some(ns) = memory_namespace.as_deref() {
5215        bind_memory_namespace(state, session, ns).await?;
5216    }
5217    session
5218        .authenticated
5219        .store(true, std::sync::atomic::Ordering::Release);
5220    *session.tenant.lock().await = tenant_binding;
5221    Ok(serde_json::json!({
5222        "ok": true,
5223        "auth_enabled": true,
5224        "memory_namespace": memory_namespace,
5225        "parslee": state.parslee_session.get().map(|session| session.identity.clone()),
5226    }))
5227}
5228
5229/// Length-checked constant-time byte comparison. Returns false when
5230/// lengths differ (so length itself is the only timing leak — fine
5231/// for our 43-char fixed-length tokens).
5232fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5233    if a.len() != b.len() {
5234        return false;
5235    }
5236    let mut diff: u8 = 0;
5237    for (x, y) in a.iter().zip(b.iter()) {
5238        diff |= x ^ y;
5239    }
5240    diff == 0
5241}
5242
5243/// Block dispatch of `method` until the user resolves the approval
5244/// raised on [`HostState`].
5245///
5246/// Called from the dispatcher loop only when
5247/// [`crate::session::ApprovalGate::requires_approval`] returns true.
5248/// `Ok(())` means the user picked "approve"; `Err(reason)` is sent
5249/// to the caller as JSON-RPC error code `-32003` with the supplied
5250/// reason. On timeout, the approval row stays in `Pending` so the
5251/// UI keeps a record of the unanswered request.
5252async fn gate_high_risk_method(
5253    method: &str,
5254    params: &Value,
5255    state: &Arc<ServerState>,
5256) -> Result<(), String> {
5257    let timeout = state.approval_gate.timeout;
5258    let req = CreateHostApprovalRequest {
5259        agent_id: None,
5260        action: format!("ws.method:{method}"),
5261        details: serde_json::json!({
5262            "method": method,
5263            // Truncate params for the UI — full payload is recoverable
5264            // via the request-time host event log if needed. The cap
5265            // keeps a malicious caller from drowning the UI in JSON.
5266            "params_preview": preview_params(params, 2_000),
5267        }),
5268        options: vec!["approve".to_string(), "deny".to_string()],
5269        // The high-risk-method gate is already system-level (it
5270        // passes None as the owner via request_and_wait_approval's
5271        // internal call). This field is informational here.
5272        system_level: true,
5273    };
5274    match state
5275        .host
5276        .request_and_wait_approval(req, "approve", timeout)
5277        .await
5278    {
5279        Ok(crate::host::ApprovalOutcome::Approved) => Ok(()),
5280        Ok(crate::host::ApprovalOutcome::Denied) => Err(format!(
5281            "{method} denied by user (approval gate, audit 2026-05). \
5282             To call this method without an interactive prompt, start \
5283             car-server with --no-approvals on a trusted machine."
5284        )),
5285        Ok(crate::host::ApprovalOutcome::TimedOut) => Err(format!(
5286            "{method} approval timed out after {}s with no resolution. \
5287             The approval is still visible in `host.approvals` for \
5288             forensics; resubmit the request to retry.",
5289            timeout.as_secs()
5290        )),
5291        Err(e) => Err(format!("approval gate error: {e}")),
5292    }
5293}
5294
5295fn preview_params(value: &Value, max_chars: usize) -> Value {
5296    let s = value.to_string();
5297    if s.len() <= max_chars {
5298        value.clone()
5299    } else {
5300        Value::String(format!("{}… (truncated)", &s[..max_chars]))
5301    }
5302}
5303
5304async fn handle_session_init(
5305    req: &JsonRpcMessage,
5306    session: &crate::session::ClientSession,
5307) -> Result<Value, String> {
5308    let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5309    let init: SessionInitRequest =
5310        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5311
5312    for tool in &init.tools {
5313        register_callback_definition(session, tool).await?;
5314    }
5315
5316    let mut policy_count = 0;
5317    {
5318        let mut policies = session.runtime.policies.write().await;
5319        for policy_def in &init.policies {
5320            if let Some(check) = build_policy_check(policy_def) {
5321                match blanket_denied_tool(policy_def) {
5322                    Some(tool) => policies.register_tool_deny(&policy_def.name, &tool, check, ""),
5323                    None => policies.register(&policy_def.name, check, ""),
5324                }
5325                policy_count += 1;
5326            }
5327        }
5328    }
5329
5330    serde_json::to_value(SessionInitResponse {
5331        session_id: session.client_id.clone(),
5332        tools_registered: init.tools.len(),
5333        policies_registered: policy_count,
5334    })
5335    .map_err(|e| e.to_string())
5336}
5337
5338/// Clear the fail-stop latch on this exact WebSocket session.
5339///
5340/// The latch belongs to the connection, so only a host-authenticated client
5341/// on that same connection can clear it. Ordinary clients recover by
5342/// reconnecting, which creates a fresh session as required by the non-durable
5343/// contract.
5344async fn handle_session_clear_halt(
5345    session: &crate::session::ClientSession,
5346) -> Result<Value, String> {
5347    if !session.is_host.load(Ordering::Acquire) {
5348        return Err("session.clear_halt requires the host-management role".to_string());
5349    }
5350    let cleared = session.halted.swap(false, Ordering::AcqRel);
5351    Ok(serde_json::json!({ "cleared": cleared, "halted": false }))
5352}
5353
5354/// `session.bindSubstrate { connector: "<slug>" }` — bind THIS session's
5355/// runtime to the execution substrate of an already-connected MCP
5356/// connector (`docs/execution-substrate.md` phase 3). After binding, the
5357/// session's commodity built-ins (`read_file`/`write_file`/`edit_file`/
5358/// `list_dir`/`find_files`/`grep_files`) execute on the connector's
5359/// environment instead of the WS client/host, so they co-locate with the
5360/// session's `mcp_{slug}_*` tools.
5361///
5362/// Opt-in and per-session: a session that never calls this keeps the
5363/// historic host/client composition. Returns
5364/// `{ "bound": true, "substrate": "<name>" }` on success.
5365async fn handle_session_bind_substrate(
5366    req: &JsonRpcMessage,
5367    session: &Arc<crate::session::ClientSession>,
5368    state: &Arc<ServerState>,
5369) -> Result<Value, String> {
5370    let connector = req
5371        .params
5372        .get("connector")
5373        .and_then(|v| v.as_str())
5374        .ok_or_else(|| "missing `connector` (MCP connector slug)".to_string())?;
5375
5376    let name = state
5377        .bind_substrate_to_connector(session, connector)
5378        .await?;
5379    Ok(serde_json::json!({ "bound": true, "substrate": name }))
5380}
5381
5382/// `session.bindSandbox { working_dir, image?, network?, memory?, cpus?,
5383/// pids_limit?, persistent?, command_timeout_secs? }` — bind this session's
5384/// runtime to a Docker-sandboxed execution environment (D1). The session's
5385/// commodity built-ins (shell / read_file / write_file / list_dir /
5386/// grep_files) then run INSIDE a hardened container mounted on
5387/// `working_dir` (default-secure: no network unless `network` is set,
5388/// memory/pids/cpu caps, dropped capabilities — D2/D3).
5389///
5390/// Preflights Docker + the image FIRST and returns the preflight's
5391/// actionable error when the host can't run containers (no daemon, image
5392/// unpullable) — an explicit, typed refusal, never a silent fallback to
5393/// unsandboxed local execution (D3: substituting the host for the sandbox
5394/// behind the caller's back would be a security downgrade).
5395async fn handle_session_bind_sandbox(
5396    req: &JsonRpcMessage,
5397    session: &Arc<crate::session::ClientSession>,
5398    state: &Arc<ServerState>,
5399) -> Result<Value, String> {
5400    // `session.bindSandbox` mounts an arbitrary host `working_dir` read-write
5401    // into a container the caller drives via shell. Bound agents are denied
5402    // (Parslee-ai/car#480); host/CLI clients are unaffected.
5403    //
5404    // **Advisory, not a confinement boundary — do not build authorization on
5405    // it.** This used to say that letting a supervised agent bind-mount any
5406    // host dir "would turn the daemon into a confinement escape". It would not.
5407    // A supervised agent runs as the launching user with no sandbox, so it can
5408    // already read `~/.ssh` directly; the isolation car-pr-review has is
5409    // app-level (CAR declines to hand it credentials), not an OS boundary. And
5410    // the check is escapable on its own terms: an agent controls its own
5411    // environment, so it can unset `CAR_AGENT_ID` and reconnect unbound
5412    // (car#1297).
5413    //
5414    // Kept anyway, because it costs nothing and makes the confined path the
5415    // easy one. What it must not do is imply a guarantee it cannot make. The
5416    // per-gate audit is in docs/proposals/agent-binding-trust-boundary.md;
5417    // `host_token` is what a real boundary looks like in this codebase.
5418    if let Some(agent_id) = session.agent_id.lock().await.clone() {
5419        return Err(format!(
5420            "session.bindSandbox is not available to supervised agents \
5421             (agent '{agent_id}'): it would let a confined agent bind-mount any \
5422             host directory into a container it controls. See Parslee-ai/car#480."
5423        ));
5424    }
5425
5426    let working_dir = req
5427        .params
5428        .get("working_dir")
5429        .and_then(|v| v.as_str())
5430        .ok_or_else(|| "missing `working_dir`".to_string())?;
5431    let wd = std::path::Path::new(working_dir);
5432    if !wd.is_dir() {
5433        return Err(format!("working_dir '{working_dir}' is not a directory"));
5434    }
5435
5436    let mut config = car_sandbox::SandboxConfig {
5437        working_dir: wd.to_path_buf(),
5438        ..Default::default()
5439    };
5440    if let Some(image) = opt_str(&req.params, "image") {
5441        config.image = image.to_string();
5442    }
5443    // Egress stays OFF unless explicitly requested (D2).
5444    if let Some(net) = opt_str(&req.params, "network") {
5445        config.network = Some(net.to_string());
5446    }
5447    if let Some(mem) = opt_str(&req.params, "memory") {
5448        config.memory = Some(mem.to_string());
5449    }
5450    if let Some(cpus) = opt_str(&req.params, "cpus") {
5451        config.cpus = Some(cpus.to_string());
5452    }
5453    if let Some(pids) = req.params.get("pids_limit").and_then(|v| v.as_u64()) {
5454        config.pids_limit = Some(pids);
5455    }
5456    // NOTE: no `persistent` param — SandboxConfig::persistent is not yet
5457    // wired in the executor (the container is always cached per executor),
5458    // and advertising a no-op knob is worse than not offering it (review Q1).
5459    if let Some(t) = req
5460        .params
5461        .get("command_timeout_secs")
5462        .and_then(|v| v.as_u64())
5463    {
5464        if t == 0 {
5465            return Err("command_timeout_secs must be >= 1".to_string());
5466        }
5467        config.command_timeout_secs = t;
5468    }
5469
5470    // D3: preflight before binding — a host without Docker gets the
5471    // preflight's own actionable message (install/start Docker, pull the
5472    // image), not a broken half-bound session.
5473    let pf = car_sandbox::preflight(&config.image).await;
5474    if !pf.is_ok() {
5475        return Err(format!("sandbox preflight failed: {}", pf.message()));
5476    }
5477
5478    let executor = std::sync::Arc::new(car_sandbox::SandboxExecutor::new(config));
5479    let substrate: std::sync::Arc<dyn car_engine::Substrate> = executor;
5480    let name = substrate.name().to_string();
5481
5482    // Same three-step swap as bind_substrate_to_connector (see its note on
5483    // why the non-atomic swap is benign: a WS connection handles its
5484    // JSON-RPC sequentially and bind is a control call issued before tool
5485    // work): bind the substrate, re-register the substrate-backed
5486    // built-ins, then shadow the substrate-owned tool names so they fall
5487    // through to the sandbox while everything else keeps the WS callback
5488    // path.
5489    session.runtime.set_substrate(substrate).await;
5490    session.runtime.register_agent_basics().await;
5491    // Compose EXACTLY like bind_substrate_to_connector: connector
5492    // (mcp_{slug}_*) routes first, then the WS client callback, with the
5493    // substrate-owned built-in names shadowed to the sandbox. Wrapping the
5494    // bare WS executor here severed every connector tool on the session —
5495    // still registered and advertised to the model, but dispatched to a WS
5496    // client that has never heard of them (review C1).
5497    let ws_executor = std::sync::Arc::new(crate::session::WsToolExecutor::new(
5498        session.channel.clone(),
5499        session.negotiated_capabilities.clone(),
5500        session.halted.clone(),
5501    ));
5502    let composed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5503        std::sync::Arc::new(state.mcp_executor.share_with_fallback(ws_executor));
5504    let shadowed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5505        std::sync::Arc::new(crate::session::SubstrateShadowExecutor::new(composed));
5506    session.runtime.set_executor(shadowed).await;
5507
5508    Ok(serde_json::json!({ "bound": true, "substrate": name }))
5509}
5510
5511/// The tool a policy definition forbids **outright**, if it forbids one.
5512///
5513/// `deny_tool` is the only wire rule kind whose totality is decidable from the
5514/// kind alone, which is the same line `PolicyEngine::blanket_denied_tools`
5515/// draws. `deny_connector` is also a blanket refusal but of an `mcp_{slug}_*`
5516/// PREFIX rather than a named tool, which that surface cannot express. It stays
5517/// enforcement-only, and nothing is lost by that: connector rules live on the
5518/// daemon's session runtime, which never assembles a model-facing tool list
5519/// (the model is in the client there), and disabling a connector already calls
5520/// `Runtime::unregister_tool` on each of its tools — removing them from the
5521/// registry outright, which is strictly stronger than declining to advertise
5522/// them. A prefix read-back here would have no caller.
5523fn blanket_denied_tool(def: &PolicyDefinition) -> Option<String> {
5524    (def.rule.as_str() == "deny_tool").then(|| def.target.clone())
5525}
5526
5527fn build_policy_check(def: &PolicyDefinition) -> Option<car_policy::PolicyCheck> {
5528    match def.rule.as_str() {
5529        "deny_tool" => {
5530            let target = def.target.clone();
5531            Some(Box::new(
5532                move |action: &car_ir::Action, _: &car_state::StateStore| {
5533                    if action.tool.as_deref() == Some(&target) {
5534                        Some(format!("tool '{}' denied", target))
5535                    } else {
5536                        None
5537                    }
5538                },
5539            ))
5540        }
5541        // Deny every tool from a remote MCP connector by slug. Connector
5542        // tools are named `mcp_{slug}_{tool}` (car-connectors), so a
5543        // single rule governs the whole connector — the policy-level
5544        // counterpart to per-tool enablement.
5545        "deny_connector" => {
5546            let slug = def.target.clone();
5547            let prefix = format!("mcp_{slug}_");
5548            Some(Box::new(
5549                move |action: &car_ir::Action, _: &car_state::StateStore| match action
5550                    .tool
5551                    .as_deref()
5552                {
5553                    Some(tool) if tool.starts_with(&prefix) => {
5554                        Some(format!("connector '{}' denied", slug))
5555                    }
5556                    _ => None,
5557                },
5558            ))
5559        }
5560        "require_state" => {
5561            let key = def.key.clone();
5562            let value = def.value.clone();
5563            Some(Box::new(
5564                move |_: &car_ir::Action, state: &car_state::StateStore| {
5565                    if state.get(&key).as_ref() != Some(&value) {
5566                        Some(format!("state['{}'] must be {:?}", key, value))
5567                    } else {
5568                        None
5569                    }
5570                },
5571            ))
5572        }
5573        "deny_tool_param" => {
5574            let target = def.target.clone();
5575            let param = def.key.clone();
5576            let pattern = def.pattern.clone();
5577            Some(Box::new(
5578                move |action: &car_ir::Action, _: &car_state::StateStore| {
5579                    if action.tool.as_deref() != Some(&target) {
5580                        return None;
5581                    }
5582                    if let Some(val) = action.parameters.get(&param) {
5583                        let s = val.as_str().unwrap_or(&val.to_string()).to_string();
5584                        if s.contains(&pattern) {
5585                            return Some(format!("param '{}' matches '{}'", param, pattern));
5586                        }
5587                    }
5588                    None
5589                },
5590            ))
5591        }
5592        _ => None,
5593    }
5594}
5595
5596async fn handle_tools_register(
5597    req: &JsonRpcMessage,
5598    session: &crate::session::ClientSession,
5599) -> Result<Value, String> {
5600    let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5601    let tools: Vec<ToolDefinition> =
5602        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5603    for tool in &tools {
5604        register_callback_definition(session, tool).await?;
5605    }
5606    Ok(Value::from(tools.len()))
5607}
5608
5609/// Bridge a wire-protocol `ToolDefinition` to the engine's
5610/// schema-aware registration. Carries the caller-settable ToolSchema fields
5611/// (description, parameters, returns, idempotency, caching, rate limit) through
5612/// to the validator, while assigning `source = user_defined` server-side. An
5613/// empty `parameters` object is
5614/// the legacy schemaless registration — the validator no-ops for
5615/// those, so pre-v0.5.x callers see no change.
5616fn callback_standing_authority_eligible(def: &ToolDefinition) -> bool {
5617    // Standing authority is intentionally limited to the one callback CAR
5618    // currently needs for Daily Continuity. A denylist is unsafe here: an
5619    // equivalent generic executor can always be renamed (for example `curl`
5620    // or `fetch`) or hide its authority behind a new parameter name.
5621    if def.name != "newsroom.publish"
5622        || def.idempotent
5623        || def.cache_ttl_secs.is_some()
5624        || def.rate_limit.is_some()
5625    {
5626        return false;
5627    }
5628    def.parameters
5629        == serde_json::json!({
5630            "type": "object",
5631            "properties": {
5632                "edition_id": {
5633                    "type": "string",
5634                    "minLength": 1
5635                }
5636            },
5637            "required": ["edition_id"],
5638            "additionalProperties": false
5639        })
5640}
5641
5642async fn register_callback_definition(
5643    session: &crate::session::ClientSession,
5644    def: &ToolDefinition,
5645) -> Result<(), String> {
5646    if session.runtime.registry.get(&def.name).await.is_some() {
5647        return Err(format!(
5648            "tool '{}' is server-owned and cannot be shadowed by tools.register",
5649            def.name
5650        ));
5651    }
5652    let digest = car_proto::canonical_sha256(def)?;
5653    {
5654        let mut callbacks = session.callback_tool_schema_digests.write().await;
5655        if callback_standing_authority_eligible(def) {
5656            callbacks.insert(def.name.clone(), digest);
5657        } else {
5658            callbacks.remove(&def.name);
5659        }
5660    }
5661    session
5662        .runtime
5663        .register_tool_schema(car_ir::ToolSchema {
5664            name: def.name.clone(),
5665            source: car_ir::ToolSourceKind::UserDefined,
5666            description: def.description.clone(),
5667            parameters: def.parameters.clone(),
5668            returns: def.returns.clone(),
5669            idempotent: def.idempotent,
5670            cache_ttl_secs: def.cache_ttl_secs,
5671            rate_limit: def.rate_limit.as_ref().map(|rl| car_ir::ToolRateLimit {
5672                max_calls: rl.max_calls,
5673                interval_secs: rl.interval_secs,
5674            }),
5675        })
5676        .await;
5677    Ok(())
5678}
5679
5680/// `tools.list` — the toolset actually in effect on this connection. No params.
5681///
5682/// Counterpart to `tools.register`, which had none: a client could add tools to
5683/// its session runtime but had no way to ask what was registered, so a governed
5684/// or read-only deployment could not prove "only X and Y are callable here"
5685/// (Parslee-ai/car#892). The enumerate already existed on the engine
5686/// (`Runtime::tool_schemas`) — it was simply never exposed to daemon clients.
5687/// Exactly the gap `policy.list` closed for `policy.register` in
5688/// Parslee-ai/car#623; this is the other half of the same pair.
5689///
5690/// Returns `{ tools: [ToolSchema, ...], count }`. Each entry is the **full**
5691/// schema — `name`, runtime-assigned `source`, `description`, `parameters`,
5692/// and (when set) `returns`, `idempotent`, `cache_ttl_secs`, `rate_limit` — not
5693/// just the name, because
5694/// what a tool accepts is part of the effective surface being proven. Optional
5695/// fields are omitted when unset, per `car_ir::ToolSchema`'s own serialization.
5696///
5697/// **The array is sorted by tool name.** The underlying store is a `HashMap`,
5698/// so unsorted output would reorder between two calls that registered nothing
5699/// in between — an audit surface whose order changes on its own cannot be
5700/// diffed and is therefore not usable as proof.
5701///
5702/// Scope is the connection: every WebSocket client gets its own
5703/// `car_engine::Runtime`, so this reports what is in force on *this* session,
5704/// not a daemon-wide set. It is also not the assistant's set — `car do` /
5705/// `agents.chat` build their own `Runtime`, so a client that lists here and
5706/// then drives the assistant is not looking at the toolset that will execute
5707/// there.
5708///
5709/// Note that "in force" is a superset of "what the client registered", which is
5710/// most of why enumerating is worth doing. A fresh session already carries the
5711/// `messaging.send` built-in — `create_session` attaches an outbound message
5712/// sink, and `Runtime::with_message_sink` registers the matching schema in the
5713/// same step so the model is never shown a tool the runtime cannot execute. The
5714/// commodity stdlib is *not* included until the session asks for it; both
5715/// `session.bindSubstrate` and `session.bindSandbox` call
5716/// `register_agent_basics`, so either one adds it.
5717async fn handle_tools_list(session: &crate::session::ClientSession) -> Result<Value, String> {
5718    let mut schemas = session.runtime.tool_schemas().await;
5719    schemas.sort_by(|a, b| a.name.cmp(&b.name));
5720    let count = schemas.len();
5721    let tools = schemas
5722        .iter()
5723        .map(serde_json::to_value)
5724        .collect::<std::result::Result<Vec<Value>, _>>()
5725        .map_err(|e| format!("serialize tool schema: {e}"))?;
5726    Ok(serde_json::json!({ "tools": tools, "count": count }))
5727}
5728
5729/// `tools.unregister` — remove one tool from this session's runtime.
5730/// `{ name }`.
5731///
5732/// The removal half of Parslee-ai/car#892: with only `tools.register` on the
5733/// wire, a tool added to a session could not be taken back for the life of the
5734/// connection, so narrowing an over-broad registration meant reconnecting and
5735/// rebuilding the session's state. `Runtime::unregister_tool` already dropped
5736/// the tool from **both** the canonical registry and the legacy schema map —
5737/// the model stops seeing it and the validator stops accepting it — but nothing
5738/// called it from the dispatcher.
5739///
5740/// Returns `{ unregistered, removed }`, where `removed` is `1` if the tool was
5741/// present and `0` if it was not. A `0` is reported rather than raised, so a
5742/// client cleaning up can call this unconditionally without first listing —
5743/// the same contract `policy.unregister` settled on (Parslee-ai/car#623).
5744async fn handle_tools_unregister(
5745    req: &JsonRpcMessage,
5746    session: &crate::session::ClientSession,
5747) -> Result<Value, String> {
5748    let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5749    let name = req
5750        .params
5751        .get("name")
5752        .and_then(|v| v.as_str())
5753        .ok_or("missing 'name'")?;
5754    let removed = session.runtime.unregister_tool(name).await;
5755    session
5756        .callback_tool_schema_digests
5757        .write()
5758        .await
5759        .remove(name);
5760    Ok(serde_json::json!({
5761        "unregistered": name,
5762        "removed": u32::from(removed),
5763    }))
5764}
5765
5766// ---- Detached tool dispatch (streaming/long-running, EPIC C / C2) ----
5767//
5768// A ToolCall action with `invocation_mode: "streaming" | "long_running"`
5769// is *started*, not awaited: its output is `{tool_handle, status:
5770// "running"}` and the DAG proceeds. These methods drive the handle
5771// against the session runtime's `ToolHandleRegistry`.
5772
5773/// `tools.poll { handle }` — drain the chunks buffered since the last
5774/// poll plus the invocation's current status (and, once terminal, its
5775/// final result/error). Returns `null` for an unknown or already
5776/// fully-consumed handle — absence, not an error (a terminal handle is
5777/// removed after its final state has been observed once with an empty
5778/// buffer, per the `car_engine::tool_handles` contract).
5779async fn handle_tools_poll(
5780    req: &JsonRpcMessage,
5781    session: &crate::session::ClientSession,
5782) -> Result<Value, String> {
5783    let handle = req
5784        .params
5785        .get("handle")
5786        .and_then(|v| v.as_str())
5787        .ok_or_else(|| "missing `handle`".to_string())?;
5788    match session.runtime.tool_poll(handle).await {
5789        Some(res) => serde_json::to_value(&res).map_err(|e| e.to_string()),
5790        None => Ok(Value::Null),
5791    }
5792}
5793
5794/// `tools.cancel { handle }` — request cooperative cancellation of a
5795/// detached invocation. Returns `{ cancelled: bool }` — `false` for an
5796/// unknown handle. Distinct from the server → client `tools.cancel`
5797/// *notification* (car#264, emitted when a `tools.execute` callback is
5798/// reaped): same method name, opposite direction.
5799async fn handle_tools_cancel(
5800    req: &JsonRpcMessage,
5801    session: &crate::session::ClientSession,
5802) -> Result<Value, String> {
5803    let handle = req
5804        .params
5805        .get("handle")
5806        .and_then(|v| v.as_str())
5807        .ok_or_else(|| "missing `handle`".to_string())?;
5808    let cancelled = session.runtime.tool_cancel(handle).await;
5809    Ok(serde_json::json!({ "cancelled": cancelled }))
5810}
5811
5812/// `tools.stream.subscribe {}` — forward every [`car_ir::ToolStreamEvent`]
5813/// from this session runtime's fanout to the connection as a
5814/// `tools.stream.event` JSON-RPC notification `{ handle, chunk }`.
5815/// Idempotent per connection (a second subscribe is a no-op — one
5816/// forwarder task per session). The forwarder holds only the broadcast
5817/// receiver + the WS channel: it exits when the socket is gone (write
5818/// failure/timeout, same bounded-write policy as the run-trace drain
5819/// task) or the broadcast closes; on lag it skips the missed events and
5820/// keeps forwarding — missed chunks remain drainable via `tools.poll`,
5821/// which reads the buffer, not the broadcast.
5822async fn handle_tools_stream_subscribe(
5823    session: &crate::session::ClientSession,
5824) -> Result<Value, String> {
5825    use std::sync::atomic::Ordering;
5826    if session
5827        .tool_stream_subscribed
5828        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
5829        .is_err()
5830    {
5831        // Already forwarding to this connection.
5832        return Ok(serde_json::json!({ "subscribed": true }));
5833    }
5834    let mut rx = session.runtime.subscribe_tool_events();
5835    let channel = session.channel.clone();
5836    // Q2 (linus review): the forwarder can exit on a 10s write TIMEOUT
5837    // while the connection later recovers — the flag must reset when
5838    // the task exits so a re-subscribe spawns a fresh forwarder instead
5839    // of no-op'ing while events silently never flow again.
5840    let subscribed = session.tool_stream_subscribed.clone();
5841    tokio::spawn(async move {
5842        use futures::SinkExt;
5843        struct ResetOnExit(std::sync::Arc<std::sync::atomic::AtomicBool>);
5844        impl Drop for ResetOnExit {
5845            fn drop(&mut self) {
5846                self.0.store(false, std::sync::atomic::Ordering::SeqCst);
5847            }
5848        }
5849        let _reset_on_exit = ResetOnExit(subscribed);
5850        loop {
5851            match rx.recv().await {
5852                Ok(ev) => {
5853                    let Ok(json) = serde_json::to_string(&serde_json::json!({
5854                        "jsonrpc": "2.0",
5855                        "method": "tools.stream.event",
5856                        "params": { "handle": ev.handle.id, "chunk": ev.chunk },
5857                    })) else {
5858                        continue;
5859                    };
5860                    // Bounded write: a wedged socket must not park this
5861                    // task on a full TCP buffer forever. Any write
5862                    // failure/timeout means the connection is unusable —
5863                    // stop forwarding (chunks stay pollable).
5864                    let mut guard = channel.write.lock().await;
5865                    let send = tokio::time::timeout(
5866                        std::time::Duration::from_secs(10),
5867                        guard.send(Message::Text(json.into())),
5868                    )
5869                    .await;
5870                    drop(guard);
5871                    match send {
5872                        Ok(Ok(())) => {}
5873                        _ => break,
5874                    }
5875                }
5876                // Lagged subscriber: the bounded broadcast dropped its
5877                // oldest events. Not fatal — resume from the current
5878                // position; the gap is recoverable via tools.poll.
5879                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
5880                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
5881            }
5882        }
5883    });
5884    Ok(serde_json::json!({ "subscribed": true }))
5885}
5886
5887// ---- Remote MCP connectors (CAR as MCP client) ----------------------
5888//
5889// All connector state is process-wide on `ServerState` (shared
5890// McpToolExecutor + ConnectorManager), so these handlers take `&state`
5891// rather than the per-session runtime. Each ensures the persisted
5892// connectors are loaded before acting (idempotent after the first call
5893// / boot-time load).
5894
5895/// `connectors.add { name, url, headers? }` — register a remote MCP
5896/// server, connect, and discover its tools. `headers` is an optional
5897/// map of secret auth-header name → value, stored in the keychain (not
5898/// the manifest). No tools are enabled by the add itself; the response
5899/// reports the discovered count.
5900async fn handle_connectors_add(
5901    req: &JsonRpcMessage,
5902    state: &Arc<ServerState>,
5903) -> Result<Value, String> {
5904    state.ensure_connectors_loaded().await;
5905    let name = req
5906        .params
5907        .get("name")
5908        .and_then(|v| v.as_str())
5909        .ok_or_else(|| "missing `name`".to_string())?;
5910    let url = req
5911        .params
5912        .get("url")
5913        .and_then(|v| v.as_str())
5914        .ok_or_else(|| "missing `url`".to_string())?;
5915    let headers: Vec<(String, String)> = req
5916        .params
5917        .get("headers")
5918        .and_then(|v| v.as_object())
5919        .map(|m| {
5920            m.iter()
5921                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
5922                .collect()
5923        })
5924        .unwrap_or_default();
5925
5926    let status = state
5927        .connectors()
5928        .add(name, url, headers)
5929        .await
5930        .map_err(|e| e.to_string())?;
5931    serde_json::to_value(status).map_err(|e| e.to_string())
5932}
5933
5934/// `connectors.add_stdio { name, command, args?, env? }` — register a
5935/// local stdio MCP server (subprocess transport), connect, and discover
5936/// its tools. No tools are enabled by the add itself.
5937async fn handle_connectors_add_stdio(
5938    req: &JsonRpcMessage,
5939    state: &Arc<ServerState>,
5940) -> Result<Value, String> {
5941    state.ensure_connectors_loaded().await;
5942    let name = req
5943        .params
5944        .get("name")
5945        .and_then(|v| v.as_str())
5946        .ok_or_else(|| "missing `name`".to_string())?;
5947    let command = req
5948        .params
5949        .get("command")
5950        .and_then(|v| v.as_str())
5951        .ok_or_else(|| "missing `command`".to_string())?;
5952    let args: Vec<String> = req
5953        .params
5954        .get("args")
5955        .and_then(|v| v.as_array())
5956        .map(|a| {
5957            a.iter()
5958                .filter_map(|v| v.as_str().map(str::to_string))
5959                .collect()
5960        })
5961        .unwrap_or_default();
5962    let env: std::collections::BTreeMap<String, String> = req
5963        .params
5964        .get("env")
5965        .and_then(|v| v.as_object())
5966        .map(|m| {
5967            m.iter()
5968                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
5969                .collect()
5970        })
5971        .unwrap_or_default();
5972
5973    let status = state
5974        .connectors()
5975        .add_stdio(name, command, args, env)
5976        .await
5977        .map_err(|e| e.to_string())?;
5978    serde_json::to_value(status).map_err(|e| e.to_string())
5979}
5980
5981/// `connectors.authenticate { name, url, redirect_uri }` — begin an
5982/// OAuth 2.1 flow for a remote MCP server. Runs discovery + dynamic
5983/// client registration + PKCE, returns `{ authorize_url, state }`. The
5984/// GUI opens `authorize_url` (with its own `redirect_uri` callback),
5985/// captures the `code`, and calls `connectors.complete_authentication`.
5986async fn handle_connectors_authenticate(
5987    req: &JsonRpcMessage,
5988    state: &Arc<ServerState>,
5989) -> Result<Value, String> {
5990    state.ensure_connectors_loaded().await;
5991    let name = req
5992        .params
5993        .get("name")
5994        .and_then(|v| v.as_str())
5995        .ok_or_else(|| "missing `name`".to_string())?;
5996    let url = req
5997        .params
5998        .get("url")
5999        .and_then(|v| v.as_str())
6000        .ok_or_else(|| "missing `url`".to_string())?;
6001    let redirect_uri = req
6002        .params
6003        .get("redirect_uri")
6004        .and_then(|v| v.as_str())
6005        .ok_or_else(|| "missing `redirect_uri`".to_string())?;
6006
6007    let (authorize_url, oauth_state) = state
6008        .connectors()
6009        .authenticate(name, url, redirect_uri)
6010        .await
6011        .map_err(|e| e.to_string())?;
6012    Ok(serde_json::json!({ "authorize_url": authorize_url, "state": oauth_state }))
6013}
6014
6015/// `connectors.complete_authentication { state, code }` — finish the
6016/// OAuth flow started by `connectors.authenticate`: exchange the code,
6017/// store tokens in the keychain, connect, and discover tools.
6018async fn handle_connectors_complete_authentication(
6019    req: &JsonRpcMessage,
6020    state: &Arc<ServerState>,
6021) -> Result<Value, String> {
6022    state.ensure_connectors_loaded().await;
6023    let oauth_state = req
6024        .params
6025        .get("state")
6026        .and_then(|v| v.as_str())
6027        .ok_or_else(|| "missing `state`".to_string())?;
6028    let code = req
6029        .params
6030        .get("code")
6031        .and_then(|v| v.as_str())
6032        .ok_or_else(|| "missing `code`".to_string())?;
6033
6034    let status = state
6035        .connectors()
6036        .complete_authentication(oauth_state, code)
6037        .await
6038        .map_err(|e| e.to_string())?;
6039    serde_json::to_value(status).map_err(|e| e.to_string())
6040}
6041
6042/// `connectors.list` — all configured connectors with status.
6043async fn handle_connectors_list(state: &Arc<ServerState>) -> Result<Value, String> {
6044    state.ensure_connectors_loaded().await;
6045    let list = state.connectors().list().await;
6046    serde_json::to_value(list).map_err(|e| e.to_string())
6047}
6048
6049/// `connectors.tools { slug }` — discovered tools for a connector,
6050/// flagged by whether each is enabled.
6051async fn handle_connectors_tools(
6052    req: &JsonRpcMessage,
6053    state: &Arc<ServerState>,
6054) -> Result<Value, String> {
6055    state.ensure_connectors_loaded().await;
6056    let slug = req
6057        .params
6058        .get("slug")
6059        .and_then(|v| v.as_str())
6060        .ok_or_else(|| "missing `slug`".to_string())?;
6061    let tools = state
6062        .connectors()
6063        .tools(slug)
6064        .await
6065        .map_err(|e| e.to_string())?;
6066    serde_json::to_value(tools).map_err(|e| e.to_string())
6067}
6068
6069/// `connectors.enable_tools { slug, tools: [..] }` — enable a set of
6070/// (bare, server-side) tool names. Routes them, registers their
6071/// schemas into every open session's runtime, and persists the choice.
6072async fn handle_connectors_enable_tools(
6073    req: &JsonRpcMessage,
6074    state: &Arc<ServerState>,
6075) -> Result<Value, String> {
6076    state.ensure_connectors_loaded().await;
6077    let slug = req
6078        .params
6079        .get("slug")
6080        .and_then(|v| v.as_str())
6081        .ok_or_else(|| "missing `slug`".to_string())?;
6082    let tools: Vec<String> = req
6083        .params
6084        .get("tools")
6085        .and_then(|v| v.as_array())
6086        .map(|a| {
6087            a.iter()
6088                .filter_map(|v| v.as_str().map(str::to_string))
6089                .collect()
6090        })
6091        .ok_or_else(|| "missing `tools` array".to_string())?;
6092
6093    let entries = state
6094        .connectors()
6095        .enable_tools(slug, &tools)
6096        .await
6097        .map_err(|e| e.to_string())?;
6098    let enabled = entries.len();
6099    state.register_connector_entries(&entries).await;
6100    Ok(serde_json::json!({ "enabled": enabled }))
6101}
6102
6103/// `connectors.refresh { slug }` — re-run `tools/list` and re-register
6104/// any still-enabled tools.
6105async fn handle_connectors_refresh(
6106    req: &JsonRpcMessage,
6107    state: &Arc<ServerState>,
6108) -> Result<Value, String> {
6109    state.ensure_connectors_loaded().await;
6110    let slug = req
6111        .params
6112        .get("slug")
6113        .and_then(|v| v.as_str())
6114        .ok_or_else(|| "missing `slug`".to_string())?;
6115    let tools = state
6116        .connectors()
6117        .refresh(slug)
6118        .await
6119        .map_err(|e| e.to_string())?;
6120    // Re-seed enabled entries into live sessions in case the refresh
6121    // re-discovered a previously-enabled tool.
6122    let entries = state.connectors().enabled_tool_entries().await;
6123    state.register_connector_entries(&entries).await;
6124    serde_json::to_value(tools).map_err(|e| e.to_string())
6125}
6126
6127/// `connectors.remove { slug }` — disconnect, drop routes, delete
6128/// keychain secrets, and remove from the manifest.
6129async fn handle_connectors_remove(
6130    req: &JsonRpcMessage,
6131    state: &Arc<ServerState>,
6132) -> Result<Value, String> {
6133    state.ensure_connectors_loaded().await;
6134    let slug = req
6135        .params
6136        .get("slug")
6137        .and_then(|v| v.as_str())
6138        .ok_or_else(|| "missing `slug`".to_string())?;
6139    let canonicals = state
6140        .connectors()
6141        .remove(slug)
6142        .await
6143        .map_err(|e| e.to_string())?;
6144    state.unregister_connector_tools(&canonicals).await;
6145    Ok(serde_json::json!({ "removed": slug }))
6146}
6147
6148async fn run_store_blocking<T, F>(context: &'static str, operation: F) -> Result<T, String>
6149where
6150    T: Send + 'static,
6151    F: FnOnce() -> Result<T, String> + Send + 'static,
6152{
6153    tokio::task::spawn_blocking(operation)
6154        .await
6155        .map_err(|error| format!("{context} blocking task failed: {error}"))?
6156}
6157
6158/// Run a trust-bearing trace read off the Tokio worker, then publish any
6159/// discovered corruption into the live run registry before returning the
6160/// typed wire error. Filesystem scanning/marker persistence never holds the
6161/// global run lock; the short quarantine step runs only after it completes.
6162async fn strict_run_trace_read_blocking<T, F>(
6163    context: &'static str,
6164    state: &Arc<ServerState>,
6165    run_id: String,
6166    operation: F,
6167) -> Result<T, String>
6168where
6169    T: Send + 'static,
6170    F: FnOnce() -> std::io::Result<T> + Send + 'static,
6171{
6172    let result = tokio::task::spawn_blocking(operation)
6173        .await
6174        .map_err(|error| format!("{context} blocking task failed: {error}"))?;
6175    match result {
6176        Ok(value) => Ok(value),
6177        Err(error) if crate::run_store::is_trace_corruption_error(&error) => Err(state
6178            .quarantine_run_trace_from_read(&run_id, error.to_string())
6179            .await),
6180        Err(error) => Err(run_trace_read_error(&run_id, error)),
6181    }
6182}
6183
6184/// Run one proposal durability boundary without pinning a Tokio worker.
6185///
6186/// The owned lifecycle guard moves into the blocking task and is returned only
6187/// after the fsync/rename operation finishes. If the JSON-RPC handler is
6188/// cancelled while awaiting, the task still owns the guard until durability is
6189/// settled, so an exact retry cannot race an in-flight marker/outbox commit.
6190async fn run_lifecycle_durability_blocking<T, F>(
6191    guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6192    context: &'static str,
6193    operation: F,
6194) -> Result<T, String>
6195where
6196    T: Send + 'static,
6197    F: FnOnce() -> Result<T, String> + Send + 'static,
6198{
6199    let owned = guard
6200        .take()
6201        .expect("proposal durability requires the owned lifecycle guard");
6202    match tokio::task::spawn_blocking(move || {
6203        let result = operation();
6204        (owned, result)
6205    })
6206    .await
6207    {
6208        Ok((owned, result)) => {
6209            *guard = Some(owned);
6210            result
6211        }
6212        Err(error) => Err(format!("{context} blocking task failed: {error}")),
6213    }
6214}
6215
6216/// `connectors.disable_tools { slug, tools: [..] }` — disable a set of
6217/// (bare, server-side) tool names: drop their routes and unregister
6218/// their schemas from every open session's runtime.
6219async fn handle_connectors_disable_tools(
6220    req: &JsonRpcMessage,
6221    state: &Arc<ServerState>,
6222) -> Result<Value, String> {
6223    state.ensure_connectors_loaded().await;
6224    let slug = req
6225        .params
6226        .get("slug")
6227        .and_then(|v| v.as_str())
6228        .ok_or_else(|| "missing `slug`".to_string())?;
6229    let tools: Vec<String> = req
6230        .params
6231        .get("tools")
6232        .and_then(|v| v.as_array())
6233        .map(|a| {
6234            a.iter()
6235                .filter_map(|v| v.as_str().map(str::to_string))
6236                .collect()
6237        })
6238        .ok_or_else(|| "missing `tools` array".to_string())?;
6239
6240    let canonicals = state
6241        .connectors()
6242        .disable_tools(slug, &tools)
6243        .await
6244        .map_err(|e| e.to_string())?;
6245    let disabled = canonicals.len();
6246    state.unregister_connector_tools(&canonicals).await;
6247    Ok(serde_json::json!({ "disabled": disabled }))
6248}
6249
6250async fn handle_proposal_submit(
6251    req: &JsonRpcMessage,
6252    session: &crate::session::ClientSession,
6253    state: &Arc<ServerState>,
6254) -> Result<Value, String> {
6255    let submit: ProposalSubmitRequest =
6256        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
6257    let original_submission = req
6258        .params
6259        .get("proposal")
6260        .cloned()
6261        .ok_or_else(|| "proposal.submit is missing `proposal`".to_string())?;
6262    // Fail before binding a policy session or executing any action when two
6263    // proposal-local actions would share an ambiguous journal/DAG join key, or
6264    // when the exact submitted Proposal cannot carry the mandatory RFC 8785
6265    // identity used by proposal_received. Runtime entry points repeat both
6266    // guards; this boundary preserves an invalid-proposal JSON-RPC error.
6267    car_engine::validate_proposal_action_ids(&submit.proposal)
6268        .map_err(|error| format!("invalid proposal: {error}"))?;
6269    let submitted_proposal_digest = canonical_sha256(&submit.proposal)?;
6270    let submitted_proposal_value =
6271        serde_json::to_value(&submit.proposal).map_err(|e| e.to_string())?;
6272    // `session_id` is sibling to `proposal` in the params object —
6273    // not part of `ProposalSubmitRequest` (kept proto-compatible). When
6274    // present, executes the proposal under the named session so any
6275    // session-scoped policies layer on top of global ones.
6276    // See docs/proposals/per-session-policy-scoping.md.
6277    let session_id = match req.params.get("session_id") {
6278        None | Some(Value::Null) => None,
6279        Some(Value::String(value)) if !value.is_empty() => Some(value.clone()),
6280        Some(_) => return Err("invalid `session_id`: expected a non-empty string".to_string()),
6281    };
6282
6283    // Decode every execution context before acquiring/binding live lifecycle
6284    // state. A malformed scope must be a side-effect-free request error, not a
6285    // half-open proposal policy binding. CAR currently has no runtime entry
6286    // point that enforces both a policy session and a tenant scope; accepting
6287    // both while silently dropping either constraint would be an authorization
6288    // bypass, so the wire fails closed until a combined entry point exists.
6289    let scope: Option<car_engine::RuntimeScope> = match req.params.get("scope") {
6290        Some(v) if !v.is_null() => {
6291            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid scope: {e}"))?)
6292        }
6293        _ => None,
6294    };
6295    if session_id.is_some() && scope.is_some() {
6296        return Err(
6297            "proposal.submit cannot combine `session_id` and `scope`: CAR cannot yet enforce both contexts"
6298                .to_string(),
6299        );
6300    }
6301
6302    // One authenticated run bracket is one ordered journal producer. The
6303    // dispatcher otherwise runs frames concurrently, so serialize proposal
6304    // lifecycle with runs.start/runs.complete/disconnect before reading the
6305    // current run and keep the guard through the terminal proposal event.
6306    let mut run_guard = Some(session.run_lifecycle_guard.clone().lock_owned().await);
6307    let current_run = session.current_run_id.lock().await.clone();
6308
6309    // The global retry-owner check is one content-addressed file read. Run it
6310    // on blocking capacity so the async worker never performs filesystem I/O.
6311    // Keeping the lifecycle guard preserves request ordering while replacing
6312    // the old unbounded receipt/run-directory scan with one exact lookup.
6313    let receipt_owner = {
6314        let run_store = state.run_store.clone();
6315        let requested_policy_session_id = session_id.clone();
6316        let original_submission = original_submission.clone();
6317        tokio::task::spawn_blocking(move || {
6318            run_store.completed_proposal_retry_owner(
6319                requested_policy_session_id.as_deref(),
6320                &original_submission,
6321            )
6322        })
6323        .await
6324        .map_err(|error| format!("completed proposal response owner lookup failed: {error}"))?
6325        .map_err(|error| format!("completed proposal response registry is unreadable: {error}"))?
6326    };
6327    if let Some((receipt_run, receipt_client)) = receipt_owner.as_ref() {
6328        let owner_matches = if current_run.as_deref() == Some(receipt_run.as_str()) {
6329            state
6330                .run_owner_binding(receipt_run)
6331                .await
6332                .is_some_and(|(durable, active)| {
6333                    durable == *receipt_client && active == session.client_id
6334                })
6335        } else {
6336            false
6337        };
6338        if !owner_matches {
6339            return Err(format!(
6340                "{} completed proposal response belongs to run `{receipt_run}` / durable client `{receipt_client}`; this socket is not the active authenticated owner",
6341                car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6342            ));
6343        }
6344    }
6345    if let Some(run_id) = current_run.as_deref() {
6346        let (durable_client, active_client) = state
6347            .run_owner_binding(run_id)
6348            .await
6349            .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6350        let (owner_client, terminal, start_committed, completion_pending, trace_corruption) = state
6351            .run_lifecycle_state_with_corruption(run_id)
6352            .await
6353            .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6354        if let Some(error) = trace_corruption {
6355            return Err(error);
6356        }
6357        if terminal
6358            || !start_committed
6359            || completion_pending
6360            || owner_client != session.client_id
6361            || active_client != session.client_id
6362        {
6363            return Err(format!(
6364                "active run is not writable for run_id `{run_id}` / client_id `{}`",
6365                session.client_id
6366            ));
6367        }
6368        session.require_run_journal_binding(run_id).await?;
6369
6370        let (completed, resumed_policy_rotation) = {
6371            let run_store = state.run_store.clone();
6372            let run_id = run_id.to_string();
6373            let client_id = durable_client.clone();
6374            let requested_policy_session_id = session_id.clone();
6375            let original_submission = original_submission.clone();
6376            run_store_blocking("completed proposal response lookup", move || {
6377                let exact = run_store
6378                    .completed_proposal(
6379                        &run_id,
6380                        &client_id,
6381                        requested_policy_session_id.as_deref(),
6382                        &original_submission,
6383                    )
6384                    .map_err(|error| {
6385                        proposal_durability_quarantine(&run_id, "completed response", &error)
6386                    })?;
6387                let resumed_owner = client_id != active_client;
6388                if exact.is_some() || !resumed_owner {
6389                    return Ok((exact, resumed_owner));
6390                }
6391                run_store
6392                    .completed_proposal_for_resumed_owner(&run_id, &client_id, &original_submission)
6393                    .map(|receipt| (receipt, true))
6394                    .map_err(|error| {
6395                        proposal_durability_quarantine(
6396                            &run_id,
6397                            "resumed completed response",
6398                            &error,
6399                        )
6400                    })
6401            })
6402            .await?
6403        };
6404        if let Some(receipt) = completed {
6405            if resumed_policy_rotation {
6406                let original_policy = &receipt.finalization.requested_policy_session_id;
6407                let authenticated_original_policy = &receipt.finalization.policy_session_id;
6408                let replacement_policy_is_live = match session_id.as_deref() {
6409                    Some(policy_session_id) => {
6410                        session.runtime.session_exists(policy_session_id).await
6411                    }
6412                    None => false,
6413                };
6414                match (original_policy, authenticated_original_policy, &session_id) {
6415                    (None, None, None) => {}
6416                    (Some(requested), Some(authenticated), Some(_))
6417                        if requested == authenticated && replacement_policy_is_live => {}
6418                    _ => {
6419                        return Err(
6420                            "resumed proposal recovery requires the original authenticated policy provenance and a live replacement policy session"
6421                                .to_string(),
6422                        );
6423                    }
6424                }
6425            }
6426            let run_store = state.run_store.clone();
6427            let (value, cleanup_error) = run_lifecycle_durability_blocking(
6428                &mut run_guard,
6429                "completed proposal guard cleanup",
6430                move || {
6431                    let cleanup_error = run_store
6432                        .cleanup_completed_proposal_guards(&receipt)
6433                        .err()
6434                        .map(|error| error.to_string());
6435                    let value = run_store
6436                        .completed_proposal_response_value(&receipt)
6437                        .map_err(|error| {
6438                            format!("completed proposal response serialization failed: {error}")
6439                        })?;
6440                    Ok((value, cleanup_error))
6441                },
6442            )
6443            .await?;
6444            if let Some(error) = cleanup_error {
6445                tracing::warn!(run_id, %error, "completed proposal response remains recoverable while guard cleanup is pending");
6446            }
6447            return Ok(value);
6448        }
6449
6450        let pending = {
6451            let run_store = state.run_store.clone();
6452            let run_id = run_id.to_string();
6453            run_store_blocking("pending proposal lookup", move || {
6454                run_store.pending_proposal(&run_id).map_err(|error| {
6455                    proposal_durability_quarantine(&run_id, "finalization", &error)
6456                })
6457            })
6458            .await?
6459        };
6460        if let Some(pending) = pending {
6461            if pending.client_id != durable_client
6462                || pending.original_submission != original_submission
6463                || pending.requested_policy_session_id.as_deref() != session_id.as_deref()
6464            {
6465                return Err(format!(
6466                    "proposal finalization pending for run_id `{run_id}`; only an exact retry of original submission `{}` may reconcile it",
6467                    pending.original_proposal_id
6468                ));
6469            }
6470            return finish_pending_proposal(
6471                session,
6472                state,
6473                &pending,
6474                &durable_client,
6475                &mut run_guard,
6476            )
6477            .await;
6478        }
6479        let marker = {
6480            let run_store = state.run_store.clone();
6481            let run_id = run_id.to_string();
6482            run_store_blocking("proposal execution marker lookup", move || {
6483                run_store
6484                    .execution_marker(&run_id)
6485                    .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))
6486            })
6487            .await?
6488        };
6489        if let Some(marker) = marker {
6490            return Err(format!(
6491                "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not redispatch automatically",
6492                marker.original_proposal_id
6493            ));
6494        }
6495        if durable_client != session.client_id {
6496            return Err(format!(
6497                "resumed run `{run_id}` accepts only an exact recovery submission; CAR will not dispatch new actions"
6498            ));
6499        }
6500    }
6501
6502    // Only a CAR-minted live policy session is authenticated enough to stamp
6503    // into the journal. An arbitrary request label is never copied. The
6504    // runtime preserves its existing unknown-session rejection semantics.
6505    let authenticated_policy_session = match session_id.as_deref() {
6506        Some(policy_session_id) if session.runtime.session_exists(policy_session_id).await => {
6507            Some(policy_session_id.to_string())
6508        }
6509        _ => None,
6510    };
6511    let execution_marker =
6512        current_run
6513            .as_deref()
6514            .map(|run_id| crate::run_store::ProposalExecutionMarker {
6515                run_id: run_id.to_string(),
6516                client_id: session.client_id.clone(),
6517                requested_policy_session_id: session_id.clone(),
6518                policy_session_id: authenticated_policy_session.clone(),
6519                original_proposal_id: submit.proposal.id.clone(),
6520                original_submission: original_submission.clone(),
6521                original_proposal: submitted_proposal_value.clone(),
6522                proposal_digest: submitted_proposal_digest.clone(),
6523            });
6524    let retry_rollback =
6525        execution_marker
6526            .as_ref()
6527            .map(|marker| crate::run_store::ProposalRetryRollback {
6528                run_id: marker.run_id.clone(),
6529                client_id: marker.client_id.clone(),
6530                requested_policy_session_id: marker.requested_policy_session_id.clone(),
6531                original_submission: original_submission.clone(),
6532            });
6533    if let (Some(marker), Some(rollback)) = (execution_marker.as_ref(), retry_rollback.as_ref()) {
6534        let run_store = state.run_store.clone();
6535        let marker = marker.clone();
6536        let rollback = rollback.clone();
6537        let expected_run_id = marker.run_id.clone();
6538        let reservation = run_lifecycle_durability_blocking(
6539            &mut run_guard,
6540            "proposal pre-execution reservation durability",
6541            move || {
6542                run_store
6543                    .claim_proposal_id(
6544                        &marker.run_id,
6545                        &marker.client_id,
6546                        &marker.original_proposal_id,
6547                        &marker.original_submission,
6548                    )
6549                    .map_err(|error| {
6550                        format!("proposal id could not be durably claimed: {error}")
6551                    })?;
6552                run_store
6553                    .write_proposal_retry_rollback(&rollback)
6554                    .map_err(|error| {
6555                        format!("proposal retry rollback could not be durably prepared: {error}")
6556                    })?;
6557                run_store
6558                    .reserve_proposal_retry_owner(
6559                        &marker.run_id,
6560                        &marker.client_id,
6561                        marker.requested_policy_session_id.as_deref(),
6562                        &marker.original_submission,
6563                    )
6564                    .map_err(|error| {
6565                        format!("proposal retry reservation could not be durably prepared: {error}")
6566                    })
6567            },
6568        )
6569        .await?;
6570        if let crate::run_store::ProposalRetryReservation::Existing {
6571            run_id: reserved_run,
6572            client_id: reserved_client,
6573        } = reservation
6574        {
6575            if reserved_run != expected_run_id || reserved_client != session.client_id {
6576                let run_store = state.run_store.clone();
6577                let rollback = retry_rollback
6578                    .as_ref()
6579                    .expect("active reservation has rollback authority")
6580                    .clone();
6581                run_lifecycle_durability_blocking(
6582                    &mut run_guard,
6583                    "losing proposal retry rollback cleanup",
6584                    move || {
6585                        run_store
6586                            .clear_proposal_retry_rollback(&rollback)
6587                            .map_err(|error| {
6588                                format!("losing retry rollback cleanup failed: {error}")
6589                            })
6590                    },
6591                )
6592                .await?;
6593                return Err(format!(
6594                    "{} proposal retry tuple belongs to run `{reserved_run}` / client `{reserved_client}`; concurrent submission cannot dispatch it",
6595                    car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6596                ));
6597            }
6598        }
6599    }
6600    if let Some(marker) = execution_marker.as_ref() {
6601        let run_store = state.run_store.clone();
6602        let durable_marker = marker.clone();
6603        let write_result = run_lifecycle_durability_blocking(
6604            &mut run_guard,
6605            "proposal execution marker durability",
6606            move || {
6607                run_store
6608                    .write_execution_marker(&durable_marker)
6609                    .map_err(|error| {
6610                        format!("proposal execution could not be durably prepared: {error}")
6611                    })
6612            },
6613        )
6614        .await;
6615        if let Err(error) = write_result {
6616            rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6617                .await
6618                .map_err(|rollback| format!("{error}; exact guard rollback failed: {rollback}"))?;
6619            return Err(error);
6620        }
6621    }
6622    if current_run.is_some() {
6623        if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6624            if let Err(error) = session
6625                .runtime
6626                .event_log_handle()
6627                .lock()
6628                .await
6629                .bind_policy_session(policy_session_id)
6630            {
6631                if let Some(marker) = execution_marker.as_ref() {
6632                    rollback_pre_execution_guards(
6633                        state,
6634                        &mut run_guard,
6635                        marker,
6636                        &original_submission,
6637                    )
6638                    .await
6639                    .map_err(|rollback| {
6640                        format!("{error}; exact guard rollback failed: {rollback}")
6641                    })?;
6642                }
6643                return Err(error);
6644            }
6645        }
6646    }
6647
6648    if let Some(rollback) = retry_rollback.as_ref() {
6649        let run_store = state.run_store.clone();
6650        let rollback = rollback.clone();
6651        let clear_result = run_lifecycle_durability_blocking(
6652            &mut run_guard,
6653            "proposal retry rollback intent cleanup",
6654            move || {
6655                run_store
6656                    .clear_proposal_retry_rollback(&rollback)
6657                    .map_err(|error| format!("proposal retry rollback cleanup failed: {error}"))
6658            },
6659        )
6660        .await;
6661        if let Err(error) = clear_result {
6662            if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6663                session
6664                    .runtime
6665                    .event_log_handle()
6666                    .lock()
6667                    .await
6668                    .clear_policy_session(policy_session_id)
6669                    .map_err(|unbind| {
6670                        format!("{error}; policy rollback failed before dispatch: {unbind}")
6671                    })?;
6672            }
6673            if let Some(marker) = execution_marker.as_ref() {
6674                rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6675                    .await
6676                    .map_err(|rollback| {
6677                        format!("{error}; exact guard rollback failed: {rollback}")
6678                    })?;
6679            }
6680            return Err(error);
6681        }
6682    }
6683
6684    let proposal_event_start = if current_run.is_some() {
6685        Some(session.runtime.event_log_handle().lock().await.len())
6686    } else {
6687        None
6688    };
6689
6690    let result = match (session_id.as_deref(), scope) {
6691        (Some(sid), None) => {
6692            if current_run.is_some() {
6693                session
6694                    .runtime
6695                    .execute_with_session_and_stable_replan_id(&submit.proposal, sid)
6696                    .await
6697            } else {
6698                session
6699                    .runtime
6700                    .execute_with_session(&submit.proposal, sid)
6701                    .await
6702            }
6703        }
6704        (None, Some(s)) => {
6705            if current_run.is_some() {
6706                session
6707                    .runtime
6708                    .execute_scoped_with_stable_replan_id(&submit.proposal, &s)
6709                    .await
6710            } else {
6711                session.runtime.execute_scoped(&submit.proposal, &s).await
6712            }
6713        }
6714        (None, None) => {
6715            if current_run.is_some() {
6716                session
6717                    .runtime
6718                    .execute_with_stable_replan_id(&submit.proposal)
6719                    .await
6720            } else {
6721                session.runtime.execute(&submit.proposal).await
6722            }
6723        }
6724        (Some(_), Some(_)) => unreachable!("combined execution context rejected before binding"),
6725    };
6726
6727    // The engine has already applied terminal semantics here: retry stopped,
6728    // the proposal aborted, and transactional state rolled back. Latch the
6729    // daemon-only session scope before any optional run-finalization work so a
6730    // terminal callback cannot be followed by another admitted proposal even
6731    // if durable finalization reports an error to this caller.
6732    if result.results.iter().any(|action| action.terminal) {
6733        session.halted.store(true, Ordering::Release);
6734    }
6735
6736    if current_run.is_some() {
6737        let proposal_result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
6738        let result_digest = canonical_sha256(&proposal_result_value)?;
6739        let final_proposal = result
6740            .final_proposal
6741            .clone()
6742            .ok_or_else(|| "active v3 runtime result is missing final_proposal".to_string())?;
6743        let event_start = proposal_event_start
6744            .expect("active run captured its proposal event boundary before dispatch");
6745        let accepted_proposal_preimages = {
6746            let log = session.runtime.event_log_handle();
6747            let log = log.lock().await;
6748            collect_accepted_proposal_preimages(
6749                &submit.proposal,
6750                &result,
6751                &log.events()[event_start..],
6752            )?
6753        };
6754        let run_id = current_run
6755            .as_deref()
6756            .expect("current run checked before proposal finalization");
6757        let pending = crate::run_store::PendingProposalFinalization {
6758            run_id: run_id.to_string(),
6759            client_id: session.client_id.clone(),
6760            requested_policy_session_id: session_id,
6761            policy_session_id: authenticated_policy_session.clone(),
6762            original_proposal_id: result.original_proposal_id.clone(),
6763            final_proposal_id: result.proposal_id.clone(),
6764            original_submission,
6765            original_proposal: submit.proposal.clone(),
6766            final_proposal,
6767            accepted_proposal_preimages,
6768            proposal_result: result.clone(),
6769            result_digest,
6770        };
6771        let run_store = state.run_store.clone();
6772        let durable_pending = pending.clone();
6773        run_lifecycle_durability_blocking(
6774            &mut run_guard,
6775            "proposal finalization outbox durability",
6776            move || {
6777                run_store
6778                    .write_pending_proposal(&durable_pending)
6779                    .map_err(|error| {
6780                        format!("proposal finalization could not be durably prepared: {error}")
6781                    })
6782            },
6783        )
6784        .await?;
6785        return finish_pending_proposal(
6786            session,
6787            state,
6788            &pending,
6789            &session.client_id,
6790            &mut run_guard,
6791        )
6792        .await;
6793    }
6794
6795    serde_json::to_value(result).map_err(|e| e.to_string())
6796}
6797
6798async fn rollback_pre_execution_guards(
6799    state: &Arc<ServerState>,
6800    run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6801    marker: &crate::run_store::ProposalExecutionMarker,
6802    original_submission: &Value,
6803) -> Result<(), String> {
6804    let run_store = state.run_store.clone();
6805    let marker = marker.clone();
6806    let submission = original_submission.clone();
6807    let rollback = crate::run_store::ProposalRetryRollback {
6808        run_id: marker.run_id.clone(),
6809        client_id: marker.client_id.clone(),
6810        requested_policy_session_id: marker.requested_policy_session_id.clone(),
6811        original_submission: submission.clone(),
6812    };
6813    run_lifecycle_durability_blocking(
6814        run_guard,
6815        "proposal pre-execution guard rollback",
6816        move || {
6817            run_store
6818                .clear_execution_marker(&marker)
6819                .map_err(|error| format!("execution marker rollback failed: {error}"))?;
6820            run_store
6821                .release_proposal_retry_owner(
6822                    &marker.run_id,
6823                    &marker.client_id,
6824                    marker.requested_policy_session_id.as_deref(),
6825                    &submission,
6826                )
6827                .map_err(|error| format!("retry owner rollback failed: {error}"))?;
6828            run_store
6829                .clear_proposal_retry_rollback(&rollback)
6830                .map_err(|error| format!("retry rollback intent cleanup failed: {error}"))
6831        },
6832    )
6833    .await
6834}
6835
6836async fn finish_pending_proposal(
6837    session: &crate::session::ClientSession,
6838    state: &Arc<ServerState>,
6839    pending: &crate::run_store::PendingProposalFinalization,
6840    durable_client_id: &str,
6841    run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6842) -> Result<Value, String> {
6843    let (started, _marker) = {
6844        let run_store = state.run_store.clone();
6845        let pending = pending.clone();
6846        run_store_blocking("proposal finalization provenance lookup", move || {
6847            run_store.pending_provenance(&pending).map_err(|error| {
6848                format!("proposal finalization provenance validation failed: {error}")
6849            })
6850        })
6851        .await?
6852    };
6853    let durable_client = started
6854        .client_id
6855        .as_deref()
6856        .ok_or_else(|| "durable RunStarted is missing its client identity".to_string())?;
6857    let active_owner = state
6858        .run_owner_binding(&pending.run_id)
6859        .await
6860        .ok_or_else(|| {
6861            format!(
6862                "active run `{}` is absent from CAR's run registry",
6863                pending.run_id
6864            )
6865        })?;
6866    if durable_client != durable_client_id
6867        || active_owner.0 != durable_client_id
6868        || active_owner.1 != session.client_id
6869    {
6870        return Err(
6871            "proposal finalization owner does not match the authenticated run binding".into(),
6872        );
6873    }
6874    session.require_run_journal_binding(&pending.run_id).await?;
6875    let event_log = session.runtime.event_log_handle();
6876    let mut log = event_log.lock().await;
6877    let active_policy = log
6878        .active_run_binding()
6879        .and_then(|(_, _, policy_session_id)| policy_session_id.map(str::to_string));
6880    match (
6881        active_policy.as_deref(),
6882        pending.policy_session_id.as_deref(),
6883    ) {
6884        (Some(active), Some(expected)) if active == expected => {}
6885        (None, Some(expected)) => log.bind_policy_session(expected)?,
6886        (None, None) => {}
6887        _ => {
6888            return Err(
6889                "proposal finalization policy identity does not match the active journal binding"
6890                    .into(),
6891            );
6892        }
6893    }
6894    state.ensure_proposal_run_turns(pending).await?;
6895    match log
6896        .append_critical_async(
6897            car_eventlog::EventKind::ProposalCompleted,
6898            None,
6899            Some(&pending.final_proposal_id),
6900            pending.event_data(),
6901            PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT,
6902        )
6903        .await
6904    {
6905        Ok(_) => {}
6906        Err(car_eventlog::CriticalAppendError::DurabilityUnknown { reason }) => {
6907            return Err(format!(
6908                "proposal finalization durability is unknown; retry the exact proposal.submit safely: {reason}"
6909            ));
6910        }
6911        Err(car_eventlog::CriticalAppendError::Rejected { reason }) => {
6912            return Err(format!(
6913                "proposal finalization critical journal append rejected: {reason}"
6914            ));
6915        }
6916    }
6917    if let Some(policy_session_id) = pending.policy_session_id.as_deref() {
6918        log.clear_policy_session(policy_session_id)?;
6919    }
6920    drop(log);
6921    let run_store = state.run_store.clone();
6922    let pending = pending.clone();
6923    let pending_run_id = pending.run_id.clone();
6924    let (value, cleanup_error) = run_lifecycle_durability_blocking(
6925        run_guard,
6926        "completed proposal response durability",
6927        move || {
6928            let receipt = run_store
6929                .write_completed_proposal(&pending)
6930                .map_err(|error| {
6931                    format!("completed proposal response persistence failed: {error}")
6932                })?;
6933            let cleanup_error = run_store
6934                .cleanup_completed_proposal_guards(&receipt)
6935                .err()
6936                .map(|error| error.to_string());
6937            let value = run_store
6938                .completed_proposal_response_value(&receipt)
6939                .map_err(|error| {
6940                    format!("completed proposal response serialization failed: {error}")
6941                })?;
6942            Ok((value, cleanup_error))
6943        },
6944    )
6945    .await?;
6946    if let Some(error) = cleanup_error {
6947        tracing::warn!(run_id = %pending_run_id, %error, "completed proposal response is durable; guard cleanup remains pending");
6948    }
6949    Ok(value)
6950}
6951
6952fn collect_accepted_proposal_preimages(
6953    original: &car_ir::ActionProposal,
6954    result: &car_ir::ProposalResult,
6955    proposal_events: &[car_eventlog::Event],
6956) -> Result<Vec<crate::run_store::AcceptedProposalPreimage>, String> {
6957    let mut accepted = Vec::new();
6958    for lineage in &result.replan_lineage {
6959        if lineage.status != car_ir::ProposalLineageStatus::Accepted {
6960            continue;
6961        }
6962        if lineage.generation == 0 {
6963            accepted.push(crate::run_store::AcceptedProposalPreimage {
6964                generation: 0,
6965                proposal: original.clone(),
6966            });
6967            continue;
6968        }
6969        let matches: Vec<_> = proposal_events
6970            .iter()
6971            .filter(|event| {
6972                event.kind == car_eventlog::EventKind::ReplanProposalReceived
6973                    && event.proposal_id.as_deref() == Some(result.original_proposal_id.as_str())
6974                    && event.data.get("attempt").and_then(Value::as_u64)
6975                        == Some(u64::from(lineage.generation))
6976                    && event.data.get("proposal_id").and_then(Value::as_str)
6977                        == Some(lineage.proposal_id.as_str())
6978                    && event.data.get("proposal_digest").and_then(Value::as_str)
6979                        == lineage.proposal_digest.as_deref()
6980            })
6981            .collect();
6982        if matches.len() != 1 {
6983            return Err(format!(
6984                "accepted replan generation {} has {} exact proposal preimage events",
6985                lineage.generation,
6986                matches.len()
6987            ));
6988        }
6989        let proposal = matches[0].data.get("proposal").cloned().ok_or_else(|| {
6990            format!(
6991                "accepted replan generation {} is missing its proposal preimage",
6992                lineage.generation
6993            )
6994        })?;
6995        accepted.push(crate::run_store::AcceptedProposalPreimage {
6996            generation: lineage.generation,
6997            proposal: serde_json::from_value(proposal).map_err(|error| {
6998                format!(
6999                    "accepted replan generation {} has invalid proposal preimage: {error}",
7000                    lineage.generation
7001                )
7002            })?,
7003        });
7004    }
7005    Ok(accepted)
7006}
7007
7008#[cfg(test)]
7009mod proposal_finalization_tests {
7010    use super::*;
7011
7012    #[test]
7013    fn accepted_replan_uses_exact_authenticated_event_preimage() {
7014        let timestamp = chrono::Utc::now();
7015        let original: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7016            "id": "original",
7017            "source": "caller",
7018            "actions": [],
7019            "timestamp": timestamp,
7020            "context": {}
7021        }))
7022        .unwrap();
7023        let final_proposal: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7024            "id": "accepted-replan",
7025            "source": "replanner",
7026            "actions": [],
7027            "timestamp": timestamp,
7028            "context": {"generation": 1}
7029        }))
7030        .unwrap();
7031        let original_digest = canonical_sha256(&original).unwrap();
7032        let final_digest = canonical_sha256(&final_proposal).unwrap();
7033        let result = car_ir::ProposalResult {
7034            proposal_id: final_proposal.id.clone(),
7035            original_proposal_id: original.id.clone(),
7036            final_proposal: Some(final_proposal.clone()),
7037            accepted_proposal_preimages: vec![],
7038            replan_lineage: vec![
7039                car_ir::ProposalLineageEntry {
7040                    generation: 0,
7041                    proposal_id: original.id.clone(),
7042                    proposal_digest: Some(original_digest),
7043                    status: car_ir::ProposalLineageStatus::Accepted,
7044                    rejection_reason: None,
7045                },
7046                car_ir::ProposalLineageEntry {
7047                    generation: 1,
7048                    proposal_id: final_proposal.id.clone(),
7049                    proposal_digest: Some(final_digest.clone()),
7050                    status: car_ir::ProposalLineageStatus::Accepted,
7051                    rejection_reason: None,
7052                },
7053            ],
7054            results: vec![],
7055            cost: car_ir::CostSummary::default(),
7056        };
7057        let event = car_eventlog::Event {
7058            kind: car_eventlog::EventKind::ReplanProposalReceived,
7059            run_id: Some("run".to_string()),
7060            client_id: Some("client".to_string()),
7061            policy_session_id: None,
7062            action_id: None,
7063            proposal_id: Some(original.id.clone()),
7064            data: HashMap::from([
7065                ("attempt".to_string(), Value::from(1)),
7066                (
7067                    "proposal_id".to_string(),
7068                    Value::from(final_proposal.id.clone()),
7069                ),
7070                ("proposal_digest".to_string(), Value::from(final_digest)),
7071                (
7072                    "proposal".to_string(),
7073                    serde_json::to_value(&final_proposal).unwrap(),
7074                ),
7075            ]),
7076            timestamp,
7077            prev_hash: None,
7078            hash: None,
7079        };
7080
7081        let accepted = collect_accepted_proposal_preimages(&original, &result, &[event]).unwrap();
7082        assert_eq!(accepted.len(), 2);
7083        assert_eq!(accepted[0].generation, 0);
7084        assert_eq!(accepted[0].proposal, original);
7085        assert_eq!(accepted[1].generation, 1);
7086        assert_eq!(accepted[1].proposal, final_proposal);
7087    }
7088}
7089
7090/// Lowercase SHA-256 of RFC 8785/JCS. Proposal terminal events use the exact
7091/// serialized `ProposalResult`; run terminal identity uses the exact
7092/// `RunTermination` via `session::run_completion_digest`.
7093fn canonical_sha256<T: serde::Serialize + ?Sized>(value: &T) -> Result<String, String> {
7094    let canonical = car_inference::catalog_identity::canonical_json(value)?;
7095    Ok(format!("{:x}", Sha256::digest(canonical.as_bytes())))
7096}
7097
7098fn proposal_durability_quarantine(
7099    run_id: &str,
7100    state_kind: &str,
7101    error: &std::io::Error,
7102) -> String {
7103    format!(
7104        "proposal {state_kind} durability state is unreadable for run_id `{run_id}`; outcome unknown and CAR will not redispatch, complete, replace, or fabricate a terminal: {error}"
7105    )
7106}
7107
7108/// Keep proposal-terminal journal uncertainty inside the handler deadline.
7109/// This matches the run lifecycle acknowledgement policy in `session.rs`.
7110const PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT: std::time::Duration =
7111    std::time::Duration::from_secs(5);
7112
7113async fn handle_session_policy_open(
7114    session: &crate::session::ClientSession,
7115) -> Result<Value, String> {
7116    let id = session.runtime.open_session().await;
7117    Ok(serde_json::json!({ "session_id": id }))
7118}
7119
7120async fn handle_session_policy_close(
7121    req: &JsonRpcMessage,
7122    session: &crate::session::ClientSession,
7123) -> Result<Value, String> {
7124    let sid = req
7125        .params
7126        .get("session_id")
7127        .and_then(|v| v.as_str())
7128        .ok_or("missing 'session_id'")?;
7129    let closed = session.runtime.close_session(sid).await;
7130    Ok(serde_json::json!({ "closed": closed }))
7131}
7132
7133/// `policy.register` — register one policy against this WebSocket
7134/// session's runtime. Mirrors the `PolicyDefinition` shape used by
7135/// `session.init`. When `session_id` is present, the policy is scoped
7136/// to the named in-runtime session opened via `session.policy.open`;
7137/// otherwise it is global.
7138async fn handle_policy_register(
7139    req: &JsonRpcMessage,
7140    session: &crate::session::ClientSession,
7141) -> Result<Value, String> {
7142    let def: PolicyDefinition = serde_json::from_value(req.params.clone())
7143        .map_err(|e| format!("invalid policy params: {e}"))?;
7144    let session_id = req
7145        .params
7146        .get("session_id")
7147        .and_then(|v| v.as_str())
7148        .map(str::to_string);
7149    let check = build_policy_check(&def)
7150        .ok_or_else(|| format!("unsupported policy rule '{}'", def.rule))?;
7151    let denies_tool = blanket_denied_tool(&def);
7152    match session_id {
7153        Some(sid) => {
7154            let registered = match &denies_tool {
7155                Some(tool) => {
7156                    session
7157                        .runtime
7158                        .register_tool_deny_in_session(&sid, &def.name, tool, check, "")
7159                        .await
7160                }
7161                None => {
7162                    session
7163                        .runtime
7164                        .register_policy_in_session(&sid, &def.name, check, "")
7165                        .await
7166                }
7167            };
7168            registered.map(
7169                |_| serde_json::json!({ "registered": def.name, "scope": { "session_id": sid } }),
7170            )
7171        }
7172        None => {
7173            let mut policies = session.runtime.policies.write().await;
7174            match &denies_tool {
7175                Some(tool) => policies.register_tool_deny(&def.name, tool, check, ""),
7176                None => policies.register(&def.name, check, ""),
7177            }
7178            Ok(serde_json::json!({ "registered": def.name, "scope": "global" }))
7179        }
7180    }
7181}
7182
7183/// `policy.unregister` — remove a policy by name. `{ name, session_id? }`.
7184///
7185/// Counterpart to `policy.register`, which had none: a global policy, once
7186/// registered over the wire, could only be cleared by restarting the daemon, so
7187/// a mistyped or over-broad rule stayed in force and took every other piece of
7188/// in-memory state with it when you finally bounced the process
7189/// (Parslee-ai/car#623). Session-scoped policies could already be dropped
7190/// wholesale by closing the session; this removes one by name.
7191///
7192/// Returns `{ unregistered, removed, scope }`. `removed: 0` means nothing
7193/// matched — reported rather than treated as an error, so a client cleaning up
7194/// can call this unconditionally.
7195async fn handle_policy_unregister(
7196    req: &JsonRpcMessage,
7197    session: &crate::session::ClientSession,
7198) -> Result<Value, String> {
7199    let name = req
7200        .params
7201        .get("name")
7202        .and_then(|v| v.as_str())
7203        .ok_or("missing 'name'")?;
7204    let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7205    let removed = session
7206        .runtime
7207        .unregister_policy(name, session_id)
7208        .await
7209        .map_err(|e| e.to_string())?;
7210    Ok(serde_json::json!({
7211        "unregistered": name,
7212        "removed": removed,
7213        "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7214            .unwrap_or_else(|| Value::String("global".to_string())),
7215    }))
7216}
7217
7218/// `policy.list` — what is currently in force. `{ session_id? }`.
7219///
7220/// Without this a client could register a policy but never ask what was
7221/// enforced, so an action rejection could not be explained beyond its own
7222/// message (Parslee-ai/car#623).
7223async fn handle_policy_list(
7224    req: &JsonRpcMessage,
7225    session: &crate::session::ClientSession,
7226) -> Result<Value, String> {
7227    let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7228    let policies = session
7229        .runtime
7230        .list_policies(session_id)
7231        .await
7232        .map_err(|e| e.to_string())?;
7233    Ok(serde_json::json!({
7234        "policies": policies
7235            .into_iter()
7236            .map(|(name, description)| serde_json::json!({
7237                "name": name,
7238                "description": description,
7239            }))
7240            .collect::<Vec<_>>(),
7241        "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7242            .unwrap_or_else(|| Value::String("global".to_string())),
7243    }))
7244}
7245
7246async fn handle_verify(
7247    req: &JsonRpcMessage,
7248    session: &crate::session::ClientSession,
7249) -> Result<Value, String> {
7250    let vr: VerifyRequest =
7251        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7252    // Verify against the full registered schemas so tool_call
7253    // parameters are checked for type mismatches + missing required
7254    // fields, not just tool existence (register_tool_schema's
7255    // documented contract; car-releases#56). The read guard is held
7256    // across the synchronous verify call.
7257    let tools_guard = session.runtime.tools.read().await;
7258    let result = car_verify::verify_with_schemas(
7259        &vr.proposal,
7260        Some(&vr.initial_state),
7261        Some(&tools_guard),
7262        30,
7263    );
7264    drop(tools_guard);
7265    let evidence = serde_json::to_value(&result.evidence).unwrap_or(Value::Null);
7266    serde_json::to_value(VerifyResponse {
7267        valid: result.valid,
7268        issues: result
7269            .issues
7270            .iter()
7271            .map(|i| VerifyIssueProto {
7272                action_id: i.action_id.clone(),
7273                severity: i.severity.clone(),
7274                message: i.message.clone(),
7275                tier: i.tier.as_str().to_string(),
7276            })
7277            .collect(),
7278        simulated_state: result.simulated_state,
7279        execution_levels: result.execution_levels,
7280        conflicts: result.conflicts,
7281        evidence,
7282    })
7283    .map_err(|e| e.to_string())
7284}
7285
7286/// Default recency window for trajectory-derived success rates, in days.
7287///
7288/// A success rate is a claim about how a tool behaves *now*. Unbounded history
7289/// makes it progressively less responsive to the recent change an operator is
7290/// usually trying to see, and keeps an outage that was fixed months ago
7291/// depressing the rate forever.
7292const MONTE_CARLO_RATE_WINDOW_DAYS: u32 = 30;
7293
7294/// Params for `verify.monte_carlo`.
7295///
7296/// `tool_success_rates` is optional. When omitted, rates are derived from the
7297/// daemon's trajectory store over the last `rate_window_days` — the point of
7298/// wiring that store into every session's `Runtime`. An explicitly supplied map
7299/// always wins: a caller modelling a hypothetical ("what if this tool were 99%
7300/// reliable?") must be able to override observed history, and per-tool entries
7301/// merge over the derived ones rather than replacing the whole map.
7302#[derive(Debug, Deserialize)]
7303struct MonteCarloParams {
7304    proposal: car_ir::ActionProposal,
7305    #[serde(default)]
7306    initial_state: HashMap<String, Value>,
7307    #[serde(default)]
7308    tool_success_rates: HashMap<String, f64>,
7309    #[serde(default)]
7310    goal: Option<car_verify::GoalCondition>,
7311    #[serde(default)]
7312    config: car_verify::MonteCarloConfig,
7313    /// Recency window for derived rates. Defaults to
7314    /// [`MONTE_CARLO_RATE_WINDOW_DAYS`]. Set `0` to skip derivation entirely
7315    /// and use only what the caller passed.
7316    #[serde(default)]
7317    rate_window_days: Option<u32>,
7318}
7319
7320async fn handle_verify_monte_carlo(
7321    req: &JsonRpcMessage,
7322    session: &crate::session::ClientSession,
7323) -> Result<Value, String> {
7324    let p: MonteCarloParams =
7325        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7326
7327    let window = p.rate_window_days.unwrap_or(MONTE_CARLO_RATE_WINDOW_DAYS);
7328    let derived = if window > 0 {
7329        session.runtime.tool_feedback(window)
7330    } else {
7331        None
7332    };
7333
7334    // Caller-supplied entries win per tool, so a hypothetical can override one
7335    // tool's observed rate without discarding the evidence for the others.
7336    let mut rates = derived
7337        .as_ref()
7338        .map(|f| f.tool_success_rates.clone())
7339        .unwrap_or_default();
7340    let overridden: Vec<String> = p
7341        .tool_success_rates
7342        .keys()
7343        .filter(|t| rates.contains_key(*t))
7344        .cloned()
7345        .collect();
7346    rates.extend(p.tool_success_rates.iter().map(|(k, v)| (k.clone(), *v)));
7347
7348    let result = car_verify::simulate_monte_carlo(
7349        &p.proposal,
7350        Some(&p.initial_state),
7351        &rates,
7352        p.goal.as_ref(),
7353        &p.config,
7354    );
7355
7356    // Where every rate actually came from. A probability with no provenance is
7357    // not actionable — the caller cannot tell 0.9-from-500-observations from
7358    // 0.9-because-nothing-was-recorded, and those warrant very different
7359    // confidence in the verdict.
7360    let counts = derived
7361        .as_ref()
7362        .map(|f| f.tool_dispatch_counts.clone())
7363        .unwrap_or_default();
7364    let mut tools: Vec<Value> = p
7365        .proposal
7366        .actions
7367        .iter()
7368        .filter(|a| a.action_type == car_ir::ActionType::ToolCall)
7369        .filter_map(|a| a.tool.clone())
7370        .collect::<std::collections::BTreeSet<_>>()
7371        .into_iter()
7372        .map(|tool| {
7373            let caller_set = p.tool_success_rates.contains_key(&tool);
7374            let observed = counts.get(&tool).copied();
7375            let source = if caller_set {
7376                "caller"
7377            } else if observed.is_some() {
7378                "trajectories"
7379            } else {
7380                "default"
7381            };
7382            serde_json::json!({
7383                "tool": tool,
7384                "rate": rates.get(&tool).copied()
7385                    .unwrap_or(p.config.default_success_rate),
7386                "source": source,
7387                "succeeded": observed.map(|(s, _)| s),
7388                "dispatched": observed.map(|(_, d)| d),
7389            })
7390        })
7391        .collect();
7392    tools.sort_by(|a, b| a["tool"].as_str().cmp(&b["tool"].as_str()));
7393
7394    let mut out = serde_json::to_value(&result).map_err(|e| e.to_string())?;
7395    if let Some(obj) = out.as_object_mut() {
7396        obj.insert(
7397            "rate_provenance".to_string(),
7398            serde_json::json!({
7399                "window_days": window,
7400                "trajectories_available": derived.is_some(),
7401                "overridden_by_caller": overridden,
7402                "tools": tools,
7403            }),
7404        );
7405    }
7406    Ok(out)
7407}
7408
7409// --- sync.* / lease.* : multi-device sync + execution lease (B6) ---
7410//
7411// Thin param-parsers over the daemon-held `SyncSubsystem` (one per daemon = one
7412// device). Each locks the subsystem's tokio mutex and calls a `&mut self`
7413// method; the sync mechanics, fold, fence, and lease coordination all live in
7414// `crate::sync`. See `docs/proposals/multi-device-sync.md` §B6.
7415
7416async fn handle_sync_status(state: &ServerState) -> Result<Value, String> {
7417    let sub = state.sync_subsystem()?;
7418    let mut s = sub.lock().await;
7419    s.status()
7420}
7421
7422/// `sync.knowledge` — the read side of the assistant's synced memory. Returns
7423/// the folded knowledge facts (`{subject, body}`, ascending by hlc). With
7424/// `pump: true` it pumps-then-reads under ONE lock acquisition, so recall sees a
7425/// fresh pull with no interleave window; the pump is best-effort (a relay hiccup
7426/// still returns the already-folded facts). Read-only otherwise.
7427async fn handle_sync_knowledge(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7428    let pump = req
7429        .params
7430        .get("pump")
7431        .and_then(Value::as_bool)
7432        .unwrap_or(false);
7433    // Personal knowledge first; release its guard before touching the org one.
7434    let personal = {
7435        let sub = state.sync_subsystem()?;
7436        let mut s = sub.lock().await;
7437        if pump {
7438            let _ = s.pump();
7439        }
7440        s.knowledge()
7441    };
7442    // Merge the shared org subsystem's knowledge when opted in (8b): personal ∪ org.
7443    // With org-scope OFF, `org_subsystem()` is None → personal-only, byte-identical.
7444    let org = match state.org_subsystem() {
7445        Some(org_sub) => {
7446            let mut s = org_sub.lock().await;
7447            if pump {
7448                let _ = s.pump();
7449            }
7450            Some(s.knowledge())
7451        }
7452        None => None,
7453    };
7454    Ok(serde_json::json!({ "facts": merge_knowledge(personal, org) }))
7455}
7456
7457/// Merge personal knowledge with the opted-in org knowledge (personal ∪ org).
7458/// `None` org → personal unchanged (the org-scope-off invariant). Pure, so it is
7459/// unit-testable without a daemon.
7460///
7461/// ORDER IS LOAD-BEARING (linus): each scope's `knowledge()` is ascending by
7462/// `(hlc, op_id)`, but the two are NOT globally HLC-ordered, and the recall
7463/// reducer (`car-cli reduce_newest_per_subject`) is POSITIONAL last-wins — it
7464/// keeps the LAST occurrence per subject, trusting ascending order. So this
7465/// concatenates ORG FIRST, PERSONAL LAST: within a scope newest still wins, and
7466/// for a subject present in BOTH scopes the personal entry lands last → PERSONAL
7467/// wins. That is deliberate — it matches the recall path's Option-B policy
7468/// ("a peer's newer edit to a locally-held subject is intentionally NOT applied";
7469/// personal/local is authoritative). Do NOT flip to personal-first (that would
7470/// silently make a staler org fact shadow a newer personal one).
7471fn merge_knowledge(personal: Vec<Value>, org: Option<Vec<Value>>) -> Vec<Value> {
7472    match org {
7473        Some(mut org) => {
7474            org.extend(personal);
7475            org
7476        }
7477        None => personal,
7478    }
7479}
7480
7481async fn handle_sync_append(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7482    let surface_str = req
7483        .params
7484        .get("surface")
7485        .and_then(Value::as_str)
7486        .ok_or("missing 'surface'")?;
7487    let surface = crate::sync::parse_surface(surface_str)?;
7488    let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
7489    let scope = crate::sync::parse_scope(&req.params);
7490    let sub = state.subsystem_for_scope(&scope)?;
7491    let mut s = sub.lock().await;
7492    s.append(scope, surface, payload)
7493}
7494
7495async fn handle_sync_record_turn(
7496    req: &JsonRpcMessage,
7497    state: &ServerState,
7498) -> Result<Value, String> {
7499    let conversation_id = req
7500        .params
7501        .get("conversation_id")
7502        .and_then(Value::as_str)
7503        .unwrap_or("");
7504    let role = req
7505        .params
7506        .get("role")
7507        .and_then(Value::as_str)
7508        .unwrap_or("user");
7509    let content = req
7510        .params
7511        .get("content")
7512        .and_then(Value::as_str)
7513        .unwrap_or("");
7514    let tool_calls = req
7515        .params
7516        .get("tool_calls")
7517        .and_then(Value::as_array)
7518        .cloned()
7519        .unwrap_or_default();
7520    let tool_use_id = req.params.get("tool_use_id").and_then(Value::as_str);
7521    let timestamp = req
7522        .params
7523        .get("timestamp")
7524        .and_then(Value::as_u64)
7525        .unwrap_or(0);
7526    let scope = crate::sync::parse_scope(&req.params);
7527    let sub = state.subsystem_for_scope(&scope)?;
7528    let mut s = sub.lock().await;
7529    s.record_turn(
7530        scope,
7531        conversation_id,
7532        role,
7533        content,
7534        tool_calls,
7535        tool_use_id,
7536        timestamp,
7537    )
7538}
7539
7540async fn handle_sync_record_intent(
7541    req: &JsonRpcMessage,
7542    state: &ServerState,
7543    session: &crate::session::ClientSession,
7544) -> Result<Value, String> {
7545    let agent_id = req
7546        .params
7547        .get("agent_id")
7548        .and_then(Value::as_str)
7549        .ok_or("missing 'agent_id'")?;
7550    require_sync_agent_or_host(session, state, "sync.record_intent", agent_id).await?;
7551    record_sync_intent(req, state, agent_id).await
7552}
7553
7554async fn record_sync_intent(
7555    req: &JsonRpcMessage,
7556    state: &ServerState,
7557    agent_id: &str,
7558) -> Result<Value, String> {
7559    let run_id = req
7560        .params
7561        .get("run_id")
7562        .and_then(Value::as_str)
7563        .ok_or("missing 'run_id'")?;
7564    let epoch = req
7565        .params
7566        .get("epoch")
7567        .and_then(Value::as_u64)
7568        .ok_or("missing 'epoch'")?;
7569    let status_str = req
7570        .params
7571        .get("status")
7572        .and_then(Value::as_str)
7573        .ok_or("missing 'status'")?;
7574    let status = crate::sync::parse_intent_status(status_str)?;
7575    let intent = car_sync::Intent::new(agent_id, run_id, epoch, status);
7576    let scope = crate::sync::parse_scope(&req.params);
7577    let sub = state.subsystem_for_scope(&scope)?;
7578    let mut s = sub.lock().await;
7579    s.record_intent(scope, &intent)
7580}
7581
7582async fn handle_sync_pump(state: &ServerState) -> Result<Value, String> {
7583    // Pump the personal subsystem first and RELEASE its guard before touching the
7584    // org one — never hold both `tokio::Mutex` subsystem guards across an await
7585    // (deadlock vector). A personal-pump failure is fatal, as before.
7586    let user_result = {
7587        let sub = state.sync_subsystem()?;
7588        let mut s = sub.lock().await;
7589        s.pump()?
7590    };
7591    // Then the shared org subsystem, if opted in (8a). Its leases are independent
7592    // of the personal scope, so there is no cross-scope ordering requirement. An
7593    // org-pump failure must NOT suppress the personal-pump result — surface both.
7594    let Some(org_sub) = state.org_subsystem() else {
7595        return Ok(user_result);
7596    };
7597    let org_result = {
7598        let mut s = org_sub.lock().await;
7599        s.pump()
7600    };
7601    match org_result {
7602        Ok(org) => Ok(serde_json::json!({ "user": user_result, "org": org })),
7603        Err(e) => Ok(serde_json::json!({ "user": user_result, "org_error": e })),
7604    }
7605}
7606
7607async fn handle_sync_checkpoint(state: &ServerState) -> Result<Value, String> {
7608    let sub = state.sync_subsystem()?;
7609    let mut s = sub.lock().await;
7610    s.checkpoint()
7611}
7612
7613async fn handle_sync_rebase(state: &ServerState) -> Result<Value, String> {
7614    let sub = state.sync_subsystem()?;
7615    let mut s = sub.lock().await;
7616    s.rebase()
7617}
7618
7619async fn handle_sync_transcript(
7620    req: &JsonRpcMessage,
7621    state: &ServerState,
7622) -> Result<Value, String> {
7623    let conversation_id = req
7624        .params
7625        .get("conversation_id")
7626        .and_then(Value::as_str)
7627        .unwrap_or("");
7628    let sub = state.sync_subsystem()?;
7629    let s = sub.lock().await;
7630    Ok(s.transcript(conversation_id))
7631}
7632
7633async fn handle_sync_resume(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7634    let conversation_id = req
7635        .params
7636        .get("conversation_id")
7637        .and_then(Value::as_str)
7638        .unwrap_or("");
7639    let sub = state.sync_subsystem()?;
7640    let s = sub.lock().await;
7641    s.resume(conversation_id)
7642}
7643
7644async fn handle_sync_assistant_checkpoint_put(
7645    req: &JsonRpcMessage,
7646    state: &ServerState,
7647) -> Result<Value, String> {
7648    let raw = req
7649        .params
7650        .get("checkpoint")
7651        .cloned()
7652        .ok_or("missing 'checkpoint'")?;
7653    let checkpoint =
7654        serde_json::from_value(raw).map_err(|e| format!("invalid assistant checkpoint: {e}"))?;
7655    let sub = state.sync_subsystem()?;
7656    let mut s = sub.lock().await;
7657    s.assistant_checkpoint_put(checkpoint)
7658}
7659
7660async fn handle_sync_assistant_checkpoint_get(
7661    req: &JsonRpcMessage,
7662    state: &ServerState,
7663) -> Result<Value, String> {
7664    let session_id = req
7665        .params
7666        .get("session_id")
7667        .and_then(Value::as_str)
7668        .ok_or("missing 'session_id'")?;
7669    let sub = state.sync_subsystem()?;
7670    let s = sub.lock().await;
7671    serde_json::to_value(s.assistant_checkpoint_get(session_id)?)
7672        .map_err(|e| format!("serialize assistant checkpoint: {e}"))
7673}
7674
7675async fn handle_sync_assistant_action_put(
7676    req: &JsonRpcMessage,
7677    state: &ServerState,
7678) -> Result<Value, String> {
7679    let raw = req
7680        .params
7681        .get("record")
7682        .cloned()
7683        .ok_or("missing 'record'")?;
7684    let record = serde_json::from_value(raw)
7685        .map_err(|e| format!("invalid supervised action record: {e}"))?;
7686    let sub = state.sync_subsystem()?;
7687    let mut s = sub.lock().await;
7688    s.assistant_action_put(record)
7689}
7690
7691async fn handle_sync_assistant_action_get(
7692    req: &JsonRpcMessage,
7693    state: &ServerState,
7694) -> Result<Value, String> {
7695    let action_id = req
7696        .params
7697        .get("action_id")
7698        .and_then(Value::as_str)
7699        .ok_or("missing 'action_id'")?;
7700    let sub = state.sync_subsystem()?;
7701    let s = sub.lock().await;
7702    serde_json::to_value(s.assistant_action_get(action_id)?)
7703        .map_err(|e| format!("serialize supervised action record: {e}"))
7704}
7705
7706async fn handle_sync_fence_check(
7707    req: &JsonRpcMessage,
7708    state: &ServerState,
7709    session: &crate::session::ClientSession,
7710) -> Result<Value, String> {
7711    let agent_id = req
7712        .params
7713        .get("agent_id")
7714        .and_then(Value::as_str)
7715        .ok_or("missing 'agent_id'")?;
7716    require_sync_agent_or_host(session, state, "sync.fence_check", agent_id).await?;
7717    check_sync_fence(req, state, agent_id).await
7718}
7719
7720async fn check_sync_fence(
7721    req: &JsonRpcMessage,
7722    state: &ServerState,
7723    agent_id: &str,
7724) -> Result<Value, String> {
7725    let run_id = req
7726        .params
7727        .get("run_id")
7728        .and_then(Value::as_str)
7729        .ok_or("missing 'run_id'")?;
7730    let epoch = req
7731        .params
7732        .get("epoch")
7733        .and_then(Value::as_u64)
7734        .ok_or("missing 'epoch'")?;
7735    let sub = state.sync_subsystem()?;
7736    let mut s = sub.lock().await;
7737    s.fence_check(agent_id, run_id, epoch)
7738}
7739
7740async fn handle_lease_acquire(
7741    req: &JsonRpcMessage,
7742    state: &ServerState,
7743    session: &crate::session::ClientSession,
7744) -> Result<Value, String> {
7745    let agent_id = req
7746        .params
7747        .get("agent_id")
7748        .and_then(Value::as_str)
7749        .ok_or("missing 'agent_id'")?;
7750    require_sync_agent_or_host(session, state, "lease.acquire", agent_id).await?;
7751    acquire_lease(req, state, agent_id).await
7752}
7753
7754async fn acquire_lease(
7755    req: &JsonRpcMessage,
7756    state: &ServerState,
7757    agent_id: &str,
7758) -> Result<Value, String> {
7759    let ttl_ms = req
7760        .params
7761        .get("ttl_ms")
7762        .and_then(Value::as_u64)
7763        .unwrap_or(30_000);
7764    let sub = state.sync_subsystem()?;
7765    let mut s = sub.lock().await;
7766    s.lease_acquire(agent_id, ttl_ms)
7767}
7768
7769async fn handle_lease_renew(
7770    req: &JsonRpcMessage,
7771    state: &ServerState,
7772    session: &crate::session::ClientSession,
7773) -> Result<Value, String> {
7774    let agent_id = req
7775        .params
7776        .get("agent_id")
7777        .and_then(Value::as_str)
7778        .ok_or("missing 'agent_id'")?;
7779    require_sync_agent_or_host(session, state, "lease.renew", agent_id).await?;
7780    renew_lease(req, state, agent_id).await
7781}
7782
7783async fn renew_lease(
7784    req: &JsonRpcMessage,
7785    state: &ServerState,
7786    agent_id: &str,
7787) -> Result<Value, String> {
7788    let epoch = req
7789        .params
7790        .get("epoch")
7791        .and_then(Value::as_u64)
7792        .ok_or("missing 'epoch'")?;
7793    let ttl_ms = req
7794        .params
7795        .get("ttl_ms")
7796        .and_then(Value::as_u64)
7797        .unwrap_or(30_000);
7798    let sub = state.sync_subsystem()?;
7799    let mut s = sub.lock().await;
7800    s.lease_renew(agent_id, epoch, ttl_ms)
7801}
7802
7803async fn handle_lease_release(
7804    req: &JsonRpcMessage,
7805    state: &ServerState,
7806    session: &crate::session::ClientSession,
7807) -> Result<Value, String> {
7808    let agent_id = req
7809        .params
7810        .get("agent_id")
7811        .and_then(Value::as_str)
7812        .ok_or("missing 'agent_id'")?;
7813    require_sync_agent_or_host(session, state, "lease.release", agent_id).await?;
7814    release_lease(req, state, agent_id).await
7815}
7816
7817async fn release_lease(
7818    req: &JsonRpcMessage,
7819    state: &ServerState,
7820    agent_id: &str,
7821) -> Result<Value, String> {
7822    let epoch = req
7823        .params
7824        .get("epoch")
7825        .and_then(Value::as_u64)
7826        .ok_or("missing 'epoch'")?;
7827    let sub = state.sync_subsystem()?;
7828    let mut s = sub.lock().await;
7829    s.lease_release(agent_id, epoch)
7830}
7831
7832async fn handle_lease_status(
7833    req: &JsonRpcMessage,
7834    state: &ServerState,
7835    bound_agent: Option<&str>,
7836) -> Result<Value, String> {
7837    let agent_id = req
7838        .params
7839        .get("agent_id")
7840        .and_then(Value::as_str)
7841        .ok_or("missing 'agent_id'")?;
7842    // Reading a lease is the same identity parameter as taking one, and the
7843    // answer names the holder and its fencing epoch — everything needed to
7844    // decide when to try stealing it.
7845    require_own_agent(bound_agent, "lease.status", agent_id)?;
7846    let sub = state.sync_subsystem()?;
7847    let mut s = sub.lock().await;
7848    s.lease_status(agent_id)
7849}
7850
7851/// Host-only, aggregate process-lifetime secret-store activity. The public
7852/// value is intentionally the exact five-counter struct from `car-secrets`:
7853/// no service, key, path, value, identity, or credential state is available to
7854/// serialize here.
7855fn handle_secret_store_activity(
7856    session: &crate::session::ClientSession,
7857    state: &ServerState,
7858) -> Result<Value, String> {
7859    require_approval_authority(session, state)?;
7860    serde_json::to_value(car_secrets::secret_store_activity()).map_err(|error| error.to_string())
7861}
7862
7863// --- permission.* : per-session permission-tier gate (survey §3.4.3/§5.2.5) ---
7864
7865// The canonical value lives in the leaf `car-proto` crate so the lightweight
7866// daemon client can consume it without creating car-server-core →
7867// car-ffi-common → car-daemon-client → car-server-core. Re-exporting it beside
7868// the authority gate makes this the daemon's public authorization contract.
7869pub use car_proto::HOST_MANAGEMENT_METHODS;
7870
7871/// Authority gate for the *mutating* permission methods (set_tier, approve,
7872/// reject). When the daemon runs under a host token (the CarHost regime,
7873/// where there is a distinguished human-management client), only that host
7874/// connection may change a session's tier or record approvals — a
7875/// registered agent connection must not self-elevate or self-approve, which
7876/// would make the §5.2.5 gate theater (neo review #3). In pure-dev /
7877/// embedder mode (no host token configured) the connection governs its own
7878/// session, the developer being the authority.
7879fn require_approval_authority(
7880    session: &crate::session::ClientSession,
7881    state: &ServerState,
7882) -> Result<(), String> {
7883    if state.host_token.get().is_some()
7884        && !session.is_host.load(std::sync::atomic::Ordering::Acquire)
7885    {
7886        return Err("this operation requires the host-management role".into());
7887    }
7888    Ok(())
7889}
7890
7891/// Refuse a bound session acting on an agent that is not itself.
7892///
7893/// Whether a caller-supplied agent id is one this session may act as.
7894/// The bound identity WINS over the parameter — the rule `runs.start` already
7895/// applies to trace attribution (FIX 5). A session that authenticated as an
7896/// agent is authoritative about who it is, so an id naming a *different* agent
7897/// is a forgery attempt and is rejected loudly rather than honored. A matching
7898/// id is redundant and fine.
7899///
7900/// An UNBOUND session passes this identity-only helper. It remains appropriate
7901/// for `agents.wait`/`tail_log` and the lease/sync surfaces, where an unbound
7902/// operator may act on an explicit id. Mutating lifecycle methods use the
7903/// stronger host-aware helpers below: start/stop/restart admit self or host,
7904/// while upsert/install/remove require host authority.
7905///
7906/// Takes the RESOLVED bound id rather than the session: it is the whole input
7907/// the rule needs, and a `ClientSession` cannot be built outside a live
7908/// connection, so this keeps every caller exercisable.
7909///
7910/// Naming the id back is safe here, unlike [`authorize_run_access`]'s FIX 3 —
7911/// the caller supplied it, and the refusal happens before any lookup, so it
7912/// reveals nothing about whether that agent exists.
7913fn require_own_agent(bound: Option<&str>, method: &str, target: &str) -> Result<(), String> {
7914    match bound {
7915        Some(b) if b != target => Err(format!(
7916            "{method} `{target}` does not match this session's bound agent: a supervised \
7917             agent may only act on itself"
7918        )),
7919        _ => Ok(()),
7920    }
7921}
7922
7923/// Start, stop, or restart is available to the bound agent itself and to the
7924/// host authority. An unbound daemon-token client is accepted only in
7925/// dev/embedder mode, where no host token exists and `require_approval_authority`
7926/// deliberately treats the developer as the authority.
7927async fn require_own_agent_or_host(
7928    session: &crate::session::ClientSession,
7929    state: &ServerState,
7930    method: &str,
7931    target: &str,
7932) -> Result<(), String> {
7933    if session.is_host.load(Ordering::Acquire) {
7934        return Ok(());
7935    }
7936    let bound = session.agent_id.lock().await.clone();
7937    match bound.as_deref() {
7938        Some(_) => require_own_agent(bound.as_deref(), method, target),
7939        None if state.host_token.get().is_some() => {
7940            Err("this operation requires the host-management role".into())
7941        }
7942        None => Ok(()),
7943    }
7944}
7945
7946/// Authorize a lease/sync mutation under the connection's actual identity.
7947/// The identity and host rules intentionally match agent lifecycle mutations.
7948async fn require_sync_agent_or_host(
7949    session: &crate::session::ClientSession,
7950    state: &ServerState,
7951    method: &str,
7952    target: &str,
7953) -> Result<(), String> {
7954    require_own_agent_or_host(session, state, method, target).await
7955}
7956
7957/// Agent-definition and removal methods belong to host authority. Preserve
7958/// auth-disabled development mode for unbound callers, but never let a bound
7959/// supervised agent inherit that degraded-mode authority.
7960pub(crate) async fn require_host_lifecycle_authority(
7961    session: &crate::session::ClientSession,
7962    state: &ServerState,
7963) -> Result<(), String> {
7964    require_approval_authority(session, state)?;
7965    if session.agent_id.lock().await.is_some() && !session.is_host.load(Ordering::Acquire) {
7966        return Err("this operation requires the host-management role".into());
7967    }
7968    Ok(())
7969}
7970
7971/// Stronger boundary for daemon-wide agent policy. Even in auth-disabled
7972/// embedder mode, an authenticated lifecycle agent may not mutate or inspect
7973/// host-only exact grants for itself. Development clients remain compatible;
7974/// they are unbound and continue to govern their own daemon when no host token
7975/// is configured.
7976/// Gate for `agent_permissions.*`: approval authority, and not a bound agent.
7977///
7978/// **The second half is escapable and the first half is conditional — read both
7979/// before trusting this.** An agent controls its own environment, so it can
7980/// unset `CAR_AGENT_ID` and reconnect unbound (car#1297). What stops it there is
7981/// [`require_approval_authority`], which only bites when a host token exists:
7982/// on an ordinary desktop install CarHost holds one, `is_host` fails for the
7983/// agent, and the gate holds — `host_token` is a separate `0600` file never
7984/// served over `GET /auth-token`.
7985///
7986/// On a daemon with NO host token — headless, CI, `car-server` started directly
7987/// — approval authority passes for every authenticated client, and unbinding is
7988/// sufficient to reach this surface. That is tracked separately; see the
7989/// per-gate audit in `docs/proposals/agent-binding-trust-boundary.md`.
7990async fn require_agent_permissions_authority(
7991    session: &crate::session::ClientSession,
7992    state: &ServerState,
7993) -> Result<(), String> {
7994    require_approval_authority(session, state)?;
7995    if session.agent_id.lock().await.is_some() {
7996        return Err("this operation requires the host-management role".into());
7997    }
7998    Ok(())
7999}
8000
8001/// Resolve and freeze the callback identity currently attached to `agent_id`.
8002/// The returned lifecycle guard stays held through the policy write, so a
8003/// concurrent `tools.register` / `tools.unregister` / proposal cannot change
8004/// the schema between observation and persistence.
8005async fn live_agent_callback_registration(
8006    state: &ServerState,
8007    agent_id: &str,
8008    tool: &str,
8009) -> Result<
8010    (
8011        Arc<crate::session::ClientSession>,
8012        tokio::sync::OwnedMutexGuard<()>,
8013        String,
8014    ),
8015    String,
8016> {
8017    let client_id = state
8018        .attached_agents
8019        .lock()
8020        .await
8021        .get(agent_id)
8022        .cloned()
8023        .ok_or_else(|| format!("agent '{agent_id}' is not attached"))?;
8024    let target = state
8025        .sessions
8026        .lock()
8027        .await
8028        .get(&client_id)
8029        .cloned()
8030        .ok_or_else(|| format!("attached agent '{agent_id}' has no live session"))?;
8031    let guard = target.run_lifecycle_guard.clone().lock_owned().await;
8032
8033    let still_attached = state
8034        .attached_agents
8035        .lock()
8036        .await
8037        .get(agent_id)
8038        .is_some_and(|current| current == &target.client_id);
8039    let bound_agent = target.agent_id.lock().await.clone();
8040    if !still_attached
8041        || !target
8042            .authenticated
8043            .load(std::sync::atomic::Ordering::Acquire)
8044        || bound_agent.as_deref() != Some(agent_id)
8045    {
8046        return Err(format!(
8047            "agent '{agent_id}' callback session changed while authorizing tool '{tool}'"
8048        ));
8049    }
8050    if target.runtime.registry.get(tool).await.is_some() {
8051        return Err(format!(
8052            "tool '{tool}' is server-owned and is ineligible for agent standing authority"
8053        ));
8054    }
8055    let digest = target
8056        .callback_tool_schema_digests
8057        .read()
8058        .await
8059        .get(tool)
8060        .cloned()
8061        .ok_or_else(|| {
8062            format!(
8063                "tool '{tool}' has no eligible exact reverse-callback registration for agent '{agent_id}'"
8064            )
8065        })?;
8066    Ok((target, guard, digest))
8067}
8068
8069async fn handle_agent_permission_set_tool(
8070    req: &JsonRpcMessage,
8071    state: &ServerState,
8072) -> Result<Value, String> {
8073    let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8074    let mode = crate::agent_permissions::requested_tool_mode(&req.params)?;
8075    if mode == car_policy::ApprovalMode::AlwaysAllow {
8076        let (_target, _guard, digest) =
8077            live_agent_callback_registration(state, agent_id, tool).await?;
8078        crate::agent_permissions::handle_set_tool(req, Some(digest))
8079    } else {
8080        crate::agent_permissions::handle_set_tool(req, None)
8081    }
8082}
8083
8084async fn handle_agent_permission_evaluate_tool(
8085    req: &JsonRpcMessage,
8086    state: &ServerState,
8087) -> Result<Value, String> {
8088    let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8089    let mut value = crate::agent_permissions::handle_evaluate_tool(req)?;
8090    let has_override = value
8091        .get("has_tool_override")
8092        .and_then(Value::as_bool)
8093        .unwrap_or(false);
8094    let mode = value.get("mode").and_then(Value::as_str);
8095    let stored_digest = value.get("schema_digest").and_then(Value::as_str);
8096    let live_digest = if mode == Some("always_allow") && has_override {
8097        live_agent_callback_registration(state, agent_id, tool)
8098            .await
8099            .ok()
8100            .map(|(_target, _guard, digest)| digest)
8101    } else {
8102        None
8103    };
8104    let schema_bound = stored_digest.is_some();
8105    let active = if mode == Some("always_allow") {
8106        stored_digest
8107            .zip(live_digest.as_deref())
8108            .is_some_and(|(stored, live)| stored == live)
8109    } else {
8110        has_override
8111    };
8112    let object = value
8113        .as_object_mut()
8114        .ok_or_else(|| "agent permission evaluation returned a non-object".to_string())?;
8115    object.insert("active".into(), active.into());
8116    object.insert("schema_bound".into(), schema_bound.into());
8117    object.insert("schema_digest_present".into(), schema_bound.into());
8118    Ok(value)
8119}
8120
8121/// The authenticated principal for this connection, stamped into approval
8122/// audit records server-side so the "who decided" field can't be forged by
8123/// the caller (neo review #5c). Prefers the bound agent identity, else the
8124/// connection id.
8125/// Public wrapper over [`session_principal`] for the peer-messaging module.
8126///
8127/// Exposed rather than duplicated: the whole value of the principal is that
8128/// exactly one place derives it, server-side, from the bound agent identity.
8129pub async fn session_principal_for_peers(session: &crate::session::ClientSession) -> String {
8130    session_principal(session).await
8131}
8132
8133async fn session_principal(session: &crate::session::ClientSession) -> String {
8134    if let Some(agent) = session.agent_id.lock().await.clone() {
8135        format!("agent:{agent}")
8136    } else {
8137        format!("conn:{}", session.client_id)
8138    }
8139}
8140
8141/// Return the session's current granted standing tier.
8142async fn handle_permission_get_tier(
8143    session: &crate::session::ClientSession,
8144) -> Result<Value, String> {
8145    let tier = session.permission_gate.read().await.granted_tier();
8146    Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8147}
8148
8149/// Set the session's granted standing tier. Params: `{ tier }`.
8150async fn handle_permission_set_tier(
8151    req: &JsonRpcMessage,
8152    session: &crate::session::ClientSession,
8153    state: &ServerState,
8154) -> Result<Value, String> {
8155    require_approval_authority(session, state)?;
8156    let tier_str = req
8157        .params
8158        .get("tier")
8159        .and_then(|v| v.as_str())
8160        .ok_or("missing 'tier'")?;
8161    let tier = car_policy::PermissionTier::from_str_opt(tier_str)
8162        .ok_or_else(|| format!("invalid tier '{tier_str}'"))?;
8163    session.permission_gate.write().await.set_granted_tier(tier);
8164    Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8165}
8166
8167// --- iMessage approval-transport config surface (`messaging.*`) ---
8168//
8169// The host/local-auth-gated config channel for the iMessage approval
8170// transport (Unit 3). These handlers are the ONLY allowlist/config-mutation
8171// path in the system. Every one of them rejects a caller that is not
8172// `session.is_host` and is not presenting the per-launch local auth token —
8173// the SAME trust root (`require_approval_authority`) that guards all config
8174// mutation today (permission-tier / approval changes). An inbound iMessage
8175// carries neither credential, so there is no inbound→config edge anywhere.
8176//
8177// DEGRADED-MODE CAVEAT (inherited platform behavior, NOT specific to this
8178// surface): `require_approval_authority` is a no-op when NO host token is
8179// configured (pure-dev / `--no-auth` / a failed boot token-write) — in that
8180// regime the connection governs its own session, the developer being the
8181// authority, so these `messaging.*` methods become callable by any local
8182// connection. This is identical to how `permission.*` (set_tier/approve/reject)
8183// already behaves and is the deliberate platform gate pattern; we do NOT
8184// diverge from it here. The core anti-injection invariant still holds
8185// regardless: an inbound iMessage carries no WS session at all, so even in the
8186// fail-open regime it can never reach these setters — the degraded mode only
8187// relaxes WHICH local connections are trusted, never opens an inbound→config
8188// edge.
8189//
8190// The store is constructed per-call from the CAR state root (the same pattern
8191// `models.register` uses for its per-call `models.json`), keeping these
8192// handlers state-free and side-effect-local.
8193
8194/// Build the durable messaging config store rooted at the daemon's state root.
8195/// `from_home` resolves it through `car_home` — `CAR_HOME` when set, otherwise
8196/// `~/.car` from `HOME`/`USERPROFILE`.
8197fn messaging_store() -> crate::messaging_config::MessagingConfigStore {
8198    crate::messaging_config::MessagingConfigStore::from_home()
8199}
8200
8201/// Resolve the optional `channel` string from a request's params to a
8202/// [`ChannelId`]. **Absent ⇒ iMessage** (back-compat for the #403 surface and
8203/// the bindings, which have no `channel` field). An unrecognized channel string
8204/// is an error rather than a silent default (fail-closed on a typo). Reads the
8205/// top-level `channel` field directly so it works for both the `set` request
8206/// (which has a typed field) and the param-light `get`/`pairing` methods.
8207fn messaging_channel_from_params(
8208    params: &Value,
8209) -> Result<crate::messaging_config::ChannelId, String> {
8210    match params.get("channel") {
8211        None | Some(Value::Null) => Ok(crate::messaging_config::ChannelId::IMessage),
8212        Some(Value::String(s)) => crate::messaging_config::ChannelId::from_str_opt(s)
8213            .ok_or_else(|| format!("unknown messaging channel '{s}'")),
8214        Some(other) => Err(format!("messaging channel must be a string, got {other}")),
8215    }
8216}
8217
8218/// Project a `MessagingConfig` load into the wire view for `channel` (without
8219/// leaking the active pairing code — that is `messaging.pairing.status` only).
8220///
8221/// Per-channel since Unit 2; an absent `channel` in the request defaults to
8222/// iMessage so the existing 4 methods and their bindings keep working unchanged.
8223fn messaging_config_view(
8224    store: &crate::messaging_config::MessagingConfigStore,
8225    channel: crate::messaging_config::ChannelId,
8226) -> Result<car_proto::MessagingConfigView, String> {
8227    let cfg = store.load()?.channel(channel);
8228    Ok(car_proto::MessagingConfigView {
8229        channel: channel.as_str().to_string(),
8230        enabled: cfg.enabled,
8231        allowlisted_handles: cfg.allowlisted_handles,
8232        pairing_active: cfg.active_pairing_code.is_some(),
8233    })
8234}
8235
8236/// `messaging.config.get` — return the current transport config view for the
8237/// (optional) channel (default iMessage). Host/local-auth gated (a read of the
8238/// trust state is host-only).
8239async fn handle_messaging_config_get(
8240    req: &JsonRpcMessage,
8241    session: &crate::session::ClientSession,
8242    state: &ServerState,
8243) -> Result<Value, String> {
8244    require_approval_authority(session, state)?;
8245    let channel = messaging_channel_from_params(&req.params)?;
8246    let view = messaging_config_view(&messaging_store(), channel)?;
8247    serde_json::to_value(view).map_err(|e| e.to_string())
8248}
8249
8250/// `messaging.config.set` — mutate the transport config (enabled flag and
8251/// allowlist) for the (optional) channel (default iMessage). Host/local-auth
8252/// gated; this is the ONLY allowlist/config mutation path. Params:
8253/// `MessagingConfigSetRequest`.
8254async fn handle_messaging_config_set(
8255    req: &JsonRpcMessage,
8256    session: &crate::session::ClientSession,
8257    state: &ServerState,
8258) -> Result<Value, String> {
8259    require_approval_authority(session, state)?;
8260    let set: car_proto::MessagingConfigSetRequest = serde_json::from_value(req.params.clone())
8261        .map_err(|e| format!("invalid messaging.config.set params: {e}"))?;
8262    // Resolve the channel through the shared `messaging_channel_from_params`
8263    // (used by all four messaging handlers) rather than `set.channel`: the
8264    // resolver is the single place that maps absent ⇒ iMessage AND rejects an
8265    // unknown/non-string `channel` with a clear error. `set.channel` stays a
8266    // documented part of the request shape (it is what the absent-⇒-iMessage
8267    // default is named after) but is not re-parsed here.
8268    let channel = messaging_channel_from_params(&req.params)?;
8269    let store = messaging_store();
8270    // Slack token provisioning (MC-9 + MC-6). The `require_approval_authority`
8271    // host-gate above has ALREADY fired (provisioning is host-only — an inbound
8272    // message carries no WS session, so it can never reach here). When BOTH
8273    // `bot_token` + `app_token` are present on the SLACK channel, write the
8274    // bearer values to the OS keychain via `provision_slack_tokens` and persist
8275    // ONLY the returned keychain key-REFERENCES into the config — the bearer
8276    // values never touch `messaging.json` and never echo back in the view.
8277    // Both-or-neither: a single token present (or the wrong channel) is ignored
8278    // here so the normal enable/allowlist set stays back-compatible.
8279    if let (Some(bot), Some(app)) = (set.bot_token.as_deref(), set.app_token.as_deref()) {
8280        if channel == crate::messaging_config::ChannelId::Slack {
8281            let secrets = car_secrets::SecretStore::new();
8282            let refs = crate::slack_adapter::provision_slack_tokens(&secrets, bot, app)
8283                .map_err(|e| format!("provision slack tokens: {e}"))?;
8284            store.set_slack_token_ref_for(
8285                channel,
8286                crate::messaging_config::SlackTokenRef {
8287                    bot_token_key: refs.bot_token_key,
8288                    app_token_key: refs.app_token_key,
8289                },
8290            )?;
8291        }
8292    }
8293    // Slack post-channel id (S1). CONFIG, not a secret: the host-gated call
8294    // persists it INTO `messaging.json` (never the keychain) so the boot path
8295    // can construct the adapter with the channel to post into. Slack channel
8296    // only; a non-empty value is required (an empty string would post to
8297    // `channel:""` → `channel_not_found`).
8298    if let Some(slack_channel) = set.slack_channel.as_deref() {
8299        if channel == crate::messaging_config::ChannelId::Slack && !slack_channel.is_empty() {
8300            store.set_slack_channel_id_for(channel, slack_channel)?;
8301        }
8302    }
8303    if let Some(enabled) = set.enabled {
8304        // U1: detect an OFF→ON transition so we can spawn the channel's watcher
8305        // immediately (no daemon/app restart). Read the prior flag BEFORE the
8306        // mutation; only an actual off→on edge triggers the spawn (an on→on
8307        // re-set is a no-op via the supervisor's idempotency guard anyway).
8308        let was_enabled = store.is_enabled_for(channel).unwrap_or(false);
8309        store.set_enabled_for(channel, enabled)?;
8310        if enabled && !was_enabled {
8311            if let Some(sup) = state.channel_supervisor.get() {
8312                // Spawn the watcher now. A spawn error (e.g. Slack runtime-enable,
8313                // which is unsupported in this build because its outbound prompt
8314                // driver is the boot-time FanoutCoordinator) does NOT hard-500 the
8315                // whole call: config is config, so the enabled flag stays
8316                // persisted (the user asked to enable it), but the supervisor
8317                // rolls back its live-set reservation so the channel is NOT marked
8318                // watcher_running. `messaging.status` then honestly reports
8319                // `watcher_running:false` — never claiming a channel is live when
8320                // its outbound cannot fire. The failure is logged, not swallowed
8321                // silently, and surfaced via `messaging.status` rather than as a
8322                // transport error on this config write.
8323                if let Err(e) = sup.ensure_spawned(channel) {
8324                    tracing::warn!(
8325                        channel = channel.as_str(),
8326                        error = %e,
8327                        "channel enabled in config but its runtime watcher could not start; \
8328                         messaging.status will report watcher_running:false"
8329                    );
8330                }
8331            }
8332        }
8333    }
8334    if let Some(handles) = set.allowlisted_handles {
8335        store.set_allowlist_for(channel, handles)?;
8336    }
8337    for handle in &set.add_handles {
8338        store.add_handle_for(channel, handle)?;
8339    }
8340    for handle in &set.remove_handles {
8341        store.remove_handle_for(channel, handle)?;
8342    }
8343    let view = messaging_config_view(&store, channel)?;
8344    serde_json::to_value(view).map_err(|e| e.to_string())
8345}
8346
8347/// `messaging.pairing.start` — mint a fresh high-entropy pairing code for the
8348/// (optional) channel (default iMessage), persist it as that channel's active
8349/// code, and return it for display ONLY in the local UI. Host/local-auth gated.
8350/// Rotates any prior active code on that channel.
8351async fn handle_messaging_pairing_start(
8352    req: &JsonRpcMessage,
8353    session: &crate::session::ClientSession,
8354    state: &ServerState,
8355) -> Result<Value, String> {
8356    require_approval_authority(session, state)?;
8357    let channel = messaging_channel_from_params(&req.params)?;
8358    let store = messaging_store();
8359    let code = store.mint_pairing_code_for(channel)?;
8360    let view = messaging_config_view(&store, channel)?;
8361    let resp = car_proto::MessagingPairingStartResponse {
8362        pairing_code: code,
8363        config: view,
8364    };
8365    serde_json::to_value(resp).map_err(|e| e.to_string())
8366}
8367
8368/// `messaging.pairing.status` — report whether a pairing is in flight on the
8369/// (optional) channel (default iMessage) and (host/local-auth gated only)
8370/// re-surface the active code so the local UI can re-display it after a reload.
8371/// Never exposed over any inbound channel.
8372async fn handle_messaging_pairing_status(
8373    req: &JsonRpcMessage,
8374    session: &crate::session::ClientSession,
8375    state: &ServerState,
8376) -> Result<Value, String> {
8377    require_approval_authority(session, state)?;
8378    let channel = messaging_channel_from_params(&req.params)?;
8379    let code = messaging_store().active_pairing_code_for(channel)?;
8380    let resp = car_proto::MessagingPairingStatusResponse {
8381        pairing_active: code.is_some(),
8382        pairing_code: code,
8383    };
8384    serde_json::to_value(resp).map_err(|e| e.to_string())
8385}
8386
8387/// Daemon-side Full Disk Access probe for `messaging.status` (U2, R-B). The
8388/// daemon is the chat.db reader, so this reflects the DAEMON's access — not the
8389/// host app's. iMessage probes the real Messages library; other channels carry
8390/// no chat.db dependency and are always "readable". On non-macOS there is no
8391/// chat.db, so iMessage is reported readable too (the channel reads nothing
8392/// there anyway, gated by `cfg(target_os = "macos")` in the adapter).
8393#[allow(unused_variables)]
8394fn messaging_fda_readable(channel: crate::messaging_config::ChannelId) -> bool {
8395    match channel {
8396        crate::messaging_config::ChannelId::IMessage => {
8397            #[cfg(target_os = "macos")]
8398            {
8399                car_ffi_common::integrations::messages_fda_readable()
8400            }
8401            #[cfg(not(target_os = "macos"))]
8402            {
8403                true
8404            }
8405        }
8406        crate::messaging_config::ChannelId::Slack => true,
8407    }
8408}
8409
8410/// `messaging.status` — return the real runtime liveness of the (optional)
8411/// channel (default iMessage) so the host UI can render a SINGLE readiness state
8412/// (U2). Host/local-auth gated EXACTLY like the other `messaging.*` methods — an
8413/// inbound message carries no host credential, so it can never call this.
8414///
8415/// `enabled` + `paired` come from the config store; `watcher_running` from the
8416/// runtime channel supervisor's live set (U1); `fda_readable` from a daemon-side
8417/// probe (R-B); `last_send_*` / `last_error` from the supervisor's per-channel
8418/// liveness (written by the send path, U3). When the supervisor is not installed
8419/// (an embedder that never booted the channel pollers), `watcher_running` is
8420/// `false` and the liveness fields are empty.
8421async fn handle_messaging_status(
8422    req: &JsonRpcMessage,
8423    session: &crate::session::ClientSession,
8424    state: &ServerState,
8425) -> Result<Value, String> {
8426    require_approval_authority(session, state)?;
8427    let channel = messaging_channel_from_params(&req.params)?;
8428    let store = messaging_store();
8429    let enabled = store.is_enabled_for(channel).unwrap_or(false);
8430    let paired = !store.allowlist_for(channel).unwrap_or_default().is_empty();
8431
8432    let (watcher_running, liveness) = match state.channel_supervisor.get() {
8433        Some(sup) => (sup.is_spawned(channel), sup.liveness_snapshot(channel)),
8434        None => (false, crate::channel_supervisor::ChannelLiveness::default()),
8435    };
8436
8437    let view = car_proto::MessagingStatusView {
8438        channel: channel.as_str().to_string(),
8439        enabled,
8440        paired,
8441        watcher_running,
8442        fda_readable: messaging_fda_readable(channel),
8443        last_send_at_ms: liveness.last_send_at_ms,
8444        last_send_ok: liveness.last_send_ok,
8445        last_error: liveness.last_error,
8446    };
8447    serde_json::to_value(view).map_err(|e| e.to_string())
8448}
8449
8450/// `messaging.test_send` — send a fixed, clearly-labeled self-test message to
8451/// the paired handle of the (optional) channel (default iMessage) and return a
8452/// synchronous `{ ok, error }` (U4). Host/local-auth gated EXACTLY like the
8453/// other `messaging.*` methods. The self-test mints NO approval/pairing mapping
8454/// and resolves nothing — it is purely a "does my Mac actually text my phone"
8455/// probe whose outcome is recorded into liveness (so a pass genuinely proves
8456/// Automation works and a failure surfaces identically to a real send).
8457///
8458/// Reuses the SAME `RealMessageSender` + liveness sink the boot/runtime adapters
8459/// use, so the test result and `messaging.status`'s `last_send_*` agree. Slack
8460/// is not yet wired here (the pane this serves is iMessage-only in v1); a Slack
8461/// test_send returns a clear "not supported on this channel yet" error.
8462async fn handle_messaging_test_send(
8463    req: &JsonRpcMessage,
8464    session: &crate::session::ClientSession,
8465    state: &ServerState,
8466) -> Result<Value, String> {
8467    require_approval_authority(session, state)?;
8468    let channel = messaging_channel_from_params(&req.params)?;
8469
8470    let result = match channel {
8471        crate::messaging_config::ChannelId::IMessage => {
8472            // Build a one-shot orchestrator over the SAME shared liveness the
8473            // status method reads, so a test send updates "last delivered".
8474            let liveness = state.channel_supervisor.get().map(|sup| sup.liveness());
8475            // The CAR state root, so a relocated daemon's test send writes its
8476            // liveness alongside the rest of its state rather than the primary's.
8477            let base_dir = car_home::root_or_relative();
8478            let orch = match liveness {
8479                Some(liveness) => {
8480                    crate::messaging_orchestrator::MessagingOrchestrator::with_liveness(
8481                        state.host.clone(),
8482                        messaging_store(),
8483                        std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8484                        base_dir,
8485                        liveness,
8486                    )
8487                }
8488                None => crate::messaging_orchestrator::MessagingOrchestrator::new(
8489                    state.host.clone(),
8490                    messaging_store(),
8491                    std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8492                    base_dir,
8493                ),
8494            };
8495            orch.send_test().await
8496        }
8497        crate::messaging_config::ChannelId::Slack => {
8498            Err("Test send is not supported on the Slack channel yet.".to_string())
8499        }
8500    };
8501
8502    let resp = match result {
8503        Ok(()) => car_proto::MessagingTestSendResponse {
8504            ok: true,
8505            error: None,
8506        },
8507        Err(e) => car_proto::MessagingTestSendResponse {
8508            ok: false,
8509            error: Some(e),
8510        },
8511    };
8512    serde_json::to_value(resp).map_err(|e| e.to_string())
8513}
8514
8515/// Parse a proposal from `params.proposal`.
8516fn proposal_from_params(req: &JsonRpcMessage) -> Result<car_ir::ActionProposal, String> {
8517    let p = req.params.get("proposal").ok_or("missing 'proposal'")?;
8518    serde_json::from_value(p.clone()).map_err(|e| format!("invalid proposal: {e}"))
8519}
8520
8521/// Classify each action in a proposal on both authorization-adjacent axes.
8522/// Params: `{ proposal }`. Returns `{ classifications: [{ action_id, tool,
8523/// required_tier, reversibility, missing_compensation }],
8524/// declared_rollback_contract }`. The envelope is what distinguishes this from
8525/// the FFI `permission_classify`, which returns the row array bare and
8526/// therefore carries no batch-level roll-up.
8527///
8528/// `required_tier` answers *who may authorize this*; `reversibility`
8529/// (`car_policy::classify_reversibility`) answers the independent question
8530/// *can this be undone*. They used to be one field, and collapsed they cannot
8531/// distinguish a `git push` from a charged card — both land on `full_access`.
8532/// The tier comes from the **session's** classifier (which may carry custom
8533/// rules); the reversibility classifier is stateless and has no such hook yet.
8534///
8535/// `missing_compensation` flags the one incoherent IR combination —
8536/// `reversibility: "compensable"` declared with no `compensation` — and reads
8537/// the action's *declared* field, so it stays `false` for proposals that never
8538/// opted into the axis.
8539///
8540/// `declared_rollback_contract` is `ActionProposal::rollback_contract()`: the
8541/// least recoverable contract any action **declares**, because a plan is only
8542/// as recoverable as its worst step and partial execution is a real outcome.
8543/// It is named for what it reads and is deliberately *not* a roll-up of the
8544/// `reversibility` column beside it — that column is the classifier's
8545/// independent guess from tool names, this one is what the author committed
8546/// to. For a proposal that never set the field it reads `"irreversible"`,
8547/// which is the serde default and exactly what the runtime should believe
8548/// about an unclassified plan.
8549///
8550/// Nothing here gates on the second axis — it is classified and reported, not
8551/// enforced. See `docs/proposals/shepherd-substrate-adoption.md`.
8552async fn handle_permission_classify(
8553    req: &JsonRpcMessage,
8554    session: &crate::session::ClientSession,
8555) -> Result<Value, String> {
8556    let proposal = proposal_from_params(req)?;
8557    let gate = session.permission_gate.read().await;
8558    let rows: Vec<Value> = proposal
8559        .actions
8560        .iter()
8561        // Same row shape as the FFI `permission_classify`, from one
8562        // definition — but built against the SESSION's classifier, which may
8563        // carry custom rules a fresh `RiskClassifier` would not have.
8564        .map(|a| car_ffi_common::permgate::classification_row(gate.classifier(), a))
8565        .collect();
8566    Ok(serde_json::json!({
8567        "classifications": rows,
8568        "declared_rollback_contract": proposal.rollback_contract().as_str(),
8569    }))
8570}
8571
8572/// Evaluate each action against the session's gate (consulting its durable
8573/// ledger). Params: `{ proposal }`. Returns `[{ action_id, fingerprint,
8574/// decision, reversibility, ... }]`.
8575///
8576/// `reversibility` is on every row, `allow` included, and is orthogonal to
8577/// `decision`: the gate's verdict says whether the action may run, not whether
8578/// it could be taken back afterwards. An approval UI that shows both can tell
8579/// the operator which of two identically-escalated actions is the one there is
8580/// no undoing.
8581async fn handle_permission_evaluate(
8582    req: &JsonRpcMessage,
8583    session: &crate::session::ClientSession,
8584    state: &ServerState,
8585) -> Result<Value, String> {
8586    let proposal = proposal_from_params(req)?;
8587    let ceiling = resolve_skill_ceiling(req, session).await;
8588    let agent_id = authenticated_bound_agent_id(session).await;
8589    let policy = crate::agent_permissions::load_policy();
8590    let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8591        &session.callback_tool_schema_digests,
8592        &session.runtime.registry,
8593    )
8594    .await;
8595    let gate = session.permission_gate.read().await;
8596    // Prior decisions come from the SHARED daemon ledger (C1) — an approval
8597    // recorded on another connection must be honoured here.
8598    let ledger = state.approval_ledger.read().await;
8599    let rows = evaluate_actions(
8600        &gate,
8601        &policy,
8602        agent_id.as_deref(),
8603        &callback_tools,
8604        &proposal,
8605        ceiling,
8606        &ledger,
8607    );
8608    let mut resp = serde_json::json!({ "decisions": rows });
8609    if let Some(c) = ceiling {
8610        resp.as_object_mut()
8611            .unwrap()
8612            .insert("skill_ceiling".into(), serde_json::json!(c.as_str()));
8613    }
8614    Ok(resp)
8615}
8616
8617/// Like evaluate but returns only the actions that need a human decision —
8618/// the work queue for an approval UI. Rows carry `reversibility` for the same
8619/// reason they do there, and it matters most here: this queue is where a human
8620/// decides, and "can this be undone?" is the question they are actually
8621/// weighing.
8622async fn handle_permission_pending(
8623    req: &JsonRpcMessage,
8624    session: &crate::session::ClientSession,
8625    state: &ServerState,
8626) -> Result<Value, String> {
8627    let proposal = proposal_from_params(req)?;
8628    let ceiling = resolve_skill_ceiling(req, session).await;
8629    let agent_id = authenticated_bound_agent_id(session).await;
8630    let policy = crate::agent_permissions::load_policy();
8631    let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8632        &session.callback_tool_schema_digests,
8633        &session.runtime.registry,
8634    )
8635    .await;
8636    let gate = session.permission_gate.read().await;
8637    let ledger = state.approval_ledger.read().await;
8638    let pending: Vec<Value> = evaluate_actions(
8639        &gate,
8640        &policy,
8641        agent_id.as_deref(),
8642        &callback_tools,
8643        &proposal,
8644        ceiling,
8645        &ledger,
8646    )
8647    .into_iter()
8648    .filter(|r| r.get("decision").and_then(|v| v.as_str()) == Some("needs_approval"))
8649    .collect();
8650    Ok(serde_json::json!({ "pending": pending }))
8651}
8652
8653/// Resolve the optional skill-deployment ceiling for a permission evaluation: if
8654/// the request names a `skill`, look up its persisted `deployment_tier` (the
8655/// ceiling the skill-trust gate stamped at load time) so the action-level gate
8656/// caps standing authority at it. `None` when no skill is named, the skill is
8657/// absent, or it carries no governed tier — leaving evaluation unchanged.
8658async fn resolve_skill_ceiling(
8659    req: &JsonRpcMessage,
8660    session: &crate::session::ClientSession,
8661) -> Option<car_policy::PermissionTier> {
8662    let skill = req.params.get("skill").and_then(|v| v.as_str())?;
8663    let engine = session.memgine.lock().await;
8664    engine.skill_meta(skill).and_then(|m| m.deployment_tier)
8665}
8666
8667fn evaluate_actions(
8668    gate: &car_policy::PermissionGate,
8669    policy: &car_policy::AgentPermissionPolicy,
8670    agent_id: Option<&str>,
8671    callback_tools: &HashMap<String, String>,
8672    proposal: &car_ir::ActionProposal,
8673    ceiling: Option<car_policy::PermissionTier>,
8674    ledger: &car_policy::ApprovalLedger,
8675) -> Vec<Value> {
8676    proposal
8677        .actions
8678        .iter()
8679        .map(|a| {
8680            // Both axes from one flatten of the parameters (Parslee-ai/car#856)
8681            // — this runs per action over a whole batch.
8682            let evaluation = crate::permission_gate::evaluate_action(
8683                gate,
8684                policy,
8685                agent_id,
8686                a,
8687                a.tool
8688                    .as_deref()
8689                    .and_then(|tool| callback_tools.get(tool))
8690                    .map(String::as_str),
8691                ceiling,
8692                ledger,
8693            );
8694            let mut obj = serde_json::to_value(&evaluation.axes.decision).unwrap_or(Value::Null);
8695            if let Some(map) = obj.as_object_mut() {
8696                map.insert("action_id".into(), serde_json::json!(a.id));
8697                map.insert(
8698                    "fingerprint".into(),
8699                    serde_json::json!(car_policy::action_fingerprint(a)),
8700                );
8701                if let Some(source) = evaluation.authorization_source {
8702                    map.insert("authorization_source".into(), source.into());
8703                }
8704                if let Some(digest) = evaluation.schema_digest {
8705                    map.insert("authorization_schema_digest".into(), digest.into());
8706                }
8707            }
8708            // The second axis rides on every row, `allow` included — an action
8709            // the gate waved through still has a rollback contract, and that is
8710            // the row an incident review reads first. Shared with the NAPI/PyO3
8711            // projection so the two cannot drift (project convention #2).
8712            car_ffi_common::permgate::stamp_reversibility(&mut obj, evaluation.axes.reversibility);
8713            obj
8714        })
8715        .collect()
8716}
8717
8718/// Which agent id a newly created approval should be attributed to.
8719///
8720/// A session **bound to an agent** is that agent, full stop: its binding was
8721/// proven against the supervisor-minted per-agent token, so a claim to be some
8722/// other agent is a forgery and is discarded. A session with no binding is a
8723/// host client — CarHost, `car-host approve`, the CLI — and those legitimately
8724/// raise approvals on an agent's behalf, so their claim is kept.
8725///
8726/// Pure so the rule is testable without a live session.
8727fn stamped_requester(bound: Option<String>, claimed: Option<String>) -> Option<String> {
8728    bound.or(claimed)
8729}
8730
8731async fn authenticated_bound_agent_id(session: &crate::session::ClientSession) -> Option<String> {
8732    if !session
8733        .authenticated
8734        .load(std::sync::atomic::Ordering::Acquire)
8735    {
8736        return None;
8737    }
8738    session.agent_id.lock().await.clone()
8739}
8740
8741/// Record a durable human-in-the-loop decision. Params either
8742/// `{ fingerprint, required_tier, reviewer, reason, evidence? }` (from a
8743/// prior `permission.evaluate`) or `{ action, reviewer, reason, evidence? }`.
8744/// Also audited to the session event log as `ApprovalRecorded`.
8745async fn handle_permission_decision(
8746    req: &JsonRpcMessage,
8747    session: &crate::session::ClientSession,
8748    state: &ServerState,
8749    approve: bool,
8750) -> Result<Value, String> {
8751    require_approval_authority(session, state)?;
8752    // The reviewer is the server-stamped principal, NOT a caller string —
8753    // an audit log whose "who approved" is forgeable by the approver
8754    // undercuts §5.2.5 (neo review #5c). The caller's free-text note is
8755    // kept as the reason.
8756    let reviewer = session_principal(session).await;
8757    let reason = req
8758        .params
8759        .get("reason")
8760        .and_then(|v| v.as_str())
8761        .unwrap_or("");
8762    let evidence = req
8763        .params
8764        .get("evidence")
8765        .and_then(|v| v.as_str())
8766        .map(str::to_string);
8767
8768    // Decisions land on the SHARED daemon ledger (kernel review C1): the
8769    // approver is typically a host connection while the runner that surfaced
8770    // the fingerprint is another — a per-session ledger would strand the
8771    // decision where the runner never reads it. The session gate contributes
8772    // only its classifier (for the action-shaped variant).
8773    let decision = if approve {
8774        car_policy::ApprovalDecision::Approved
8775    } else {
8776        car_policy::ApprovalDecision::Rejected
8777    };
8778    let record = if let Some(action_val) = req.params.get("action") {
8779        let action: car_ir::Action = serde_json::from_value(action_val.clone())
8780            .map_err(|e| format!("invalid action: {e}"))?;
8781        let rec = {
8782            let gate = session.permission_gate.read().await;
8783            gate.decision_record(&action, decision, &reviewer, reason, evidence)
8784        };
8785        state
8786            .approval_ledger
8787            .write()
8788            .await
8789            .record(rec.clone())
8790            // Journal write failure = decision NOT durable; propagate instead
8791            // of emitting a false ApprovalRecorded audit event (review A7).
8792            .map_err(|e| format!("failed to persist approval decision: {e}"))?;
8793        rec
8794    } else {
8795        let fingerprint = req
8796            .params
8797            .get("fingerprint")
8798            .and_then(|v| v.as_str())
8799            .ok_or("missing 'fingerprint' (or 'action')")?;
8800        let required_tier = req
8801            .params
8802            .get("required_tier")
8803            .and_then(|v| v.as_str())
8804            .and_then(car_policy::PermissionTier::from_str_opt)
8805            .unwrap_or(car_policy::PermissionTier::FullAccess);
8806        state
8807            .approval_ledger
8808            .write()
8809            .await
8810            .record_decision(
8811                fingerprint,
8812                required_tier,
8813                decision,
8814                &reviewer,
8815                reason,
8816                evidence,
8817            )
8818            .map_err(|e| format!("failed to persist approval decision: {e}"))?
8819    };
8820
8821    // Audit the durable transition (§5.2.5) to the session event log — at
8822    // parity with car-engine's TierPermissionHandler emission (required_tier
8823    // + evidence; approval derived from the stored decision, not the request).
8824    let mut data = std::collections::HashMap::new();
8825    data.insert(
8826        "fingerprint".to_string(),
8827        Value::from(record.fingerprint.clone()),
8828    );
8829    data.insert(
8830        "approval".to_string(),
8831        Value::from(match record.decision {
8832            car_policy::ApprovalDecision::Approved => "approved",
8833            car_policy::ApprovalDecision::Rejected => "rejected",
8834        }),
8835    );
8836    data.insert(
8837        "required_tier".to_string(),
8838        Value::from(record.required_tier.as_str()),
8839    );
8840    data.insert("reviewer".to_string(), Value::from(record.reviewer.clone()));
8841    data.insert("reason".to_string(), Value::from(record.reason.clone()));
8842    if let Some(ev) = &record.evidence {
8843        data.insert("evidence".to_string(), Value::from(ev.clone()));
8844    }
8845    session.runtime.log.lock().await.append(
8846        car_eventlog::EventKind::ApprovalRecorded,
8847        None,
8848        None,
8849        data,
8850    );
8851
8852    serde_json::to_value(&record).map_err(|e| e.to_string())
8853}
8854
8855/// Validate a tenant id (linus review C-4). `:` is the namespace
8856/// separator in `tenant:<id>:<key>`, so an id containing it nests
8857/// inside (or envelops) another tenant's namespace — `"acme:sub"`
8858/// would read acme's keys and be captured by acme's scoped
8859/// snapshot/restore/reap. Reject at the boundary.
8860fn validate_tenant_id(id: &str) -> Result<(), String> {
8861    if id.contains(':') {
8862        return Err(format!(
8863            "invalid tenant_id '{id}': ':' is reserved as the namespace separator"
8864        ));
8865    }
8866    Ok(())
8867}
8868
8869/// Resolve the tenant identity for a tenant-scoped handler
8870/// (Parslee-ai/car#187 phase 3-E, hardened per linus review C-4).
8871///
8872/// The session's bound tenant (from `session.auth { tenant_id }`) is
8873/// authoritative: when bound, a per-request `tenant_id` may restate it
8874/// but a mismatch is an error — request params are caller-controlled
8875/// and must not hop namespaces. An unbound session keeps the legacy
8876/// per-request behavior (validated). Absent/empty → unscoped.
8877async fn effective_tenant(
8878    req: &JsonRpcMessage,
8879    session: &crate::session::ClientSession,
8880) -> Result<Option<String>, String> {
8881    let param = req
8882        .params
8883        .get("tenant_id")
8884        .and_then(|v| v.as_str())
8885        .filter(|s| !s.is_empty())
8886        .map(str::to_string);
8887    if let Some(p) = &param {
8888        validate_tenant_id(p)?;
8889    }
8890    let bound = session.tenant.lock().await.clone();
8891    match (bound, param) {
8892        (Some(b), Some(p)) if b != p => Err(format!(
8893            "tenant_id '{p}' conflicts with this session's bound tenant '{b}'"
8894        )),
8895        (Some(b), _) => Ok(Some(b)),
8896        (None, p) => Ok(p),
8897    }
8898}
8899
8900/// Refuse unscoped access to a tenant-namespaced key (linus review
8901/// C-4): without this, `state.get {key: "tenant:acme:secret"}` with no
8902/// `tenant_id` read (and `state.set` wrote) straight through another
8903/// tenant's namespace — enforcement existed only on `keys`/`snapshot`.
8904fn reject_unscoped_tenant_key(tenant: &Option<String>, key: &str) -> Result<(), String> {
8905    if tenant.is_none() && key.starts_with("tenant:") {
8906        return Err(format!(
8907            "unscoped access to tenant-namespaced key '{key}' is not permitted; \
8908             authenticate with the owning tenant_id"
8909        ));
8910    }
8911    Ok(())
8912}
8913
8914fn handle_capabilities_list(bound_agent: Option<&str>, is_host: bool) -> Value {
8915    let caller_role = if is_host {
8916        "host"
8917    } else if bound_agent.is_some() {
8918        "agent"
8919    } else {
8920        "operator"
8921    };
8922    let methods: Vec<Value> = crate::generated_rpc_capabilities::RPC_CAPABILITIES
8923        .iter()
8924        .filter(|(_, role)| match *role {
8925            // Ungated methods are callable by every authenticated client.
8926            "operator" => true,
8927            // Owner methods authorize the concrete resource at call time.
8928            "owner" => is_host || bound_agent.is_some(),
8929            "agent" => bound_agent.is_some(),
8930            "host" => is_host,
8931            _ => false,
8932        })
8933        .map(|(method, role)| serde_json::json!({"method": method, "role": role}))
8934        .collect();
8935    serde_json::json!({
8936        "caller_role": caller_role,
8937        "count": methods.len(),
8938        "methods": methods,
8939    })
8940}
8941
8942#[cfg(test)]
8943mod capability_discovery_tests {
8944    use super::handle_capabilities_list;
8945
8946    #[test]
8947    fn agent_discovery_excludes_host_methods_and_preserves_roles() {
8948        let result = handle_capabilities_list(Some("agent-a"), false);
8949        let methods = result["methods"].as_array().expect("method rows");
8950        assert_eq!(result["caller_role"], "agent");
8951        assert!(methods
8952            .iter()
8953            .any(|row| row["method"] == "capabilities.list"));
8954        assert!(methods.iter().any(|row| row["role"] == "agent"));
8955        assert!(methods.iter().any(|row| row["role"] == "owner"));
8956        assert!(methods.iter().any(|row| row["role"] == "operator"));
8957        assert!(!methods.iter().any(|row| row["role"] == "host"));
8958    }
8959
8960    #[test]
8961    fn unbound_discovery_contains_only_ungated_methods() {
8962        let result = handle_capabilities_list(None, false);
8963        let methods = result["methods"].as_array().expect("method rows");
8964        assert_eq!(result["caller_role"], "operator");
8965        assert!(methods.iter().all(|row| row["role"] == "operator"));
8966    }
8967}
8968
8969async fn handle_state_get(
8970    req: &JsonRpcMessage,
8971    session: &crate::session::ClientSession,
8972) -> Result<Value, String> {
8973    let key = require_str(&req.params, "key")?;
8974    let tenant = effective_tenant(req, session).await?;
8975    reject_unscoped_tenant_key(&tenant, key)?;
8976    Ok(session
8977        .runtime
8978        .state
8979        .scoped(tenant.as_deref())
8980        .get(key)
8981        .unwrap_or(Value::Null))
8982}
8983
8984async fn handle_state_set(
8985    req: &JsonRpcMessage,
8986    session: &crate::session::ClientSession,
8987) -> Result<Value, String> {
8988    let key = require_str(&req.params, "key")?;
8989    let value = req.params.get("value").cloned().unwrap_or(Value::Null);
8990    let tenant = effective_tenant(req, session).await?;
8991    reject_unscoped_tenant_key(&tenant, key)?;
8992    session
8993        .runtime
8994        .state
8995        .scoped(tenant.as_deref())
8996        .set(key, value, "client");
8997    Ok(Value::from("ok"))
8998}
8999
9000/// `state.exists` — true if the key is set in this session's state
9001/// store, false otherwise. Cheaper than `state.get` + null-check on
9002/// the client side because it doesn't serialize the value.
9003async fn handle_state_exists(
9004    req: &JsonRpcMessage,
9005    session: &crate::session::ClientSession,
9006) -> Result<Value, String> {
9007    let key = require_str(&req.params, "key")?;
9008    let tenant = effective_tenant(req, session).await?;
9009    reject_unscoped_tenant_key(&tenant, key)?;
9010    Ok(Value::Bool(
9011        session.runtime.state.scoped(tenant.as_deref()).exists(key),
9012    ))
9013}
9014
9015/// `state.keys` — list every key currently set in this session's
9016/// state store. Returns a JSON array of strings.
9017async fn handle_state_keys(
9018    req: &JsonRpcMessage,
9019    session: &crate::session::ClientSession,
9020) -> Result<Value, String> {
9021    let tenant = effective_tenant(req, session).await?;
9022    Ok(Value::Array(
9023        session
9024            .runtime
9025            .state
9026            .scoped(tenant.as_deref())
9027            .keys()
9028            .into_iter()
9029            .map(Value::String)
9030            .collect(),
9031    ))
9032}
9033
9034/// `state.snapshot` — return the entire session state store as a
9035/// JSON object (`{ key: value, ... }`). Equivalent to iterating
9036/// `state.keys` + `state.get` but in a single round-trip; for
9037/// inspectors/dashboards.
9038///
9039/// Tenant-scoped variant: when `tenant_id` is set, only that
9040/// tenant's keys are returned (prefix stripped on the way out).
9041/// `state.snapshot` with no `tenant_id` returns only unscoped
9042/// keys; consistent with `state.keys`'s filter behaviour and the
9043/// strict-isolation contract from phase 3-B.
9044async fn handle_state_snapshot(
9045    req: &JsonRpcMessage,
9046    session: &crate::session::ClientSession,
9047) -> Result<Value, String> {
9048    let tenant = effective_tenant(req, session).await?;
9049    // One locked snapshot, not keys()-then-get(): each of those calls
9050    // acquires the state lock separately, so a concurrent mutation batch
9051    // could commit between two get()s and the response would mix old and
9052    // new values of a single callback's key set (Parslee-ai/car#1140).
9053    let map: serde_json::Map<String, Value> = session
9054        .runtime
9055        .state
9056        .scoped(tenant.as_deref())
9057        .snapshot_stripped()
9058        .into_iter()
9059        .collect();
9060    Ok(Value::Object(map))
9061}
9062
9063// --- Per-agent persistent memgine (#170) ---
9064
9065/// `~/.car/memory/agents/<id>.json` — the per-agent snapshot file.
9066/// Mirrors the existing `memory.persist` shape (flat JSON array of
9067/// fact objects) so the same loader path works.
9068fn agent_memgine_snapshot_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
9069    let base = car_ffi_common::memory_path::ensure_base()
9070        .map_err(|e| format!("memory base unavailable: {e}"))?;
9071    let dir = base.join("agents");
9072    std::fs::create_dir_all(&dir).map_err(|e| format!("create agents dir: {e}"))?;
9073    Ok(dir.join(format!("{agent_id}.json")))
9074}
9075
9076/// Acquire (or lazy-create + load from disk) the daemon-owned
9077/// persistent memgine for `agent_id`. First call per id reads
9078/// `~/.car/memory/agents/<id>.json` if it exists; subsequent calls
9079/// share the in-memory engine across sessions. Caller stores the
9080/// returned `Arc` on `ClientSession.bound_memgine` so memory.*
9081/// handlers route through it via [`ClientSession::effective_memgine`](crate::session::ClientSession::effective_memgine).
9082/// Resolve the [`car_memgine::MemgineConfig`] the daemon seeds its engines with:
9083/// discover the `.car/` project from the **project anchor** and apply its
9084/// `config.toml` overrides (e.g. utility-aware retrieval). The anchor is
9085/// `$CAR_PROJECT_DIR` when set, else the process cwd.
9086///
9087/// The env var is load-bearing for the shipped macOS path: the
9088/// `launchd`/CarHost-supervised `car-server` inherits the app bundle (or `/`)
9089/// as cwd, which has no `.car/` ancestor — so cwd-only discovery would silently
9090/// no-op there. A host that wants project tuning points `CAR_PROJECT_DIR` at the
9091/// workspace. With neither anchor resolvable, returns the default config
9092/// (utility retrieval off) — a safe no-op, never a panic.
9093pub fn seed_memgine_config() -> car_memgine::MemgineConfig {
9094    let anchor = std::env::var_os("CAR_PROJECT_DIR")
9095        .map(std::path::PathBuf::from)
9096        .or_else(|| std::env::current_dir().ok());
9097    match anchor {
9098        Some(dir) => {
9099            car_memgine::project::resolve_config(&dir, car_memgine::MemgineConfig::default())
9100        }
9101        None => car_memgine::MemgineConfig::default(),
9102    }
9103}
9104
9105/// Read the operator's **trusted skill-signer keyring** from the `.car/`
9106/// project's `config.toml` (`trusted_skill_signers`), discovered from the same
9107/// anchor as [`seed_memgine_config`]: `$CAR_PROJECT_DIR` when set, else the
9108/// process cwd. Absent key, absent file, or no project → an empty keyring.
9109///
9110/// This is the operator half of skill-trust governance (arXiv 2602.12430
9111/// "Agent Skills"; `docs/proposals/skill-trust-governance.md`). It decides
9112/// whether a bundle's ed25519 signature confers `signer_trusted` on the
9113/// resulting `SkillProvenance`.
9114///
9115/// Be precise about what an empty keyring costs, because it is narrower than
9116/// it sounds: `signer_trusted` separates **only** `Official` from `Verified`
9117/// in [`car_policy::skill_trust::classify_trust`]. A signed + scanned pack
9118/// with an empty keyring lands `Verified` → `sandbox_edit`, not `Community`
9119/// — that tier requires `scanned && !signed`, so a signed pack can never
9120/// reach it. What the keyring withholds is `Official`/`full_access`, nothing
9121/// else. An unscanned pack is `Untrusted` → denied regardless of signature,
9122/// and `scanned` is caller-supplied on every path, so a self-signed manifest
9123/// declared scanned still reaches `sandbox_edit` here.
9124///
9125/// The keyring comes ONLY from operator config, never from the JSON-RPC
9126/// request. A caller that could name its own trusted key ids would be
9127/// self-certifying: it would sign a pack, declare its own key trusted, and
9128/// take the `manifest` path straight to `Official`. Note this constrains the
9129/// `manifest` path only — `skill.adopt_pack` also accepts a caller-assembled
9130/// `provenance` at face value (see [`resolve_adopt_provenance`]).
9131pub fn seed_trusted_skill_signers() -> Vec<String> {
9132    let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
9133        .map(std::path::PathBuf::from)
9134        .or_else(|| std::env::current_dir().ok())
9135    else {
9136        return Vec::new();
9137    };
9138    let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
9139        return Vec::new();
9140    };
9141    car_memgine::project::load_config_overrides(&car_dir)
9142        .and_then(|o| o.trusted_skill_signers)
9143        .unwrap_or_default()
9144}
9145
9146/// Snapshot path for a host-declared memory namespace.
9147///
9148/// Deliberately NOT `agents/`: a namespace is a different axis from an agent
9149/// id (Parslee-ai/car-releases#79), so one agent may work across several
9150/// namespaces and two hosts may share a namespace without sharing an identity.
9151/// Colliding the two directories would also let a namespace masquerade as a
9152/// supervised agent's snapshot.
9153///
9154/// Encoded — not sanitized — because a namespace is arbitrary host-supplied
9155/// text that becomes a filename, and the mapping from namespace to filename
9156/// must be **injective**. An `agent_id` is safe by construction (`session.auth`
9157/// rejects one the supervisor does not know); nothing validates a namespace, so
9158/// `../../…` here would escape the memory base.
9159///
9160/// The old mapping replaced `/ \ : NUL` with `_` and trimmed `.`/whitespace,
9161/// which is lossy: `proj/x`, `proj:x`, `proj\x`, `proj_x` and `proj_x ` all
9162/// landed on `proj_x.json`. The in-memory registry is keyed on the raw
9163/// namespace, so nothing looked wrong while the daemon ran — but the loader
9164/// reads whatever sits at the path with no namespace check, so on the next
9165/// snapshot LOAD (daemon restart, or `memory.load`) whichever namespace
9166/// resolved second inherited the first one's persisted graph. That is
9167/// cross-project memory leakage through a filename (#891).
9168///
9169/// The mapping here is a lowercase percent-encoding of the namespace's UTF-8
9170/// bytes: `a`-`z`, `0`-`9`, `-`, `_` and `.` pass through, every other byte
9171/// becomes `%` plus two lowercase hex digits (`/` → `%2f`, `:` → `%3a`,
9172/// ` ` → `%20`), and `%` itself becomes `%25`. Because `%` is always escaped,
9173/// every `%` in the output starts an escape, so distinct namespaces cannot
9174/// produce the same filename. Uppercase letters and uppercase hex are escaped
9175/// rather than passed through **because APFS is case-insensitive by default**:
9176/// letting `A` through would let `Proj` and `proj` collide again on the very
9177/// platform CarHost ships on.
9178///
9179/// Nothing is trimmed — trimming is lossy — and nothing needs to be: `/` is
9180/// escaped, so `.` and `..` are ordinary filenames here and no traversal is
9181/// possible.
9182fn namespace_memgine_snapshot_path(namespace: &str) -> Result<std::path::PathBuf, String> {
9183    if namespace.is_empty() {
9184        return Err("memory_namespace must contain at least one usable character".into());
9185    }
9186    const HEX: &[u8; 16] = b"0123456789abcdef";
9187    let mut encoded = String::with_capacity(namespace.len());
9188    for &b in namespace.as_bytes() {
9189        match b {
9190            b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => encoded.push(b as char),
9191            _ => {
9192                encoded.push('%');
9193                encoded.push(HEX[(b >> 4) as usize] as char);
9194                encoded.push(HEX[(b & 0x0f) as usize] as char);
9195            }
9196        }
9197    }
9198    let file_name = format!("{encoded}.json");
9199    // Encoding can triple the length, so a namespace well under any filesystem
9200    // limit can still encode past one. Fail with the namespace named rather
9201    // than letting the OS return an opaque ENAMETOOLONG on the first write.
9202    if file_name.len() > 255 {
9203        return Err(format!(
9204            "memory_namespace is too long: {:?} encodes to a {}-byte filename \
9205             (limit 255)",
9206            namespace,
9207            file_name.len()
9208        ));
9209    }
9210    let base = car_ffi_common::memory_path::ensure_base()
9211        .map_err(|e| format!("memory base unavailable: {e}"))?;
9212    let dir = base.join("memory-namespaces");
9213    std::fs::create_dir_all(&dir).map_err(|e| format!("create namespaces dir: {e}"))?;
9214    Ok(dir.join(file_name))
9215}
9216
9217/// Acquire (or lazy-create + load) the daemon-owned memgine for a namespace.
9218///
9219/// Mirrors [`get_or_load_agent_memgine`]; the registries are separate so the
9220/// two axes cannot collide.
9221async fn get_or_load_namespace_memgine(
9222    state: &Arc<ServerState>,
9223    namespace: &str,
9224) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9225    {
9226        let map = state.namespace_memgines.lock().await;
9227        if let Some(eng) = map.get(namespace) {
9228            return Ok(eng.clone());
9229        }
9230    }
9231    let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9232        Some(seed_memgine_config()),
9233    )));
9234    let path = namespace_memgine_snapshot_path(namespace)?;
9235    if path.exists() {
9236        // Same on-disk shape as the per-agent snapshot: a flat array of facts.
9237        let content = std::fs::read_to_string(&path)
9238            .map_err(|e| format!("read {}: {}", path.display(), e))?;
9239        let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9240        let mut g = engine.lock().await;
9241        for (loaded, fact) in facts.iter().enumerate() {
9242            ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9243        }
9244    }
9245    let mut map = state.namespace_memgines.lock().await;
9246    Ok(map.entry(namespace.to_string()).or_insert(engine).clone())
9247}
9248
9249/// Bind a session to a namespace graph AND record which namespace it is.
9250///
9251/// These two must happen together. Binding without recording is what made
9252/// namespace memory non-durable (car-releases#82): the daemon knew which graph
9253/// the session used but not what to call it, so nothing could write it back to
9254/// disk. Keeping them in one helper means a future bind site cannot do half of
9255/// it — there were four call sites, and a fifth would have been easy to get
9256/// wrong.
9257async fn bind_memory_namespace(
9258    state: &Arc<ServerState>,
9259    session: &crate::session::ClientSession,
9260    namespace: &str,
9261) -> Result<(), String> {
9262    let eng = get_or_load_namespace_memgine(state, namespace).await?;
9263    *session.bound_memgine.lock().await = Some(eng);
9264    *session.memory_namespace.lock().await = Some(namespace.to_string());
9265    Ok(())
9266}
9267
9268/// Write a namespace's graph to
9269/// `~/.car/memory/memory-namespaces/<encoded-ns>.json` (see
9270/// [`namespace_memgine_snapshot_path`] for the encoding).
9271///
9272/// Mirrors [`persist_agent_memgine`] exactly, including its lock discipline:
9273/// the shared memgine lock is released BEFORE serialize+write, and the write
9274/// runs on the blocking pool. Holding it across a blocking write would
9275/// serialize every namespace's memory ops and one stuck write would wedge them
9276/// all.
9277///
9278/// The on-disk shape is the flat fact array `get_or_load_namespace_memgine`
9279/// already reads — that loader existed from the start, reading a file nothing
9280/// ever wrote.
9281async fn persist_namespace_memgine(
9282    namespace: &str,
9283    engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9284) -> Result<(), String> {
9285    let path = namespace_memgine_snapshot_path(namespace)?;
9286    let g = engine.lock().await;
9287    let facts = memory_snapshot_facts(&g);
9288    drop(g);
9289    let path_owned = path.clone();
9290    tokio::task::spawn_blocking(move || -> Result<(), String> {
9291        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9292        atomic_write_sync(&path_owned, json.as_bytes())
9293            .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9294    })
9295    .await
9296    .map_err(|e| format!("persist_namespace_memgine join: {e}"))?
9297}
9298
9299/// One flat JSON row per persisted graph node, in the shape the snapshot
9300/// loaders (`get_or_load_namespace_memgine`, `get_or_load_agent_memgine`,
9301/// `handle_memory_load`) read back.
9302///
9303/// The row format is deliberately lossy and always has been: non-Fact kinds
9304/// collapse onto the three flat labels (`constraint`/`pattern`/`outcome`) and
9305/// reload as plain Fact nodes (a Conversation's `outcome` row becomes a
9306/// non-constraint Fact; Skill/Conclusion/FactSuperseded rows fall into the
9307/// `_` arm), while Identity/Environment nodes are not written at all. Only
9308/// Fact rows round-trip `fact_id`/`tags`/`source` verbatim — the metadata
9309/// preservation the docs promise holds for facts, not for the whole graph.
9310fn memory_snapshot_facts(engine: &car_memgine::MemgineEngine) -> Vec<Value> {
9311    engine
9312        .graph
9313        .inner
9314        .node_indices()
9315        .filter_map(|nix| {
9316            let node = engine.graph.inner.node_weight(nix)?;
9317            if !node.is_valid() {
9318                return None;
9319            }
9320            if node.kind == car_memgine::MemKind::Identity
9321                || node.kind == car_memgine::MemKind::Environment
9322            {
9323                return None;
9324            }
9325            Some(serde_json::json!({
9326                "fact_id": node.fact_id,
9327                "subject": node.key,
9328                "body": node.value,
9329                "kind": match node.kind {
9330                    car_memgine::MemKind::Fact if node.is_constraint => "constraint",
9331                    car_memgine::MemKind::Fact => "pattern",
9332                    car_memgine::MemKind::Conversation => "outcome",
9333                    _ => "pattern",
9334                },
9335                "confidence": 0.5,
9336                "content_type": node.content_type.as_label(),
9337                "tags": node.metadata.tags,
9338                "source": node
9339                    .metadata
9340                    .provenance
9341                    .first()
9342                    .map(|entry| entry.source.as_str())
9343                    .unwrap_or(""),
9344            }))
9345        })
9346        .collect()
9347}
9348
9349fn ingest_snapshot_fact(
9350    engine: &mut car_memgine::MemgineEngine,
9351    fact: &Value,
9352    fallback_id: String,
9353) {
9354    let subject = fact.get("subject").and_then(Value::as_str).unwrap_or("");
9355    let body = fact.get("body").and_then(Value::as_str).unwrap_or("");
9356    let kind = fact
9357        .get("kind")
9358        .and_then(Value::as_str)
9359        .unwrap_or("pattern");
9360    let fact_id = fact
9361        .get("fact_id")
9362        .and_then(Value::as_str)
9363        .unwrap_or(&fallback_id);
9364    let tags = fact
9365        .get("tags")
9366        .cloned()
9367        .and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
9368        .unwrap_or_default();
9369    let source = fact
9370        .get("source")
9371        .and_then(Value::as_str)
9372        .unwrap_or_default();
9373    let nix = engine.ingest_fact(
9374        fact_id,
9375        subject,
9376        body,
9377        "user",
9378        "peer",
9379        chrono::Utc::now(),
9380        "global",
9381        None,
9382        vec![],
9383        kind == "constraint",
9384    );
9385    if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9386        node.metadata.tags = tags;
9387        if !source.is_empty() {
9388            node.metadata.provenance = vec![car_memgine::Provenance {
9389                source: source.to_string(),
9390                ..Default::default()
9391            }];
9392        }
9393    }
9394}
9395
9396async fn get_or_load_agent_memgine(
9397    state: &Arc<ServerState>,
9398    agent_id: &str,
9399) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9400    {
9401        let map = state.agent_memgines.lock().await;
9402        if let Some(eng) = map.get(agent_id) {
9403            return Ok(eng.clone());
9404        }
9405    }
9406    // Build a fresh engine and try to load from disk. Seed its config from the
9407    // `.car/` project (like the daemon's shared engine) so a bound agent's
9408    // retrieval honors the same team-shared tuning (e.g. utility-aware
9409    // retrieval) — otherwise the knob could never reach the per-agent engine
9410    // (#170 bound_memgine path).
9411    let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9412        Some(seed_memgine_config()),
9413    )));
9414    let path = agent_memgine_snapshot_path(agent_id)?;
9415    if path.exists() {
9416        let content = std::fs::read_to_string(&path)
9417            .map_err(|e| format!("read {}: {}", path.display(), e))?;
9418        let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9419        let mut g = engine.lock().await;
9420        for (loaded, fact) in facts.iter().enumerate() {
9421            ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9422        }
9423    }
9424    let mut map = state.agent_memgines.lock().await;
9425    let stored = map.entry(agent_id.to_string()).or_insert(engine).clone();
9426    Ok(stored)
9427}
9428
9429/// Crash- and concurrency-safe write: serialize to a UNIQUE temp in the same
9430/// directory, then atomically rename over the target. A crash mid-write or a
9431/// concurrent writer to the same (client-chosen) path can no longer leave a
9432/// partial or interleaved file — `rename(2)` is atomic on the same filesystem,
9433/// and the per-call temp name (pid + monotonic seq) means two writers never
9434/// collide on the temp. Synchronous — call inside spawn_blocking.
9435fn atomic_write_sync(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
9436    use std::sync::atomic::{AtomicU64, Ordering};
9437    static SEQ: AtomicU64 = AtomicU64::new(0);
9438    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
9439    let mut tmp_os = path.as_os_str().to_owned();
9440    tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
9441    let tmp = std::path::PathBuf::from(tmp_os);
9442    std::fs::write(&tmp, bytes)?;
9443    std::fs::rename(&tmp, path)
9444}
9445
9446/// Snapshot the agent's memgine to its disk file. Same on-wire shape
9447/// as `memory.persist` so manual snapshots and the daemon-owned
9448/// persistence stay interoperable.
9449async fn persist_agent_memgine(
9450    agent_id: &str,
9451    engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9452) -> Result<(), String> {
9453    let path = agent_memgine_snapshot_path(agent_id)?;
9454    let g = engine.lock().await;
9455    let facts = memory_snapshot_facts(&g);
9456    // Release the SHARED memgine lock before serialize+write, then do that work
9457    // on the blocking pool: holding the lock across a blocking write serializes
9458    // every agent's memory ops and a slow/stuck write would wedge them all
9459    // (the incident). spawn_blocking also keeps the write off the tokio worker
9460    // (no thread-pinning) and lets the per-request deadline preempt at the await.
9461    drop(g);
9462    let path_owned = path.clone();
9463    tokio::task::spawn_blocking(move || -> Result<(), String> {
9464        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9465        atomic_write_sync(&path_owned, json.as_bytes())
9466            .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9467    })
9468    .await
9469    .map_err(|e| format!("persist_agent_memgine join: {e}"))?
9470}
9471
9472// --- Memory handlers ---
9473
9474/// `memory.fact_count` — return `valid_fact_count()` of the
9475/// session's memgine. Used by FFI bindings to mirror their
9476/// embedded `fact_count()` accessor without round-tripping a full
9477/// query. No params.
9478async fn handle_memory_fact_count(
9479    session: &crate::session::ClientSession,
9480) -> Result<Value, String> {
9481    let engine_arc = session.effective_memgine().await;
9482    let engine = engine_arc.lock().await;
9483    Ok(Value::from(engine.valid_fact_count()))
9484}
9485
9486/// Parse a `CommitAuthority` from its wire label.
9487///
9488/// Matched exhaustively against the enum's own `as_str` labels rather than
9489/// hand-written strings, so a new authority variant cannot silently become
9490/// unparseable here (project convention #2).
9491fn parse_commit_authority(s: &str) -> Result<car_verify::CommitAuthority, String> {
9492    use car_verify::CommitAuthority as A;
9493    for candidate in [
9494        A::Execution,
9495        A::Review,
9496        A::Coordination,
9497        A::Scheduling,
9498        A::SystemConfig,
9499    ] {
9500        if candidate.as_str() == s {
9501            return Ok(candidate);
9502        }
9503    }
9504    Err(format!(
9505        "unknown commit authority `{s}` (expected one of: execution, review, coordination, scheduling, system_config)"
9506    ))
9507}
9508
9509/// `memory.set_admission_table` — install or clear the durable-state
9510/// admission rules for this session's memgine.
9511///
9512/// Params: `{ table: OwnershipTable | null }`. `null` (or an absent `table`)
9513/// clears the rules, turning the gate **off** — which is the default and what
9514/// every deployment gets until this is called. Off is not the same as an empty
9515/// table: an empty table is fail-closed and refuses every externally-authored
9516/// fact, which is the core's documented behaviour.
9517async fn handle_memory_set_admission_table(
9518    req: &JsonRpcMessage,
9519    session: &crate::session::ClientSession,
9520) -> Result<Value, String> {
9521    let table: Option<car_verify::OwnershipTable> = match req.params.get("table") {
9522        None | Some(Value::Null) => None,
9523        Some(v) => {
9524            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid table: {e}"))?)
9525        }
9526    };
9527
9528    // Name the surfaces whose rule imposes no real constraint, so an operator
9529    // installing a table that only looks governed hears about it now rather
9530    // than discovering it after something unreviewed became durable.
9531    let ungated: Vec<&'static str> = table
9532        .as_ref()
9533        .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect())
9534        .unwrap_or_default();
9535    let enabled = table.is_some();
9536
9537    let engine_arc = session.effective_memgine().await;
9538    engine_arc.lock().await.set_admission_table(table);
9539
9540    Ok(serde_json::json!({
9541        "enabled": enabled,
9542        "ungated_surfaces": ungated,
9543    }))
9544}
9545
9546/// `memory.admission_table` — read back the installed rules.
9547///
9548/// Returns `{ enabled, table, ungated_surfaces }`. `enabled: false` with a null
9549/// `table` means the gate is off.
9550async fn handle_memory_admission_table(
9551    _req: &JsonRpcMessage,
9552    session: &crate::session::ClientSession,
9553) -> Result<Value, String> {
9554    let engine_arc = session.effective_memgine().await;
9555    let engine = engine_arc.lock().await;
9556    let table = engine.admission_table();
9557    Ok(serde_json::json!({
9558        "enabled": table.is_some(),
9559        "table": table,
9560        "ungated_surfaces": table
9561            .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect::<Vec<_>>())
9562            .unwrap_or_default(),
9563    }))
9564}
9565
9566async fn handle_memory_add_fact(
9567    req: &JsonRpcMessage,
9568    session: &crate::session::ClientSession,
9569) -> Result<Value, String> {
9570    let subject = req
9571        .params
9572        .get("subject")
9573        .and_then(|v| v.as_str())
9574        .ok_or("missing subject")?;
9575    let body = req
9576        .params
9577        .get("body")
9578        .and_then(|v| v.as_str())
9579        .ok_or("missing body")?;
9580    let kind = req
9581        .params
9582        .get("kind")
9583        .and_then(|v| v.as_str())
9584        .unwrap_or("pattern");
9585    let tags = match req.params.get("tags") {
9586        Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
9587            .map_err(|_| "tags must be an array of strings".to_string())?,
9588        None => vec![],
9589    };
9590    let source = match req.params.get("source") {
9591        Some(Value::String(value)) => value.clone(),
9592        Some(_) => return Err("source must be a string".to_string()),
9593        None => "user".to_string(),
9594    };
9595    // Route through `effective_memgine` so connections bound to a
9596    // lifecycle agent (#169) write into the daemon-owned per-agent
9597    // memgine instead of the per-WS ephemeral one (#170).
9598    // Durable-state admission (car#1031 core, wired here). This is the
9599    // externally-authored write path — a peer hands us a subject and body — so
9600    // it goes through `try_ingest_fact` rather than the raw ingest the engine
9601    // uses for its own derived facts.
9602    //
9603    // `produced_by` is fixed to `Execution` and is deliberately NOT
9604    // caller-settable: a peer calling this surface *is* the execution path,
9605    // and letting it name its own producer would let it satisfy any rule by
9606    // declaring itself whatever the rule expects.
9607    //
9608    // `committed_by` IS caller-supplied, and the limitation is worth stating
9609    // plainly rather than implying more than is true: this surface does not yet
9610    // authenticate the claim. So with a table configured, the gate enforces
9611    // **evidence** (a caller cannot conjure a passing verifier verdict) and
9612    // structure, but not committer **identity**. Binding the committer to the
9613    // authenticated session is follow-up work; until then treat this half as
9614    // bookkeeping, not a security boundary.
9615    let committed_by = req
9616        .params
9617        .get("committed_by")
9618        .and_then(|v| v.as_str())
9619        .map(parse_commit_authority)
9620        .transpose()?;
9621    let verdicts: Vec<car_verify::VerifierVerdict> = match req.params.get("verdicts") {
9622        Some(v) => {
9623            serde_json::from_value(v.clone()).map_err(|e| format!("invalid verdicts: {e}"))?
9624        }
9625        None => Vec::new(),
9626    };
9627
9628    let engine_arc = session.effective_memgine().await;
9629    let count = {
9630        let mut engine = engine_arc.lock().await;
9631        // A caller-supplied id is identity, not a hint. A duplicate errors —
9632        // except when it re-states the fact already stored under it (same
9633        // subject and body): that is the at-least-once retry / daemon-reload
9634        // replay case, which resolves to the existing fact instead of failing
9635        // or silently duplicating. Same id, different content is a genuine
9636        // collision and still errors.
9637        let fid = match req.params.get("fact_id") {
9638            Some(Value::String(value)) if !value.is_empty() => {
9639                let fid = value.clone();
9640                if let Some((_, existing)) = engine.graph.get_by_fact_id(&fid) {
9641                    if existing.key == subject && existing.value == body {
9642                        return Ok(Value::from(engine.valid_fact_count()));
9643                    }
9644                    return Err(format!("fact_id `{fid}` already exists"));
9645                }
9646                fid
9647            }
9648            Some(Value::String(_)) => return Err("fact_id must not be empty".to_string()),
9649            Some(_) => return Err("fact_id must be a string".to_string()),
9650            // Auto-minted `ws-<count>` ids must dodge collisions too: a caller
9651            // may legally pass a literal `ws-7`, and deletions or a reload can
9652            // shrink the count past it. Bump past any id already in the graph
9653            // rather than letting ingest_fact silently rewrite to `ws-7-2`.
9654            None => {
9655                let mut n = engine.valid_fact_count();
9656                loop {
9657                    let candidate = format!("ws-{n}");
9658                    if engine.graph.get_by_fact_id(&candidate).is_none() {
9659                        break candidate;
9660                    }
9661                    n += 1;
9662                }
9663            }
9664        };
9665        let nix = engine
9666            .try_ingest_fact(
9667                &fid,
9668                subject,
9669                body,
9670                "user",
9671                "peer",
9672                chrono::Utc::now(),
9673                "global",
9674                None,
9675                vec![],
9676                kind == "constraint",
9677                car_verify::CommitAuthority::Execution,
9678                committed_by,
9679                verdicts,
9680            )
9681            .map_err(|decision| {
9682                // Surface the refusal reasons rather than a bare "denied" —
9683                // the caller needs to know which half it failed.
9684                format!(
9685                    "memory admission refused for `{}`: {}",
9686                    decision.id,
9687                    serde_json::to_string(&decision.refusals)
9688                        .unwrap_or_else(|_| "<unserialisable>".into())
9689                )
9690            })?;
9691        if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9692            node.metadata.tags = tags;
9693            node.metadata.provenance = vec![car_memgine::Provenance {
9694                source,
9695                ..Default::default()
9696            }];
9697        }
9698        engine.valid_fact_count()
9699    };
9700    // Persist after every add when the session is bound to a durable graph —
9701    // a supervised agent OR a memory namespace. This went through the agent
9702    // branch only, which is why namespace facts were never written (#82).
9703    persist_bound_agent_memory(session, &engine_arc, "memory.add_fact").await;
9704    Ok(Value::from(count))
9705}
9706
9707/// Flush the session's bound graph after a mutation.
9708///
9709/// Covers BOTH durable axes. It used to handle only `agent_id`, so a
9710/// namespace-bound session accumulated facts that were never written anywhere
9711/// and vanished on daemon restart (car-releases#82) — CarHost restarts on every
9712/// Sparkle update, so that was routine data loss, not an edge case.
9713///
9714/// A session binds at most one of the two: `session.auth` picks the namespace
9715/// graph over the agent graph when both are supplied, and `memory_namespace`
9716/// is recorded on exactly the paths that make that choice. Persisting whichever
9717/// is set therefore writes the graph the session is actually using.
9718async fn persist_bound_agent_memory(
9719    session: &crate::session::ClientSession,
9720    engine_arc: &Arc<Mutex<car_memgine::MemgineEngine>>,
9721    op: &str,
9722) {
9723    if let Some(ns) = session.memory_namespace.lock().await.clone() {
9724        if let Err(e) = persist_namespace_memgine(&ns, engine_arc).await {
9725            tracing::warn!(memory_namespace = %ns, error = %e, op = %op,
9726                "namespace memgine persist failed; in-memory state is canonical");
9727        }
9728        return;
9729    }
9730    if let Some(id) = session.agent_id.lock().await.clone() {
9731        if let Err(e) = persist_agent_memgine(&id, engine_arc).await {
9732            tracing::warn!(agent_id = %id, error = %e, op = %op,
9733                "agent memgine persist failed; in-memory state is canonical");
9734        }
9735    }
9736}
9737
9738#[derive(Debug, Deserialize)]
9739struct MemoryUpdateStatusParams {
9740    body: String,
9741    #[serde(default)]
9742    tenant_id: Option<String>,
9743}
9744
9745async fn handle_memory_update_status(
9746    req: &JsonRpcMessage,
9747    session: &crate::session::ClientSession,
9748) -> Result<Value, String> {
9749    let params: MemoryUpdateStatusParams = typed_params(&req.params)?;
9750    let engine_arc = session.effective_memgine().await;
9751    let status = {
9752        let mut engine = engine_arc.lock().await;
9753        engine.update_proactive_status(params.body, params.tenant_id)
9754    };
9755    serde_json::to_value(status).map_err(|e| e.to_string())
9756}
9757
9758async fn handle_memory_maintain(
9759    req: &JsonRpcMessage,
9760    session: &crate::session::ClientSession,
9761) -> Result<Value, String> {
9762    let params: car_memgine::ProactiveMaintenanceRequest = typed_params(&req.params)?;
9763    let events = {
9764        let log = session.runtime.log.lock().await;
9765        log.events().to_vec()
9766    };
9767    let engine_arc = session.effective_memgine().await;
9768    let report = {
9769        let mut engine = engine_arc.lock().await;
9770        engine.maintain_proactive_memory_from_events(&events, &params)
9771    };
9772    if !report.saved.is_empty() {
9773        persist_bound_agent_memory(session, &engine_arc, "memory.maintain").await;
9774    }
9775    {
9776        let mut log = session.runtime.log.lock().await;
9777        log.append(
9778            car_eventlog::EventKind::ProactiveMemoryMaintained,
9779            None,
9780            None,
9781            proactive_maintenance_event_data(&report),
9782        );
9783    }
9784    serde_json::to_value(report).map_err(|e| e.to_string())
9785}
9786
9787async fn handle_memory_save_knowledge(
9788    req: &JsonRpcMessage,
9789    session: &crate::session::ClientSession,
9790) -> Result<Value, String> {
9791    let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
9792    let engine_arc = session.effective_memgine().await;
9793    let saved = {
9794        let mut engine = engine_arc.lock().await;
9795        engine.save_proactive_knowledge(save)
9796    };
9797    persist_bound_agent_memory(session, &engine_arc, "memory.save_knowledge").await;
9798    serde_json::to_value(saved).map_err(|e| e.to_string())
9799}
9800
9801async fn handle_memory_save_procedural(
9802    req: &JsonRpcMessage,
9803    session: &crate::session::ClientSession,
9804) -> Result<Value, String> {
9805    let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
9806    let engine_arc = session.effective_memgine().await;
9807    let saved = {
9808        let mut engine = engine_arc.lock().await;
9809        engine.save_proactive_procedural(save)
9810    };
9811    persist_bound_agent_memory(session, &engine_arc, "memory.save_procedural").await;
9812    serde_json::to_value(saved).map_err(|e| e.to_string())
9813}
9814
9815#[derive(Debug, Deserialize)]
9816struct MemoryDeleteParams {
9817    id: String,
9818}
9819
9820async fn handle_memory_delete(
9821    req: &JsonRpcMessage,
9822    session: &crate::session::ClientSession,
9823) -> Result<Value, String> {
9824    let params: MemoryDeleteParams = typed_params(&req.params)?;
9825    let engine_arc = session.effective_memgine().await;
9826    let deleted = {
9827        let mut engine = engine_arc.lock().await;
9828        engine.delete_proactive_memory(&params.id)
9829    };
9830    if deleted.deleted
9831        && !matches!(
9832            deleted.kind,
9833            Some(car_memgine::ProactiveMemoryEntryKind::Status)
9834        )
9835    {
9836        persist_bound_agent_memory(session, &engine_arc, "memory.delete").await;
9837    }
9838    serde_json::to_value(deleted).map_err(|e| e.to_string())
9839}
9840
9841async fn handle_memory_query(
9842    req: &JsonRpcMessage,
9843    session: &crate::session::ClientSession,
9844) -> Result<Value, String> {
9845    let query = req
9846        .params
9847        .get("query")
9848        .and_then(|v| v.as_str())
9849        .ok_or("missing query")?;
9850    let k = req.params.get("k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
9851    let engine_arc = session.effective_memgine().await;
9852    let engine = engine_arc.lock().await;
9853    let seeds = engine.graph.find_seeds(query, 5);
9854    // FFI parity with NAPI `query_facts` — both use Personalized PageRank so
9855    // transport choice doesn't shift ranking semantics. The bindings proxy this
9856    // result directly, including caller-authored identity and provenance.
9857    let hits = if !seeds.is_empty() {
9858        engine.graph.retrieve_ppr(&seeds, None, 0.5, k)
9859    } else {
9860        vec![]
9861    };
9862    let results: Vec<Value> = hits
9863        .iter()
9864        .filter_map(|hit| {
9865            let node = engine.graph.inner.node_weight(hit.node_ix)?;
9866            Some(serde_json::json!({
9867                "fact_id": node.fact_id,
9868                "subject": node.key,
9869                "body": node.value,
9870                "kind": format!("{:?}", node.kind).to_lowercase(),
9871                "confidence": hit.activation,
9872                "tags": node.metadata.tags,
9873                "source": node
9874                    .metadata
9875                    .provenance
9876                    .first()
9877                    .map(|entry| entry.source.as_str())
9878                    .unwrap_or(""),
9879            }))
9880        })
9881        .collect();
9882    serde_json::to_value(results).map_err(|e| e.to_string())
9883}
9884
9885/// `memory.intervene` — proactive memory control-loop hook. Returns either a
9886/// concise transient reminder grounded in the graph, or an explicit silence
9887/// decision when no memory is strong enough to interrupt the next action.
9888async fn handle_memory_intervene(
9889    req: &JsonRpcMessage,
9890    session: &crate::session::ClientSession,
9891) -> Result<Value, String> {
9892    let request: car_memgine::ProactiveMemoryRequest = typed_params(&req.params)?;
9893    let engine_arc = session.effective_memgine().await;
9894    let decision = {
9895        let mut engine = engine_arc.lock().await;
9896        engine.proactive_intervention(&request)
9897    };
9898    {
9899        let mut log = session.runtime.log.lock().await;
9900        log.append(
9901            car_eventlog::EventKind::ProactiveMemoryIntervention,
9902            None,
9903            None,
9904            proactive_intervention_event_data(&decision),
9905        );
9906    }
9907    serde_json::to_value(decision).map_err(|e| e.to_string())
9908}
9909
9910/// `memory.evaluate` — offline calibration hook for proactive memory. Runs
9911/// labeled next-action cases against the current memory graph and compares the
9912/// selective selector against always-inject, passive-retrieval, and no-memory
9913/// baselines.
9914async fn handle_memory_evaluate(
9915    req: &JsonRpcMessage,
9916    session: &crate::session::ClientSession,
9917) -> Result<Value, String> {
9918    let request: car_memgine::ProactiveEvaluationRequest = typed_params(&req.params)?;
9919    let engine_arc = session.effective_memgine().await;
9920    let engine = engine_arc.lock().await;
9921    let report = engine.evaluate_proactive_memory(&request);
9922    serde_json::to_value(report).map_err(|e| e.to_string())
9923}
9924
9925async fn maybe_apply_proactive_memory(
9926    msg: &JsonRpcMessage,
9927    session: &crate::session::ClientSession,
9928    req: &mut car_inference::GenerateRequest,
9929) -> Result<(), String> {
9930    let tier = session.permission_gate.read().await.granted_tier();
9931    let mut request = match proactive_memory_activation(&msg.params, req, tier)? {
9932        ProactiveMemoryActivation::Disabled => return Ok(()),
9933        ProactiveMemoryActivation::Default => car_memgine::ProactiveMemoryRequest::default(),
9934        ProactiveMemoryActivation::Request(request) => request,
9935    };
9936
9937    if request.query.trim().is_empty() {
9938        request.query = msg
9939            .params
9940            .get("context_query")
9941            .and_then(|v| v.as_str())
9942            .filter(|s| !s.trim().is_empty())
9943            .unwrap_or(&req.prompt)
9944            .to_string();
9945    }
9946    if request.recent.is_empty() && !req.prompt.trim().is_empty() {
9947        request.recent.push(req.prompt.clone());
9948    }
9949    let events = {
9950        let log = session.runtime.log.lock().await;
9951        log.events().to_vec()
9952    };
9953    let engine_arc = session.effective_memgine().await;
9954    let maintenance = {
9955        let mut engine = engine_arc.lock().await;
9956        engine.maintain_proactive_memory_from_events(
9957            &events,
9958            &car_memgine::ProactiveMaintenanceRequest {
9959                max_recent: 32,
9960                tenant_id: request.tenant_id.clone(),
9961            },
9962        )
9963    };
9964    if !maintenance.saved.is_empty() {
9965        persist_bound_agent_memory(session, &engine_arc, "memory_intervention.maintain").await;
9966    }
9967    {
9968        let mut log = session.runtime.log.lock().await;
9969        log.append(
9970            car_eventlog::EventKind::ProactiveMemoryMaintained,
9971            None,
9972            None,
9973            proactive_maintenance_event_data(&maintenance),
9974        );
9975    }
9976    let mut derived_trigger = maintenance.trigger;
9977    if tier == car_policy::PermissionTier::FullAccess
9978        || req.intent.as_ref().is_some_and(|intent| intent.high_stakes)
9979    {
9980        derived_trigger.high_risk_action = true;
9981    }
9982    request.trigger.merge(derived_trigger);
9983
9984    let decision = {
9985        let mut engine = engine_arc.lock().await;
9986        engine.proactive_intervention(&request)
9987    };
9988    {
9989        let mut log = session.runtime.log.lock().await;
9990        log.append(
9991            car_eventlog::EventKind::ProactiveMemoryIntervention,
9992            None,
9993            None,
9994            proactive_intervention_event_data(&decision),
9995        );
9996    }
9997    if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
9998        append_context_block(req, "Proactive Memory", &reminder);
9999    }
10000    Ok(())
10001}
10002
10003enum ProactiveMemoryActivation {
10004    Disabled,
10005    Default,
10006    Request(car_memgine::ProactiveMemoryRequest),
10007}
10008
10009fn proactive_memory_activation(
10010    params: &Value,
10011    req: &car_inference::GenerateRequest,
10012    tier: car_policy::PermissionTier,
10013) -> Result<ProactiveMemoryActivation, String> {
10014    match params.get("memory_intervention") {
10015        Some(raw) if raw.as_bool() == Some(false) || raw.is_null() => {
10016            Ok(ProactiveMemoryActivation::Disabled)
10017        }
10018        Some(raw) if raw.as_bool() == Some(true) => Ok(ProactiveMemoryActivation::Default),
10019        Some(raw) => serde_json::from_value(raw.clone())
10020            .map(ProactiveMemoryActivation::Request)
10021            .map_err(|e| format!("invalid memory_intervention: {e}")),
10022        None if should_auto_apply_proactive_memory(req, tier) => {
10023            Ok(ProactiveMemoryActivation::Default)
10024        }
10025        None => Ok(ProactiveMemoryActivation::Disabled),
10026    }
10027}
10028
10029fn should_auto_apply_proactive_memory(
10030    req: &car_inference::GenerateRequest,
10031    tier: car_policy::PermissionTier,
10032) -> bool {
10033    if tier == car_policy::PermissionTier::FullAccess {
10034        return true;
10035    }
10036    if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
10037        return true;
10038    }
10039    let Some(intent) = req.intent.as_ref() else {
10040        return false;
10041    };
10042    if intent.high_stakes {
10043        return true;
10044    }
10045    matches!(
10046        intent.task,
10047        Some(car_inference::TaskHint::Code | car_inference::TaskHint::Reasoning)
10048    )
10049}
10050
10051fn proactive_maintenance_event_data(
10052    report: &car_memgine::ProactiveMaintenanceReport,
10053) -> HashMap<String, Value> {
10054    let mut data = proactive_trigger_event_data(&report.trigger);
10055    data.insert(
10056        "saved_count".to_string(),
10057        Value::from(report.saved.len() as u64),
10058    );
10059    data.insert(
10060        "skipped_existing".to_string(),
10061        Value::from(report.skipped_existing as u64),
10062    );
10063    data.insert(
10064        "status_updated".to_string(),
10065        Value::from(report.status.is_some()),
10066    );
10067    data
10068}
10069
10070fn proactive_intervention_event_data(
10071    decision: &car_memgine::ProactiveMemoryDecision,
10072) -> HashMap<String, Value> {
10073    let mut data = HashMap::new();
10074    match decision {
10075        car_memgine::ProactiveMemoryDecision::Inject {
10076            selected,
10077            candidates,
10078            bank,
10079            ..
10080        } => {
10081            data.insert("decision".to_string(), Value::from("inject"));
10082            data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
10083            data.insert(
10084                "selected_kind".to_string(),
10085                Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
10086            );
10087            data.insert(
10088                "candidate_count".to_string(),
10089                Value::from(candidates.len() as u64),
10090            );
10091            data.insert(
10092                "bank_knowledge".to_string(),
10093                Value::from(bank.knowledge as u64),
10094            );
10095            data.insert(
10096                "bank_procedural".to_string(),
10097                Value::from(bank.procedural as u64),
10098            );
10099            data.insert(
10100                "bank_open_subgoals".to_string(),
10101                Value::from(bank.open_subgoals as u64),
10102            );
10103        }
10104        car_memgine::ProactiveMemoryDecision::Silent {
10105            reason,
10106            candidates,
10107            bank,
10108        } => {
10109            data.insert("decision".to_string(), Value::from("silent"));
10110            data.insert("reason".to_string(), Value::from(reason.clone()));
10111            data.insert(
10112                "candidate_count".to_string(),
10113                Value::from(candidates.len() as u64),
10114            );
10115            data.insert(
10116                "bank_knowledge".to_string(),
10117                Value::from(bank.knowledge as u64),
10118            );
10119            data.insert(
10120                "bank_procedural".to_string(),
10121                Value::from(bank.procedural as u64),
10122            );
10123            data.insert(
10124                "bank_open_subgoals".to_string(),
10125                Value::from(bank.open_subgoals as u64),
10126            );
10127        }
10128    }
10129    data
10130}
10131
10132fn proactive_trigger_event_data(
10133    trigger: &car_memgine::ProactiveMemoryTrigger,
10134) -> HashMap<String, Value> {
10135    HashMap::from([
10136        (
10137            "repeated_failures".to_string(),
10138            Value::from(trigger.repeated_failures as u64),
10139        ),
10140        ("tool_error".to_string(), Value::from(trigger.tool_error)),
10141        (
10142            "explicit_uncertainty".to_string(),
10143            Value::from(trigger.explicit_uncertainty),
10144        ),
10145        (
10146            "high_risk_action".to_string(),
10147            Value::from(trigger.high_risk_action),
10148        ),
10149        (
10150            "context_shift".to_string(),
10151            Value::from(trigger.context_shift),
10152        ),
10153    ])
10154}
10155
10156fn append_context_block(req: &mut car_inference::GenerateRequest, title: &str, body: &str) {
10157    let block = format!("## {title}\n{body}");
10158    req.context = Some(match req.context.take() {
10159        Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
10160        _ => block,
10161    });
10162}
10163
10164#[cfg(test)]
10165mod proactive_memory_activation_tests {
10166    use super::{proactive_memory_activation, ProactiveMemoryActivation};
10167    use car_inference::{GenerateRequest, IntentHint, TaskHint};
10168    use car_policy::PermissionTier;
10169    use serde_json::json;
10170
10171    fn req() -> GenerateRequest {
10172        GenerateRequest {
10173            prompt: "finish the task".to_string(),
10174            ..Default::default()
10175        }
10176    }
10177
10178    fn is_disabled(activation: ProactiveMemoryActivation) -> bool {
10179        matches!(activation, ProactiveMemoryActivation::Disabled)
10180    }
10181
10182    fn is_default(activation: ProactiveMemoryActivation) -> bool {
10183        matches!(activation, ProactiveMemoryActivation::Default)
10184    }
10185
10186    #[test]
10187    fn explicit_false_disables_even_when_auto_eligible() {
10188        let mut req = req();
10189        req.tools = Some(vec![json!({"name": "edit_file"})]);
10190        let activation = proactive_memory_activation(
10191            &json!({"memory_intervention": false}),
10192            &req,
10193            PermissionTier::FullAccess,
10194        )
10195        .unwrap();
10196        assert!(is_disabled(activation));
10197    }
10198
10199    #[test]
10200    fn explicit_true_uses_default_request() {
10201        let activation = proactive_memory_activation(
10202            &json!({"memory_intervention": true}),
10203            &req(),
10204            PermissionTier::ReadOnly,
10205        )
10206        .unwrap();
10207        assert!(is_default(activation));
10208    }
10209
10210    #[test]
10211    fn explicit_object_is_preserved() {
10212        let activation = proactive_memory_activation(
10213            &json!({
10214                "memory_intervention": {
10215                    "query": "pricing rollout",
10216                    "tenant_id": "tenant-a"
10217                }
10218            }),
10219            &req(),
10220            PermissionTier::ReadOnly,
10221        )
10222        .unwrap();
10223        let ProactiveMemoryActivation::Request(request) = activation else {
10224            panic!("expected explicit request");
10225        };
10226        assert_eq!(request.query, "pricing rollout");
10227        assert_eq!(request.tenant_id.as_deref(), Some("tenant-a"));
10228    }
10229
10230    #[test]
10231    fn ordinary_chat_is_not_auto_enabled() {
10232        let mut req = req();
10233        req.intent = Some(IntentHint {
10234            task: Some(TaskHint::Chat),
10235            ..Default::default()
10236        });
10237        let activation =
10238            proactive_memory_activation(&json!({}), &req, PermissionTier::ReadOnly).unwrap();
10239        assert!(is_disabled(activation));
10240    }
10241
10242    #[test]
10243    fn tool_code_reasoning_and_high_stakes_requests_auto_enable() {
10244        let mut tool_req = req();
10245        tool_req.tools = Some(vec![json!({"name": "shell"})]);
10246        assert!(is_default(
10247            proactive_memory_activation(&json!({}), &tool_req, PermissionTier::ReadOnly).unwrap()
10248        ));
10249
10250        let mut code_req = req();
10251        code_req.intent = Some(IntentHint {
10252            task: Some(TaskHint::Code),
10253            ..Default::default()
10254        });
10255        assert!(is_default(
10256            proactive_memory_activation(&json!({}), &code_req, PermissionTier::ReadOnly).unwrap()
10257        ));
10258
10259        let mut reasoning_req = req();
10260        reasoning_req.intent = Some(IntentHint {
10261            task: Some(TaskHint::Reasoning),
10262            ..Default::default()
10263        });
10264        assert!(is_default(
10265            proactive_memory_activation(&json!({}), &reasoning_req, PermissionTier::ReadOnly)
10266                .unwrap()
10267        ));
10268
10269        assert!(is_default(
10270            proactive_memory_activation(&json!({}), &req(), PermissionTier::FullAccess).unwrap()
10271        ));
10272    }
10273}
10274
10275async fn handle_memory_build_context(
10276    req: &JsonRpcMessage,
10277    session: &crate::session::ClientSession,
10278) -> Result<Value, String> {
10279    let query = req
10280        .params
10281        .get("query")
10282        .and_then(|v| v.as_str())
10283        .unwrap_or("");
10284    // FFI parity with NAPI `build_context(query, model_context_window)`.
10285    // When supplied, sizes the assembly budget against the model's window
10286    // instead of the fixed 8K default.
10287    let model_context_window = req
10288        .params
10289        .get("model_context_window")
10290        .and_then(|v| v.as_u64())
10291        .map(|w| w as usize);
10292    // `effective_memgine`: context assembly must read the graph the session's
10293    // facts actually went into. Reading the ephemeral graph while a namespace
10294    // was bound meant every namespace fact was stored, isolated, queryable —
10295    // and invisible to the model, which is the only consumer that matters.
10296    let engine_arc = session.effective_memgine().await;
10297    let mut engine = engine_arc.lock().await;
10298    Ok(Value::from(
10299        engine.build_context_for_model(query, model_context_window),
10300    ))
10301}
10302
10303/// `memory.build_context_fast` — Fast-mode context assembly for
10304/// latency-sensitive paths (voice, real-time). Skips embedding flush,
10305/// skill lookup, PPR-based scoring, inline repairs, known-unknowns
10306/// extraction. Keeps identity, constraints, facts (creation order),
10307/// conversation, environment.
10308async fn handle_memory_build_context_fast(
10309    req: &JsonRpcMessage,
10310    session: &crate::session::ClientSession,
10311) -> Result<Value, String> {
10312    let query = req
10313        .params
10314        .get("query")
10315        .and_then(|v| v.as_str())
10316        .unwrap_or("");
10317    let model_context_window = req
10318        .params
10319        .get("model_context_window")
10320        .and_then(|v| v.as_u64())
10321        .map(|w| w as usize);
10322    // Same binding fix as build_context above (#82).
10323    let engine_arc = session.effective_memgine().await;
10324    let mut engine = engine_arc.lock().await;
10325    Ok(Value::from(engine.build_context_with_options(
10326        query,
10327        model_context_window,
10328        car_memgine::ContextMode::Fast,
10329        None,
10330    )))
10331}
10332
10333/// `memory.persist` — write the session's memgine to a JSON file
10334/// at `path`. Mirrors NAPI `persist_memory` (car-ffi-napi/src/lib.rs:797)
10335/// so daemon-mode clients can drive checkpoint/restore symmetrically
10336/// with embedded mode. Returns the number of facts written.
10337///
10338/// Filesystem caveat: `path` is interpreted on the daemon's filesystem,
10339/// not the caller's. Since the 2026-05 audit, `path` is also
10340/// sandboxed under `~/.car/memory/` via
10341/// [`car_ffi_common::memory_path::resolve`] — relative paths land
10342/// under the base, absolute paths must already be under the base,
10343/// `..` segments are rejected, symlinks pointing out are rejected.
10344/// Pre-2026-05 the path was passed straight to `std::fs::write` and
10345/// became an arbitrary file-write primitive. The base64-blob escape
10346/// hatch tracked in `Parslee-ai/car-releases#31` will plug into the
10347/// same resolver when it lands.
10348struct ProtocolHandshakeError {
10349    code: i32,
10350    message: String,
10351}
10352
10353/// Negotiate this connection's daemon JSON-RPC protocol.
10354///
10355/// Protocol v3 is exact-version only. Capability negotiation follows transport
10356/// authentication and rejects any unsupported mandatory capability. The
10357/// negotiated version and capabilities are stored on
10358/// `ClientSession`, never `ServerState`, so a transport reconnect cannot inherit
10359/// another socket's compatibility proof. Repeating the same handshake is
10360/// intentionally idempotent.
10361fn handle_server_handshake(
10362    req: &JsonRpcMessage,
10363    session: &crate::session::ClientSession,
10364) -> Result<Value, ProtocolHandshakeError> {
10365    let client_protocol = req
10366        .params
10367        .get("protocol_version")
10368        .and_then(Value::as_u64)
10369        .ok_or_else(|| ProtocolHandshakeError {
10370            code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10371            message: format!(
10372                "{} `server.handshake` requires an unsigned numeric \
10373                 `protocol_version`; this daemon requires v{}",
10374                car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10375                car_proto::PROTOCOL_VERSION,
10376            ),
10377        })?;
10378
10379    if client_protocol != u64::from(car_proto::PROTOCOL_VERSION) {
10380        return Err(ProtocolHandshakeError {
10381            code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10382            message: format!(
10383                "{} client requested v{}, but this daemon requires v{}; \
10384                 update/restart CarHost and car-server so their wire protocols match",
10385                car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10386                client_protocol,
10387                car_proto::PROTOCOL_VERSION,
10388            ),
10389        });
10390    }
10391
10392    fn string_array(params: &Value, field: &str) -> Result<Vec<String>, ProtocolHandshakeError> {
10393        let Some(value) = params.get(field) else {
10394            return Ok(Vec::new());
10395        };
10396        let values = value.as_array().ok_or_else(|| ProtocolHandshakeError {
10397            code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10398            message: format!(
10399                "{} `{field}` must be an array of capability strings",
10400                car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10401            ),
10402        })?;
10403        values
10404            .iter()
10405            .map(|value| {
10406                value
10407                    .as_str()
10408                    .filter(|capability| !capability.trim().is_empty())
10409                    .map(str::to_string)
10410                    .ok_or_else(|| ProtocolHandshakeError {
10411                        code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10412                        message: format!(
10413                            "{} `{field}` entries must be non-empty strings",
10414                            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10415                        ),
10416                    })
10417            })
10418            .collect()
10419    }
10420
10421    let required_capabilities = string_array(&req.params, "required_capabilities")?;
10422    let optional_capabilities = string_array(&req.params, "optional_capabilities")?;
10423    let negotiated_capabilities =
10424        car_proto::negotiate_capabilities(&required_capabilities, &optional_capabilities).map_err(
10425            |missing| ProtocolHandshakeError {
10426                code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10427                message: format!(
10428            "{} unsupported mandatory capabilities: {}; update/restart CarHost and car-server",
10429            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10430            missing.join(", "),
10431        ),
10432            },
10433        )?;
10434
10435    session.negotiated_protocol_version.store(
10436        car_proto::PROTOCOL_VERSION,
10437        std::sync::atomic::Ordering::Release,
10438    );
10439    *session
10440        .negotiated_capabilities
10441        .write()
10442        .expect("negotiated capability lock poisoned") =
10443        negotiated_capabilities.iter().cloned().collect();
10444
10445    // The assistant's identity rides on the handshake every host already
10446    // performs, so a host knows what to call the assistant before it renders a
10447    // single label. `load_or_default` deliberately keeps a damaged optional
10448    // identity record from locking every host out of the daemon; the dedicated
10449    // identity RPC remains the surface that reports that read error.
10450    let identity = car_identity::IdentityStore::from_home().load_or_default();
10451
10452    // Echo the client's own build version back. The Rust clients have always
10453    // sent it and this daemon always threw it away, so the only place a
10454    // `car-runtime` package's version ever appeared was inside the skew
10455    // warning's prose — and `car --version` answers for the bundled CLI, a
10456    // different component (Parslee-ai/car#1050). Echoing makes it a readable
10457    // field on the handshake every client already performs. Additive, so no
10458    // PROTOCOL_VERSION bump.
10459    //
10460    // `unknown` is the honest answer, not a placeholder to read past: the
10461    // native CarHost clients (macOS, iOS, Android) and the dashboard send no
10462    // `client_version`, so they land here. Do not treat this log line or field
10463    // as identifying every connect — only the ones that report.
10464    //
10465    // Truncated because it is client-controlled and otherwise unbounded. A
10466    // semver plus pre-release metadata fits well inside this; anything longer
10467    // is not a version, and it should not reach the log or the reply at full
10468    // length.
10469    const MAX_CLIENT_VERSION: usize = 64;
10470    let reported = req
10471        .params
10472        .get("client_version")
10473        .and_then(Value::as_str)
10474        .unwrap_or("unknown");
10475    let client_version = match reported.char_indices().nth(MAX_CLIENT_VERSION) {
10476        Some((cut, _)) => &reported[..cut],
10477        None => reported,
10478    };
10479    tracing::debug!(
10480        target: "car_server_core::handler",
10481        client_version,
10482        server_version = env!("CARGO_PKG_VERSION"),
10483        "server.handshake negotiated"
10484    );
10485
10486    Ok(serde_json::json!({
10487        "protocol_version": car_proto::PROTOCOL_VERSION,
10488        "server_version": env!("CARGO_PKG_VERSION"),
10489        "client_protocol_version": client_protocol,
10490        "client_version": client_version,
10491        "negotiated_capabilities": negotiated_capabilities,
10492        "assistant_name": identity.name,
10493        "assistant_aliases": identity.aliases(),
10494        "assistant_brand": car_identity::BRAND_NAME,
10495    }))
10496}
10497
10498async fn handle_memory_persist(
10499    req: &JsonRpcMessage,
10500    session: &crate::session::ClientSession,
10501) -> Result<Value, String> {
10502    let path = req
10503        .params
10504        .get("path")
10505        .and_then(|v| v.as_str())
10506        .ok_or("missing path")?;
10507    let resolved = car_ffi_common::memory_path::resolve(path)
10508        .map_err(|e| format!("memory.persist rejected path {path:?}: {e}"))?;
10509    // `effective_memgine`, not the ephemeral `memgine`: with a namespace or an
10510    // agent bound, reading the ephemeral graph serialized an EMPTY array and
10511    // reported 0 records while the real facts sat in the bound graph
10512    // (car-releases#82). A durability call that reports success having written
10513    // nothing is worse than one that fails.
10514    let engine_arc = session.effective_memgine().await;
10515    let engine = engine_arc.lock().await;
10516    let facts = memory_snapshot_facts(&engine);
10517    let count = facts.len();
10518    // Release the shared memgine lock, then serialize+write on the blocking pool
10519    // (see persist_agent_memgine): never hold the lock across blocking I/O, keep
10520    // the write off the tokio worker, and let the deadline preempt at the await.
10521    drop(engine);
10522    let resolved_owned = resolved.clone();
10523    tokio::task::spawn_blocking(move || -> Result<(), String> {
10524        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
10525        atomic_write_sync(&resolved_owned, json.as_bytes())
10526            .map_err(|e| format!("failed to write {}: {}", resolved_owned.display(), e))
10527    })
10528    .await
10529    .map_err(|e| format!("memory.persist join: {e}"))??;
10530    Ok(Value::from(count as u64))
10531}
10532
10533/// `memory.load` — replace the session's memgine with facts from the
10534/// JSON file at `path`. Mirrors NAPI `load_memory`
10535/// (car-ffi-napi/src/lib.rs:121). Same `~/.car/memory/` sandboxing
10536/// as `memory.persist` since the 2026-05 audit — relative paths
10537/// land under the base, anything that escapes is rejected.
10538async fn handle_memory_load(
10539    req: &JsonRpcMessage,
10540    session: &crate::session::ClientSession,
10541) -> Result<Value, String> {
10542    let path = req
10543        .params
10544        .get("path")
10545        .and_then(|v| v.as_str())
10546        .ok_or("missing path")?;
10547    let resolved = car_ffi_common::memory_path::resolve(path)
10548        .map_err(|e| format!("memory.load rejected path {path:?}: {e}"))?;
10549    let content = std::fs::read_to_string(&resolved)
10550        .map_err(|e| format!("failed to read {}: {}", resolved.display(), e))?;
10551    let facts: Vec<Value> =
10552        serde_json::from_str(&content).map_err(|e| format!("invalid JSON: {}", e))?;
10553    // Symmetric with memory.persist above. This one was worse than a no-op:
10554    // `reset()` cleared the EPHEMERAL graph and loaded into it, so a
10555    // namespace-bound caller restoring a snapshot silently left the bound
10556    // graph untouched and believed it had been restored.
10557    let engine_arc = session.effective_memgine().await;
10558    let mut engine = engine_arc.lock().await;
10559    engine.reset();
10560    let mut count: u32 = 0;
10561    for fact in &facts {
10562        ingest_snapshot_fact(&mut engine, fact, format!("loaded-{count}"));
10563        count += 1;
10564    }
10565    Ok(Value::from(count))
10566}
10567
10568// --- Skill handlers ---
10569
10570async fn handle_skill_ingest(
10571    req: &JsonRpcMessage,
10572    session: &crate::session::ClientSession,
10573) -> Result<Value, String> {
10574    let name = req
10575        .params
10576        .get("name")
10577        .and_then(|v| v.as_str())
10578        .ok_or("missing name")?;
10579    let code = req
10580        .params
10581        .get("code")
10582        .and_then(|v| v.as_str())
10583        .ok_or("missing code")?;
10584    let platform = req
10585        .params
10586        .get("platform")
10587        .and_then(|v| v.as_str())
10588        .unwrap_or("unknown");
10589    let persona = req
10590        .params
10591        .get("persona")
10592        .and_then(|v| v.as_str())
10593        .unwrap_or("");
10594    let url_pattern = req
10595        .params
10596        .get("url_pattern")
10597        .and_then(|v| v.as_str())
10598        .unwrap_or("");
10599    let description = req
10600        .params
10601        .get("description")
10602        .and_then(|v| v.as_str())
10603        .unwrap_or("");
10604    let supersedes = opt_str(&req.params, "supersedes");
10605    let keywords: Vec<String> = req
10606        .params
10607        .get("task_keywords")
10608        .and_then(|v| v.as_array())
10609        .map(|arr| {
10610            arr.iter()
10611                .filter_map(|v| v.as_str().map(String::from))
10612                .collect()
10613        })
10614        .unwrap_or_default();
10615
10616    let trigger = car_memgine::SkillTrigger {
10617        persona: persona.into(),
10618        url_pattern: url_pattern.into(),
10619        task_keywords: keywords,
10620        structured: None,
10621    };
10622    let mut engine = session.memgine.lock().await;
10623    let node = engine.ingest_skill(
10624        name,
10625        code,
10626        platform,
10627        trigger,
10628        description,
10629        supersedes,
10630        vec![],
10631        vec![],
10632    );
10633    Ok(Value::from(node.index() as u64))
10634}
10635
10636async fn handle_skill_find(
10637    req: &JsonRpcMessage,
10638    session: &crate::session::ClientSession,
10639) -> Result<Value, String> {
10640    let persona = req
10641        .params
10642        .get("persona")
10643        .and_then(|v| v.as_str())
10644        .unwrap_or("");
10645    let url = str_or(&req.params, "url", "");
10646    let task = req
10647        .params
10648        .get("task")
10649        .and_then(|v| v.as_str())
10650        .unwrap_or("");
10651    let max = req
10652        .params
10653        .get("max_results")
10654        .and_then(|v| v.as_u64())
10655        .unwrap_or(1) as usize;
10656    // Tenant-scoped matching (linus review C-3): a bound/param tenant
10657    // sees only its own skills; an unscoped caller sees only unscoped
10658    // skills. Strict isolation over the WS boundary, consistent with
10659    // state.keys/state.snapshot — the legacy see-everything find_skill
10660    // remains available to in-process embedders only.
10661    let tenant = effective_tenant(req, session).await?;
10662    let engine = session.memgine.lock().await;
10663    let results = engine.find_skill_scoped(persona, url, task, max, tenant.as_deref());
10664    let json: Vec<Value> = results
10665        .iter()
10666        .map(|(m, s)| {
10667            serde_json::json!({
10668                "name": m.name, "code": m.code, "platform": m.platform,
10669                "description": m.description, "stats": m.stats, "match_score": s,
10670            })
10671        })
10672        .collect();
10673    serde_json::to_value(json).map_err(|e| e.to_string())
10674}
10675
10676async fn handle_skill_report(
10677    req: &JsonRpcMessage,
10678    session: &crate::session::ClientSession,
10679) -> Result<Value, String> {
10680    let name = req
10681        .params
10682        .get("skill_name")
10683        .and_then(|v| v.as_str())
10684        .ok_or("missing skill_name")?;
10685    let outcome_str = req
10686        .params
10687        .get("outcome")
10688        .and_then(|v| v.as_str())
10689        .ok_or("missing outcome")?;
10690    let outcome = match outcome_str {
10691        "success" => car_memgine::SkillOutcome::Success,
10692        _ => car_memgine::SkillOutcome::Fail,
10693    };
10694    // Tenant-exact resolution (linus review): one tenant's outcome
10695    // reports must not degrade another tenant's same-named skill.
10696    let tenant = effective_tenant(req, session).await?;
10697    let mut engine = session.memgine.lock().await;
10698    let stats = engine
10699        .report_outcome_scoped(name, outcome, tenant.as_deref())
10700        .ok_or(format!("skill '{}' not found", name))?;
10701    serde_json::to_value(stats).map_err(|e| e.to_string())
10702}
10703
10704/// Gate a skill's deployment capability against its provenance, folding the
10705/// named skill's **live** track record into the decision (arXiv 2602.12430
10706/// "Agent Skills"; `docs/proposals/skill-trust-governance.md`). The host
10707/// supplies the static `provenance` (signature/scan/source — typically built
10708/// from a `car-bundle` manifest via `assess_signature_trust`) and the
10709/// `requested_tier`; the engine overrides the lifecycle counts with the skill's
10710/// real `success_count`/`fail_count`, so a skill failing in the field is denied
10711/// despite an official signature. Returns the `SkillDeploymentDecision` JSON.
10712async fn handle_skill_gate_deployment(
10713    req: &JsonRpcMessage,
10714    session: &crate::session::ClientSession,
10715) -> Result<Value, String> {
10716    let name = req
10717        .params
10718        .get("skill_name")
10719        .and_then(|v| v.as_str())
10720        .ok_or("missing skill_name")?;
10721    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10722        req.params
10723            .get("provenance")
10724            .cloned()
10725            .unwrap_or_else(|| serde_json::json!({})),
10726    )
10727    .map_err(|e| format!("invalid provenance: {e}"))?;
10728    let tier_str = req
10729        .params
10730        .get("requested_tier")
10731        .and_then(|v| v.as_str())
10732        .ok_or("missing requested_tier")?;
10733    let requested =
10734        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10735            format!(
10736                "invalid requested_tier '{tier_str}' \
10737                 (expected read_only|sandbox_edit|full_access)"
10738            )
10739        })?;
10740    let engine = session.memgine.lock().await;
10741    let decision = engine.gate_skill_deployment(name, provenance, requested);
10742    serde_json::to_value(decision).map_err(|e| e.to_string())
10743}
10744
10745/// Enforce a skill's deployment at load time against the session's durable
10746/// `ApprovalLedger` — the HITL bridge (arXiv 2602.12430 "Agent Skills" Slice 4;
10747/// `docs/proposals/skill-trust-governance.md`). Gates the skill (folding its
10748/// live track record), then resolves the verdict against standing operator
10749/// decisions: `Allow`/`Downgrade` deploy autonomously (a downgrade is the gate's
10750/// own safe mitigation), a `Deny` is overridden if an operator previously
10751/// approved it, blocked if rejected, or surfaced as a `pending_approval` the host
10752/// routes through the existing `permission.approve`/`permission.reject` flow by
10753/// the returned `fingerprint`. Returns `{ decision, enforcement, pending_approval? }`.
10754async fn handle_skill_enforce_deployment(
10755    req: &JsonRpcMessage,
10756    session: &crate::session::ClientSession,
10757    state: &ServerState,
10758) -> Result<Value, String> {
10759    let name = req
10760        .params
10761        .get("skill_name")
10762        .and_then(|v| v.as_str())
10763        .ok_or("missing skill_name")?;
10764    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10765        req.params
10766            .get("provenance")
10767            .cloned()
10768            .unwrap_or_else(|| serde_json::json!({})),
10769    )
10770    .map_err(|e| format!("invalid provenance: {e}"))?;
10771    let tier_str = req
10772        .params
10773        .get("requested_tier")
10774        .and_then(|v| v.as_str())
10775        .ok_or("missing requested_tier")?;
10776    let requested =
10777        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10778            format!(
10779                "invalid requested_tier '{tier_str}' \
10780                 (expected read_only|sandbox_edit|full_access)"
10781            )
10782        })?;
10783
10784    // Gate first (folds the skill's live success/fail record into the verdict).
10785    let decision = {
10786        let engine = session.memgine.lock().await;
10787        engine.gate_skill_deployment(name, provenance, requested)
10788    };
10789    // Then enforce against the daemon's SHARED durable HITL ledger — the same
10790    // ledger `permission.*` records decisions to, from ANY connection (C1).
10791    let enforcement = {
10792        let ledger = state.approval_ledger.read().await;
10793        car_policy::skill_trust::enforce_deployment(&decision, name, requested, &ledger)
10794    };
10795
10796    // Extract the pending fingerprint before the values are moved into the
10797    // response (so the host can resolve it via permission.approve/reject).
10798    let pending_json = enforcement.pending.as_ref().map(|p| {
10799        serde_json::json!({
10800            "fingerprint": p.fingerprint,
10801            "skill_name": p.skill_name,
10802            "requested_tier": requested.as_str(),
10803        })
10804    });
10805    let mut resp = serde_json::json!({ "decision": decision, "enforcement": enforcement });
10806    if let Some(pj) = pending_json {
10807        resp.as_object_mut()
10808            .unwrap()
10809            .insert("pending_approval".into(), pj);
10810    }
10811    Ok(resp)
10812}
10813
10814/// Ingest a skill **through the deployment gate** (arXiv 2602.12430 "Agent
10815/// Skills" — the loader integration). Gates the skill's provenance against the
10816/// requested capability, enforces the verdict against the session's durable
10817/// `ApprovalLedger`, and **only adds the skill to the graph when deployment is
10818/// permitted** — stamping the granted ceiling onto the skill so later execution
10819/// honours it. A denied skill is not ingested; an unseen deny surfaces a
10820/// `pending_approval` resolved through `permission.approve`/`permission.reject`.
10821/// Same flat skill fields as `skill.ingest`, plus `provenance?` and
10822/// `requested_tier`. Returns `{ ingested, node?, decision, enforcement,
10823/// pending_approval? }`.
10824async fn handle_skill_ingest_governed(
10825    req: &JsonRpcMessage,
10826    session: &crate::session::ClientSession,
10827    state: &ServerState,
10828) -> Result<Value, String> {
10829    let name = req
10830        .params
10831        .get("name")
10832        .and_then(|v| v.as_str())
10833        .ok_or("missing name")?;
10834    let code = req
10835        .params
10836        .get("code")
10837        .and_then(|v| v.as_str())
10838        .ok_or("missing code")?;
10839    let platform = req
10840        .params
10841        .get("platform")
10842        .and_then(|v| v.as_str())
10843        .unwrap_or("unknown");
10844    let persona = req
10845        .params
10846        .get("persona")
10847        .and_then(|v| v.as_str())
10848        .unwrap_or("");
10849    let url_pattern = req
10850        .params
10851        .get("url_pattern")
10852        .and_then(|v| v.as_str())
10853        .unwrap_or("");
10854    let description = req
10855        .params
10856        .get("description")
10857        .and_then(|v| v.as_str())
10858        .unwrap_or("");
10859    let supersedes = opt_str(&req.params, "supersedes");
10860    let keywords: Vec<String> = req
10861        .params
10862        .get("task_keywords")
10863        .and_then(|v| v.as_array())
10864        .map(|arr| {
10865            arr.iter()
10866                .filter_map(|v| v.as_str().map(String::from))
10867                .collect()
10868        })
10869        .unwrap_or_default();
10870    let trigger = car_memgine::SkillTrigger {
10871        persona: persona.into(),
10872        url_pattern: url_pattern.into(),
10873        task_keywords: keywords,
10874        structured: None,
10875    };
10876
10877    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10878        req.params
10879            .get("provenance")
10880            .cloned()
10881            .unwrap_or_else(|| serde_json::json!({})),
10882    )
10883    .map_err(|e| format!("invalid provenance: {e}"))?;
10884    let tier_str = req
10885        .params
10886        .get("requested_tier")
10887        .and_then(|v| v.as_str())
10888        .ok_or("missing requested_tier")?;
10889    let requested =
10890        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10891            format!(
10892                "invalid requested_tier '{tier_str}' \
10893                 (expected read_only|sandbox_edit|full_access)"
10894            )
10895        })?;
10896
10897    // Gate + enforce against the daemon's SHARED durable ledger (C1),
10898    // ingesting only on a deploy verdict. Holding the ledger read-lock across
10899    // the engine ingest is safe: no handler holds the engine and
10900    // approval-ledger locks in the opposite order, so there is no inversion
10901    // to deadlock against.
10902    let ledger = state.approval_ledger.read().await;
10903    let out = {
10904        let mut engine = session.memgine.lock().await;
10905        engine.ingest_skill_governed(
10906            name,
10907            code,
10908            platform,
10909            trigger,
10910            description,
10911            supersedes,
10912            vec![],
10913            vec![],
10914            provenance,
10915            requested,
10916            &ledger,
10917        )
10918    };
10919    drop(ledger);
10920
10921    let pending_json = out.enforcement.pending.as_ref().map(|p| {
10922        serde_json::json!({
10923            "fingerprint": p.fingerprint,
10924            "skill_name": p.skill_name,
10925            "requested_tier": requested.as_str(),
10926        })
10927    });
10928    let mut resp = serde_json::json!({
10929        "ingested": out.ingested.is_some(),
10930        "node": out.ingested.map(|n| n.index()),
10931        "decision": out.decision,
10932        "enforcement": out.enforcement,
10933    });
10934    if let Some(pj) = pending_json {
10935        resp.as_object_mut()
10936            .unwrap()
10937            .insert("pending_approval".into(), pj);
10938    }
10939    Ok(resp)
10940}
10941
10942/// Resolve the [`car_policy::skill_trust::SkillProvenance`] a `skill.adopt_pack`
10943/// call is governed under, from the three mutually-exclusive shapes a caller may
10944/// send. Pure (no I/O, no locks) so the precedence is unit-testable:
10945///
10946/// 1. `manifest` — the signed `car-bundle` manifest the pack shipped in. The
10947///    **derived** path: `signed`/`signer_trusted` are computed from it against
10948///    the operator's `trusted` keyring; the caller cannot assert either flag.
10949/// 2. `provenance` — a caller-assembled `SkillProvenance`, taken at **face
10950///    value**: nothing here clamps or cross-checks it, so any caller that can
10951///    reach this method can assert any tier, up to `official` + `full_access`.
10952///    It is a trusted-caller escape hatch for hosts carrying their own
10953///    attestation, inherited verbatim from `skill.ingest_governed`, and it is
10954///    why the operator-keyring rule constrains the `manifest` path only.
10955/// 3. Neither — unsigned and untrusted, with only the scan/source hints the
10956///    caller passed. The conservative default: the gate sees no signature.
10957///
10958/// Sending both `manifest` and `provenance` is an **error**, not a precedence
10959/// question. Silently preferring one would drop a security-relevant input the
10960/// caller believed was in force.
10961fn resolve_adopt_provenance(
10962    params: &Value,
10963    trusted: &[String],
10964) -> Result<car_policy::skill_trust::SkillProvenance, String> {
10965    let scanned = params
10966        .get("scanned")
10967        .and_then(|v| v.as_bool())
10968        .unwrap_or(false);
10969    let vulnerabilities = params
10970        .get("vulnerabilities")
10971        .and_then(|v| v.as_u64())
10972        .unwrap_or(0);
10973    let source_str = params
10974        .get("source")
10975        .and_then(|v| v.as_str())
10976        .unwrap_or("unknown");
10977    let source: car_policy::skill_trust::SkillSource =
10978        serde_json::from_value(Value::String(source_str.to_string())).map_err(|_| {
10979            format!(
10980                "invalid source '{source_str}' \
10981                 (expected official|first_party|community|unknown)"
10982            )
10983        })?;
10984
10985    let manifest_val = params.get("manifest");
10986    let provenance_val = params.get("provenance");
10987    if manifest_val.is_some() && provenance_val.is_some() {
10988        return Err(
10989            "pass either 'manifest' (signature derived from the bundle) or \
10990                    'provenance' (caller-assembled), not both"
10991                .into(),
10992        );
10993    }
10994
10995    if let Some(mv) = manifest_val {
10996        let manifest: car_registry::manifest::AgentManifest =
10997            serde_json::from_value(mv.clone()).map_err(|e| format!("invalid manifest: {e}"))?;
10998        return Ok(car_memgine::MemgineEngine::skill_provenance_from_bundle(
10999            &manifest,
11000            trusted,
11001            scanned,
11002            vulnerabilities,
11003            source,
11004        ));
11005    }
11006
11007    if let Some(pv) = provenance_val {
11008        return serde_json::from_value(pv.clone()).map_err(|e| format!("invalid provenance: {e}"));
11009    }
11010
11011    Ok(car_policy::skill_trust::SkillProvenance {
11012        scanned,
11013        vulnerabilities,
11014        source,
11015        ..Default::default()
11016    })
11017}
11018
11019/// Adopt an installed **skill pack** through the skill-trust deployment gate —
11020/// the daemon call-site for governed pack adoption (arXiv 2602.12430 "Agent
11021/// Skills"; `Parslee-ai/car` `docs/proposals/skill-trust-governance.md`).
11022/// Until this method existed, `ApprovedSkillPack::materialize_into_memgine_governed`
11023/// was reachable only by an in-process Rust consumer supplying its own ledger,
11024/// so a daemon host could not adopt a pack under governance at all.
11025///
11026/// Governance is **unconditional**: there is no ungoverned mode on this method.
11027/// Every skill in the pack is ingested through the gate under one provenance
11028/// (packs are signed and scanned as a unit) and one `requested_tier`, and a
11029/// `Deny` skill never enters the graph.
11030///
11031/// **Adoption is per-skill, so a pack can adopt partially.** The verdict is
11032/// computed per skill (the approval fingerprint is a function of skill name +
11033/// requested tier), so the skills that deploy land in `loaded` while the rest
11034/// surface in `pending`/`refused`; the host resolves those and re-adopts the
11035/// same pack. All-or-nothing would let one unresolved skill block the pack.
11036///
11037/// Provenance comes from the bundle `manifest`, whose signature is verified
11038/// against the operator's `.car/config.toml` `trusted_skill_signers` keyring
11039/// ([`seed_trusted_skill_signers`] — never from the request), or from a
11040/// caller-assembled `provenance`, defaulting conservatively to
11041/// unsigned/unscanned when neither is sent. Only the `manifest` path derives
11042/// its signature flags; a `provenance` object is taken at face value, so a
11043/// caller that can reach this method can assert any trust tier (the
11044/// `skill.ingest_governed` contract, unchanged here) — see
11045/// [`resolve_adopt_provenance`].
11046///
11047/// Params: `pack` (an `ApprovedSkillPack`), `requested_tier?` (default
11048/// `read_only`), and either `manifest?` or `provenance?`, plus optional
11049/// `scanned?`/`vulnerabilities?`/`source?`. Returns `{ loaded, pending, refused,
11050/// requested_tier, provenance, trusted_signers }`; an unseen deny surfaces in
11051/// `pending` with a `fingerprint` the host resolves via
11052/// `permission.approve`/`permission.reject` and then re-adopts.
11053async fn handle_skill_adopt_pack(
11054    req: &JsonRpcMessage,
11055    session: &crate::session::ClientSession,
11056    state: &ServerState,
11057) -> Result<Value, String> {
11058    let pack_val = req.params.get("pack").ok_or("missing 'pack' parameter")?;
11059    let pack: car_memgine::ApprovedSkillPack =
11060        serde_json::from_value(pack_val.clone()).map_err(|e| format!("invalid pack: {e}"))?;
11061    let tier_str = req
11062        .params
11063        .get("requested_tier")
11064        .and_then(|v| v.as_str())
11065        .unwrap_or("read_only");
11066    let requested =
11067        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11068            format!(
11069                "invalid requested_tier '{tier_str}' \
11070                 (expected read_only|sandbox_edit|full_access)"
11071            )
11072        })?;
11073
11074    // The keyring is operator config, never request input — see
11075    // `seed_trusted_skill_signers`.
11076    let trusted = seed_trusted_skill_signers();
11077    let provenance = resolve_adopt_provenance(&req.params, &trusted)?;
11078
11079    // Gate + enforce against the daemon's SHARED durable ledger (C1). Holding
11080    // the ledger read-lock across the engine materialization is safe: no
11081    // handler holds the engine and approval-ledger locks in the opposite
11082    // order, so there is no inversion to deadlock against.
11083    let ledger = state.approval_ledger.read().await;
11084    let out = {
11085        let mut engine = session.memgine.lock().await;
11086        pack.materialize_into_memgine_governed(&mut engine, &provenance, requested, &ledger)
11087    };
11088    drop(ledger);
11089
11090    let pending: Vec<Value> = out
11091        .pending
11092        .iter()
11093        .map(|(skill_id, fingerprint)| {
11094            serde_json::json!({ "skill_id": skill_id, "fingerprint": fingerprint })
11095        })
11096        .collect();
11097    let refused: Vec<Value> = out
11098        .refused
11099        .iter()
11100        .map(|(skill_id, reason)| serde_json::json!({ "skill_id": skill_id, "reason": reason }))
11101        .collect();
11102
11103    Ok(serde_json::json!({
11104        "loaded": out.loaded,
11105        "pending": pending,
11106        "refused": refused,
11107        "requested_tier": requested.as_str(),
11108        "provenance": provenance,
11109        // The COUNT, deliberately — never the key ids. It answers the one
11110        // question an operator debugging a downgrade has ("your keyring is
11111        // empty, that is why a signed pack stopped at Verified") without
11112        // echoing configured identifiers back over the wire to whoever asked.
11113        "trusted_signers": trusted.len(),
11114    }))
11115}
11116
11117// ---------------------------------------------------------------------------
11118// Multi-agent coordination handlers
11119//
11120// The WsAgentRunner sends a `multi.run_agent` JSON-RPC request to the client.
11121// The client runs the model loop and responds with AgentOutput JSON.
11122// ---------------------------------------------------------------------------
11123
11124/// AgentRunner backed by WebSocket callback to the client.
11125struct WsAgentRunner {
11126    channel: Arc<WsChannel>,
11127    host: Arc<crate::host::HostState>,
11128    client_id: String,
11129}
11130
11131#[async_trait::async_trait]
11132impl car_multi::AgentRunner for WsAgentRunner {
11133    async fn run(
11134        &self,
11135        spec: &car_multi::AgentSpec,
11136        task: &str,
11137        _runtime: &car_engine::Runtime,
11138        _mailbox: &car_multi::Mailbox,
11139    ) -> std::result::Result<car_multi::AgentOutput, car_multi::MultiError> {
11140        use futures::SinkExt;
11141
11142        let request_id = self.channel.next_request_id();
11143        let agent_id = agent_id_for_run(&self.client_id, &spec.name, &request_id);
11144        let agent = self
11145            .host
11146            .register_agent(
11147                &self.client_id,
11148                RegisterHostAgentRequest {
11149                    id: Some(agent_id.clone()),
11150                    name: spec.name.clone(),
11151                    kind: "callback".to_string(),
11152                    capabilities: spec.tools.clone(),
11153                    project: spec
11154                        .metadata
11155                        .get("project")
11156                        .and_then(|v| v.as_str())
11157                        .map(str::to_string),
11158                    pid: None,
11159                    display: serde_json::from_value(
11160                        spec.metadata
11161                            .get("display")
11162                            .cloned()
11163                            .unwrap_or(serde_json::Value::Null),
11164                    )
11165                    .unwrap_or_default(),
11166                    metadata: serde_json::to_value(&spec.metadata).unwrap_or(Value::Null),
11167                },
11168            )
11169            .await
11170            .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e))?;
11171        let _ = self
11172            .host
11173            .set_status(
11174                &self.client_id,
11175                SetHostAgentStatusRequest {
11176                    agent_id: agent.id.clone(),
11177                    status: HostAgentStatus::Running,
11178                    current_task: Some(task.to_string()),
11179                    message: Some(format!("{} started", spec.name)),
11180                    payload: serde_json::json!({ "task": task }),
11181                },
11182            )
11183            .await;
11184
11185        let rpc_request = serde_json::json!({
11186            "jsonrpc": "2.0",
11187            "method": "multi.run_agent",
11188            "params": {
11189                "spec": spec,
11190                "task": task,
11191            },
11192            "id": request_id,
11193        });
11194
11195        // Create oneshot channel for the response
11196        let (tx, rx) = tokio::sync::oneshot::channel();
11197        self.channel
11198            .pending
11199            .lock()
11200            .await
11201            .insert(request_id.clone(), tx);
11202
11203        let msg = Message::Text(
11204            serde_json::to_string(&rpc_request)
11205                .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e.to_string()))?
11206                .into(),
11207        );
11208        if let Err(e) = self.channel.write.lock().await.send(msg).await {
11209            let _ = self
11210                .host
11211                .set_status(
11212                    &self.client_id,
11213                    SetHostAgentStatusRequest {
11214                        agent_id: agent_id.clone(),
11215                        status: HostAgentStatus::Errored,
11216                        current_task: None,
11217                        message: Some(format!("{} failed to start", spec.name)),
11218                        payload: serde_json::json!({ "error": e.to_string() }),
11219                    },
11220                )
11221                .await;
11222            return Err(car_multi::MultiError::AgentFailed(
11223                spec.name.clone(),
11224                format!("ws send error: {}", e),
11225            ));
11226        }
11227
11228        // Wait for client response (5 min timeout for model loops)
11229        let response = match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
11230            Ok(Ok(response)) => response,
11231            Ok(Err(_)) => {
11232                let _ = self
11233                    .host
11234                    .set_status(
11235                        &self.client_id,
11236                        SetHostAgentStatusRequest {
11237                            agent_id: agent_id.clone(),
11238                            status: HostAgentStatus::Errored,
11239                            current_task: None,
11240                            message: Some(format!("{} callback channel closed", spec.name)),
11241                            payload: Value::Null,
11242                        },
11243                    )
11244                    .await;
11245                return Err(car_multi::MultiError::AgentFailed(
11246                    spec.name.clone(),
11247                    "agent callback channel closed".into(),
11248                ));
11249            }
11250            Err(_) => {
11251                let _ = self
11252                    .host
11253                    .set_status(
11254                        &self.client_id,
11255                        SetHostAgentStatusRequest {
11256                            agent_id: agent_id.clone(),
11257                            status: HostAgentStatus::Errored,
11258                            current_task: None,
11259                            message: Some(format!("{} timed out", spec.name)),
11260                            payload: Value::Null,
11261                        },
11262                    )
11263                    .await;
11264                return Err(car_multi::MultiError::AgentFailed(
11265                    spec.name.clone(),
11266                    "agent callback timed out (300s)".into(),
11267                ));
11268            }
11269        };
11270
11271        if let Some(err) = response.error {
11272            let _ = self
11273                .host
11274                .set_status(
11275                    &self.client_id,
11276                    SetHostAgentStatusRequest {
11277                        agent_id: agent_id.clone(),
11278                        status: HostAgentStatus::Errored,
11279                        current_task: None,
11280                        message: Some(format!("{} errored", spec.name)),
11281                        payload: serde_json::json!({ "error": err }),
11282                    },
11283                )
11284                .await;
11285            return Err(car_multi::MultiError::AgentFailed(spec.name.clone(), err));
11286        }
11287
11288        let output_value = response.output.unwrap_or(Value::Null);
11289        let output: car_multi::AgentOutput = serde_json::from_value(output_value).map_err(|e| {
11290            car_multi::MultiError::AgentFailed(
11291                spec.name.clone(),
11292                format!("invalid AgentOutput: {}", e),
11293            )
11294        })?;
11295        let status = if output.error.is_some() {
11296            HostAgentStatus::Errored
11297        } else {
11298            HostAgentStatus::Completed
11299        };
11300        let message = if output.error.is_some() {
11301            format!("{} errored", spec.name)
11302        } else {
11303            format!("{} completed", spec.name)
11304        };
11305        let _ = self
11306            .host
11307            .set_status(
11308                &self.client_id,
11309                SetHostAgentStatusRequest {
11310                    agent_id,
11311                    status,
11312                    current_task: None,
11313                    message: Some(message),
11314                    payload: serde_json::to_value(&output).unwrap_or(Value::Null),
11315                },
11316            )
11317            .await;
11318
11319        Ok(output)
11320    }
11321}
11322
11323fn agent_id_for_run(client_id: &str, name: &str, request_id: &str) -> String {
11324    let safe_name: String = name
11325        .chars()
11326        .map(|c| {
11327            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
11328                c
11329            } else {
11330                '-'
11331            }
11332        })
11333        .collect();
11334    format!("{}:{}:{}", client_id, safe_name, request_id)
11335}
11336
11337/// Build a [`car_multi::SharedInfra`], attaching a coordination budget when the
11338/// request carries a `budget` object (a `car_multi::BudgetLimits`, e.g.
11339/// `{"max_total_tokens": 200000, "max_agents": 12}`). Omitted fields are
11340/// unbounded; absent `budget` means no limits.
11341fn multi_infra_with_budget(
11342    req: &JsonRpcMessage,
11343    session: &crate::session::ClientSession,
11344) -> Result<car_multi::SharedInfra, String> {
11345    // Concurrency-anomaly gating (A5) is on for the daemon coordination path:
11346    // an isolated parallel swarm's cross-agent commit barrier is gated (stale
11347    // generation rejects the offending commit, reorder is serialized). The
11348    // infra SHARES the session runtime's state/log/policies (linus review
11349    // C-6): a fresh per-request store made `parent_keys_before` empty, so a
11350    // swarm write over a pre-existing session key could never be classified
11351    // as the read-modify-write that makes it a lost-update hazard — the
11352    // stale-generation check was structurally dead. Sharing also lands the
11353    // gate's audit events in the session log, matching the foreman path.
11354    let infra = car_multi::SharedInfra::with_shared(
11355        std::sync::Arc::clone(&session.runtime.state),
11356        std::sync::Arc::clone(&session.runtime.log),
11357        std::sync::Arc::clone(&session.runtime.policies),
11358    )
11359    .with_concurrency_gating();
11360    match req.params.get("budget") {
11361        None | Some(Value::Null) => Ok(infra),
11362        Some(v) => {
11363            let limits: car_multi::BudgetLimits =
11364                serde_json::from_value(v.clone()).map_err(|e| format!("invalid budget: {}", e))?;
11365            Ok(infra.with_budget(limits))
11366        }
11367    }
11368}
11369
11370async fn handle_multi_swarm(
11371    req: &JsonRpcMessage,
11372    session: &crate::session::ClientSession,
11373) -> Result<Value, String> {
11374    let mode_str = req
11375        .params
11376        .get("mode")
11377        .and_then(|v| v.as_str())
11378        .ok_or("missing 'mode'")?;
11379    let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11380    let task = req
11381        .params
11382        .get("task")
11383        .and_then(|v| v.as_str())
11384        .ok_or("missing 'task'")?;
11385
11386    let swarm_mode: car_multi::SwarmMode = serde_json::from_str(&format!("\"{}\"", mode_str))
11387        .map_err(|e| format!("invalid mode '{}': {}", mode_str, e))?;
11388    let agent_specs: Vec<car_multi::AgentSpec> =
11389        serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11390    let synth: Option<car_multi::AgentSpec> = req
11391        .params
11392        .get("synthesizer")
11393        .map(|v| serde_json::from_value(v.clone()))
11394        .transpose()
11395        .map_err(|e| format!("invalid synthesizer: {}", e))?;
11396
11397    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11398        channel: session.channel.clone(),
11399        host: session.host.clone(),
11400        client_id: session.client_id.clone(),
11401    });
11402    let infra = multi_infra_with_budget(req, session)?;
11403
11404    let mut swarm = car_multi::Swarm::new(agent_specs, swarm_mode);
11405    if let Some(s) = synth {
11406        swarm = swarm.with_synthesizer(s);
11407    }
11408
11409    let result = swarm
11410        .run(task, &runner, &infra)
11411        .await
11412        .map_err(|e| format!("swarm error: {}", e))?;
11413    serde_json::to_value(result).map_err(|e| e.to_string())
11414}
11415
11416async fn handle_multi_pipeline(
11417    req: &JsonRpcMessage,
11418    session: &crate::session::ClientSession,
11419) -> Result<Value, String> {
11420    let stages_val = req.params.get("stages").ok_or("missing 'stages'")?;
11421    let task = req
11422        .params
11423        .get("task")
11424        .and_then(|v| v.as_str())
11425        .ok_or("missing 'task'")?;
11426
11427    let stage_specs: Vec<car_multi::AgentSpec> =
11428        serde_json::from_value(stages_val.clone()).map_err(|e| format!("invalid stages: {}", e))?;
11429
11430    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11431        channel: session.channel.clone(),
11432        host: session.host.clone(),
11433        client_id: session.client_id.clone(),
11434    });
11435    let infra = multi_infra_with_budget(req, session)?;
11436
11437    let result = car_multi::Pipeline::new(stage_specs)
11438        .run(task, &runner, &infra)
11439        .await
11440        .map_err(|e| format!("pipeline error: {}", e))?;
11441    serde_json::to_value(result).map_err(|e| e.to_string())
11442}
11443
11444async fn handle_multi_supervisor(
11445    req: &JsonRpcMessage,
11446    session: &crate::session::ClientSession,
11447) -> Result<Value, String> {
11448    let workers_val = req.params.get("workers").ok_or("missing 'workers'")?;
11449    let supervisor_val = req.params.get("supervisor").ok_or("missing 'supervisor'")?;
11450    let task = req
11451        .params
11452        .get("task")
11453        .and_then(|v| v.as_str())
11454        .ok_or("missing 'task'")?;
11455    let max_rounds = req
11456        .params
11457        .get("max_rounds")
11458        .and_then(|v| v.as_u64())
11459        .unwrap_or(3) as u32;
11460
11461    let worker_specs: Vec<car_multi::AgentSpec> = serde_json::from_value(workers_val.clone())
11462        .map_err(|e| format!("invalid workers: {}", e))?;
11463    let supervisor_spec: car_multi::AgentSpec = serde_json::from_value(supervisor_val.clone())
11464        .map_err(|e| format!("invalid supervisor: {}", e))?;
11465
11466    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11467        channel: session.channel.clone(),
11468        host: session.host.clone(),
11469        client_id: session.client_id.clone(),
11470    });
11471    let infra = multi_infra_with_budget(req, session)?;
11472
11473    let result = car_multi::Supervisor::new(worker_specs, supervisor_spec)
11474        .with_max_rounds(max_rounds)
11475        .run(task, &runner, &infra)
11476        .await
11477        .map_err(|e| format!("supervisor error: {}", e))?;
11478    serde_json::to_value(result).map_err(|e| e.to_string())
11479}
11480
11481async fn handle_multi_map_reduce(
11482    req: &JsonRpcMessage,
11483    session: &crate::session::ClientSession,
11484) -> Result<Value, String> {
11485    let mapper_val = req.params.get("mapper").ok_or("missing 'mapper'")?;
11486    let reducer_val = req.params.get("reducer").ok_or("missing 'reducer'")?;
11487    let task = req
11488        .params
11489        .get("task")
11490        .and_then(|v| v.as_str())
11491        .ok_or("missing 'task'")?;
11492    let items_val = req.params.get("items").ok_or("missing 'items'")?;
11493
11494    let mapper_spec: car_multi::AgentSpec =
11495        serde_json::from_value(mapper_val.clone()).map_err(|e| format!("invalid mapper: {}", e))?;
11496    let reducer_spec: car_multi::AgentSpec = serde_json::from_value(reducer_val.clone())
11497        .map_err(|e| format!("invalid reducer: {}", e))?;
11498    let items: Vec<String> =
11499        serde_json::from_value(items_val.clone()).map_err(|e| format!("invalid items: {}", e))?;
11500
11501    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11502        channel: session.channel.clone(),
11503        host: session.host.clone(),
11504        client_id: session.client_id.clone(),
11505    });
11506    let infra = multi_infra_with_budget(req, session)?;
11507
11508    let result = car_multi::MapReduce::new(mapper_spec, reducer_spec)
11509        .run(task, &items, &runner, &infra)
11510        .await
11511        .map_err(|e| format!("map_reduce error: {}", e))?;
11512    serde_json::to_value(result).map_err(|e| e.to_string())
11513}
11514
11515async fn handle_multi_vote(
11516    req: &JsonRpcMessage,
11517    session: &crate::session::ClientSession,
11518) -> Result<Value, String> {
11519    let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11520    let task = req
11521        .params
11522        .get("task")
11523        .and_then(|v| v.as_str())
11524        .ok_or("missing 'task'")?;
11525
11526    let agent_specs: Vec<car_multi::AgentSpec> =
11527        serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11528    let synth: Option<car_multi::AgentSpec> = req
11529        .params
11530        .get("synthesizer")
11531        .map(|v| serde_json::from_value(v.clone()))
11532        .transpose()
11533        .map_err(|e| format!("invalid synthesizer: {}", e))?;
11534
11535    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11536        channel: session.channel.clone(),
11537        host: session.host.clone(),
11538        client_id: session.client_id.clone(),
11539    });
11540    let infra = multi_infra_with_budget(req, session)?;
11541
11542    let mut vote = car_multi::Vote::new(agent_specs);
11543    if let Some(s) = synth {
11544        vote = vote.with_synthesizer(s);
11545    }
11546
11547    let result = vote
11548        .run(task, &runner, &infra)
11549        .await
11550        .map_err(|e| format!("vote error: {}", e))?;
11551    serde_json::to_value(result).map_err(|e| e.to_string())
11552}
11553
11554async fn handle_multi_tournament(
11555    req: &JsonRpcMessage,
11556    session: &crate::session::ClientSession,
11557) -> Result<Value, String> {
11558    let competitors_val = req
11559        .params
11560        .get("competitors")
11561        .ok_or("missing 'competitors'")?;
11562    let judge_val = req.params.get("judge").ok_or("missing 'judge'")?;
11563    let task = req
11564        .params
11565        .get("task")
11566        .and_then(|v| v.as_str())
11567        .ok_or("missing 'task'")?;
11568
11569    let competitors: Vec<car_multi::AgentSpec> = serde_json::from_value(competitors_val.clone())
11570        .map_err(|e| format!("invalid competitors: {}", e))?;
11571    let judge: car_multi::AgentSpec =
11572        serde_json::from_value(judge_val.clone()).map_err(|e| format!("invalid judge: {}", e))?;
11573
11574    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11575        channel: session.channel.clone(),
11576        host: session.host.clone(),
11577        client_id: session.client_id.clone(),
11578    });
11579    let infra = multi_infra_with_budget(req, session)?;
11580
11581    let result = car_multi::Tournament::new(competitors, judge)
11582        .run(task, &runner, &infra)
11583        .await
11584        .map_err(|e| format!("tournament error: {}", e))?;
11585    serde_json::to_value(result).map_err(|e| e.to_string())
11586}
11587
11588async fn handle_multi_subtask(
11589    req: &JsonRpcMessage,
11590    session: &crate::session::ClientSession,
11591) -> Result<Value, String> {
11592    let main_val = req.params.get("main").ok_or("missing 'main'")?;
11593    let task = req
11594        .params
11595        .get("task")
11596        .and_then(|v| v.as_str())
11597        .ok_or("missing 'task'")?;
11598
11599    let main_spec: car_multi::AgentSpec =
11600        serde_json::from_value(main_val.clone()).map_err(|e| format!("invalid main: {}", e))?;
11601
11602    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11603        channel: session.channel.clone(),
11604        host: session.host.clone(),
11605        client_id: session.client_id.clone(),
11606    });
11607    let infra = multi_infra_with_budget(req, session)?;
11608
11609    let result = car_multi::SpawnSubtask::new(main_spec)
11610        .run(task, &runner, &infra)
11611        .await
11612        .map_err(|e| format!("spawn_subtask error: {}", e))?;
11613    serde_json::to_value(result).map_err(|e| e.to_string())
11614}
11615
11616// ---------------------------------------------------------------------------
11617// Scheduler handlers
11618// ---------------------------------------------------------------------------
11619
11620fn handle_scheduler_create(req: &JsonRpcMessage) -> Result<Value, String> {
11621    let name = req
11622        .params
11623        .get("name")
11624        .and_then(|v| v.as_str())
11625        .ok_or("scheduler.create requires 'name'")?;
11626    let prompt = req
11627        .params
11628        .get("prompt")
11629        .and_then(|v| v.as_str())
11630        .ok_or("scheduler.create requires 'prompt'")?;
11631
11632    let mut task = car_scheduler::Task::new(name, prompt);
11633
11634    if let Some(t) = opt_str(&req.params, "trigger") {
11635        let trigger = match t {
11636            "once" => car_scheduler::TaskTrigger::Once,
11637            "cron" => car_scheduler::TaskTrigger::Cron,
11638            "interval" => car_scheduler::TaskTrigger::Interval,
11639            "file_watch" => car_scheduler::TaskTrigger::FileWatch,
11640            _ => car_scheduler::TaskTrigger::Manual,
11641        };
11642        let schedule = req
11643            .params
11644            .get("schedule")
11645            .and_then(|v| v.as_str())
11646            .unwrap_or("");
11647        task = task.with_trigger(trigger, schedule);
11648    }
11649
11650    if let Some(sp) = opt_str(&req.params, "system_prompt") {
11651        task = task.with_system_prompt(sp);
11652    }
11653
11654    serde_json::to_value(&task).map_err(|e| e.to_string())
11655}
11656
11657/// Common param extraction for the OS-schedule handlers: `{ task, program, args? }`.
11658fn os_schedule_params(req: &JsonRpcMessage) -> Result<(String, String, String), String> {
11659    let task = req
11660        .params
11661        .get("task")
11662        .ok_or("requires 'task'")
11663        .and_then(|v| serde_json::to_string(v).map_err(|_| "invalid 'task'"))?;
11664    let program = req
11665        .params
11666        .get("program")
11667        .and_then(|v| v.as_str())
11668        .ok_or("requires 'program' (the binary the OS runs to execute the task)")?
11669        .to_string();
11670    let args = req
11671        .params
11672        .get("args")
11673        .cloned()
11674        .unwrap_or_else(|| Value::Array(vec![]));
11675    let args_json = serde_json::to_string(&args).map_err(|e| e.to_string())?;
11676    Ok((task, program, args_json))
11677}
11678
11679/// Preview the durable OS-level schedule a task would install (no I/O).
11680fn handle_scheduler_os_render(req: &JsonRpcMessage) -> Result<Value, String> {
11681    let (task, program, args) = os_schedule_params(req)?;
11682    let json = car_ffi_common::scheduler::render_os_schedule(&task, &program, &args)?;
11683    serde_json::from_str(&json).map_err(|e| e.to_string())
11684}
11685
11686/// Install a durable OS-level schedule (launchd/cron) for a task so it fires
11687/// even when the daemon is down.
11688fn handle_scheduler_os_install(req: &JsonRpcMessage) -> Result<Value, String> {
11689    let (task, program, args) = os_schedule_params(req)?;
11690    let json = car_ffi_common::scheduler::install_os_schedule(&task, &program, &args)?;
11691    serde_json::from_str(&json).map_err(|e| e.to_string())
11692}
11693
11694/// `tasks.schedule` — schedule a deterministic command on a cadence, hiding the
11695/// OS backend (#72). Params: `{ name, program, args?, cadence: { interval_secs? |
11696/// cron? }, durable?, working_dir?, env?, timeout_secs? }`.
11697///
11698/// **Host-gated**: scheduling a command is persistent, unsandboxed code
11699/// execution, so it requires host-management authority (a no-op in tokenless
11700/// dev/embedder mode). A registered agent connection can't self-schedule a
11701/// command — same trust root as `permission.set_tier` / `messaging.config.*`.
11702fn handle_tasks_schedule(
11703    req: &JsonRpcMessage,
11704    session: &crate::session::ClientSession,
11705    state: &ServerState,
11706) -> Result<Value, String> {
11707    require_approval_authority(session, state)?;
11708    let spec = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
11709    let json = car_ffi_common::scheduler::schedule_task(&spec)?;
11710    serde_json::from_str(&json).map_err(|e| e.to_string())
11711}
11712
11713/// `tasks.list` — list deterministic (command) scheduled tasks + their backend.
11714fn handle_tasks_list(_req: &JsonRpcMessage) -> Result<Value, String> {
11715    let json = car_ffi_common::scheduler::list_scheduled_tasks()?;
11716    serde_json::from_str(&json).map_err(|e| e.to_string())
11717}
11718
11719/// `tasks.unschedule` — remove a task's OS schedule + delete it from the store.
11720/// Params: `{ id }` (bare id or full label). Host-gated (same authority as
11721/// `tasks.schedule`).
11722fn handle_tasks_unschedule(
11723    req: &JsonRpcMessage,
11724    session: &crate::session::ClientSession,
11725    state: &ServerState,
11726) -> Result<Value, String> {
11727    require_approval_authority(session, state)?;
11728    let id = req
11729        .params
11730        .get("id")
11731        .and_then(|v| v.as_str())
11732        .ok_or("tasks.unschedule requires 'id'")?;
11733    let json = car_ffi_common::scheduler::unschedule_task(id)?;
11734    serde_json::from_str(&json).map_err(|e| e.to_string())
11735}
11736
11737/// Remove a task's OS-level schedule. Params: `{ label }` (full label or bare id).
11738fn handle_scheduler_os_uninstall(req: &JsonRpcMessage) -> Result<Value, String> {
11739    let label = req
11740        .params
11741        .get("label")
11742        .and_then(|v| v.as_str())
11743        .ok_or("scheduler.os_uninstall requires 'label' (full label or task id)")?;
11744    let json = car_ffi_common::scheduler::uninstall_os_schedule(label)?;
11745    serde_json::from_str(&json).map_err(|e| e.to_string())
11746}
11747
11748/// List CAR-managed OS-level schedule labels installed on this host.
11749fn handle_scheduler_os_list(_req: &JsonRpcMessage) -> Result<Value, String> {
11750    let json = car_ffi_common::scheduler::list_os_schedules()?;
11751    serde_json::from_str(&json).map_err(|e| e.to_string())
11752}
11753
11754/// Reap orphaned OS-level schedules whose backing task was deleted or made
11755/// non-schedulable. Returns `{ removed, kept, errors }`.
11756fn handle_scheduler_os_reconcile(_req: &JsonRpcMessage) -> Result<Value, String> {
11757    let json = car_ffi_common::scheduler::reconcile_os_schedules()?;
11758    serde_json::from_str(&json).map_err(|e| e.to_string())
11759}
11760
11761/// Reap orphaned OS-level schedules at daemon boot — best-effort, mirrors
11762/// [`recover_workflow_checkpoints`]. A schedule whose task was deleted while the
11763/// daemon was down keeps firing a no-op command until this runs.
11764pub fn reconcile_os_schedules_at_boot() {
11765    match car_ffi_common::scheduler::reconcile_os_schedules() {
11766        Ok(json) => match serde_json::from_str::<Value>(&json) {
11767            Ok(report) => {
11768                let removed = report
11769                    .get("removed")
11770                    .and_then(Value::as_array)
11771                    .map_or(0, Vec::len);
11772                let errors = report
11773                    .get("errors")
11774                    .and_then(Value::as_array)
11775                    .map_or(0, Vec::len);
11776                if removed > 0 || errors > 0 {
11777                    tracing::info!(removed, errors, "reaped orphaned OS schedules at boot");
11778                }
11779            }
11780            Err(e) => {
11781                tracing::warn!(error = %e, "OS-schedule reconcile returned unparseable report")
11782            }
11783        },
11784        Err(e) => tracing::warn!(error = %e, "OS-schedule reconcile failed at boot"),
11785    }
11786}
11787
11788/// Reap CAR Docker sandbox containers orphaned by a prior daemon crash/SIGKILL
11789/// (Parslee-ai/car#479). `SandboxExecutor`'s `Drop` handles normal teardown, but
11790/// a hard kill leaves the `sleep infinity` container running forever. Sandboxes
11791/// are created per `session.bindSandbox` (never at boot), so any container
11792/// carrying the CAR ownership label is by definition orphaned. Best-effort:
11793/// a host without Docker is a silent no-op.
11794pub async fn reap_orphaned_sandboxes_at_boot() {
11795    let reaped = car_sandbox::reap_orphaned_sandboxes().await;
11796    if reaped > 0 {
11797        tracing::info!(reaped, "reaped orphaned CAR sandbox containers at boot");
11798    }
11799}
11800
11801/// Seed a client-supplied task with any prior execution history persisted under
11802/// the given TaskStore. The WS `scheduler.run` / `scheduler.run_loop` surface is
11803/// otherwise stateless — a client deserializes a `Task` and posts it each call,
11804/// so `task.executions` arrives empty and the deterministic occurrence guard in
11805/// [`car_scheduler::Executor::run_occurrence`] never sees the prior run. Loading
11806/// the stored task by id and copying its `executions` (plus the run bookkeeping
11807/// the guard reads alongside them) restores statefulness across the JSON-RPC
11808/// boundary, so a replayed Interval/Once/Cron occurrence dedups. Manual triggers
11809/// carry no occurrence id and are intentionally never deduped — seeding is a
11810/// no-op for them.
11811fn seed_task_from_store(task: &mut car_scheduler::Task, store: &car_scheduler::TaskStore) {
11812    if let Some(prior) = store.load(&task.id) {
11813        // Anchor the occurrence slot on the persisted creation time and copy the
11814        // prior execution history so the admission guard can recognize a replay.
11815        task.created_at = prior.created_at;
11816        task.executions = prior.executions;
11817        task.run_count = prior.run_count;
11818        task.last_run_at = prior.last_run_at;
11819    }
11820}
11821
11822/// Run a single task occurrence statefully against a TaskStore, persisting the
11823/// mutated task so the next call sees this run. Shared by the WS handler and the
11824/// dedup tests.
11825async fn run_scheduler_task_once(
11826    task: &mut car_scheduler::Task,
11827    runner: Arc<dyn car_multi::AgentRunner>,
11828    store: &car_scheduler::TaskStore,
11829) -> car_scheduler::TaskExecution {
11830    seed_task_from_store(task, store);
11831    let executor = car_scheduler::Executor::new(runner);
11832    let execution = executor.run_once(task).await;
11833    let _ = store.save(task);
11834    execution
11835}
11836
11837/// Run a task loop statefully against a TaskStore, persisting the mutated task
11838/// afterward. Shared by the WS handler and the dedup tests.
11839async fn run_scheduler_task_loop(
11840    task: &mut car_scheduler::Task,
11841    max_iterations: Option<u32>,
11842    runner: Arc<dyn car_multi::AgentRunner>,
11843    store: &car_scheduler::TaskStore,
11844) -> Vec<car_scheduler::TaskExecution> {
11845    seed_task_from_store(task, store);
11846    let executor = car_scheduler::Executor::new(runner);
11847    let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
11848    let executions = executor.run_loop(task, max_iterations, cancel_rx).await;
11849    let _ = store.save(task);
11850    executions
11851}
11852
11853async fn handle_scheduler_run(
11854    req: &JsonRpcMessage,
11855    session: &crate::session::ClientSession,
11856) -> Result<Value, String> {
11857    let task_val = req
11858        .params
11859        .get("task")
11860        .ok_or("scheduler.run requires 'task'")?;
11861    let mut task: car_scheduler::Task =
11862        serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
11863
11864    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11865        channel: session.channel.clone(),
11866        host: session.host.clone(),
11867        client_id: session.client_id.clone(),
11868    });
11869    let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
11870    let execution = run_scheduler_task_once(&mut task, runner, &store).await;
11871
11872    serde_json::to_value(&execution).map_err(|e| e.to_string())
11873}
11874
11875async fn handle_scheduler_run_loop(
11876    req: &JsonRpcMessage,
11877    session: &crate::session::ClientSession,
11878) -> Result<Value, String> {
11879    let task_val = req
11880        .params
11881        .get("task")
11882        .ok_or("scheduler.run_loop requires 'task'")?;
11883    let mut task: car_scheduler::Task =
11884        serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
11885    let max_iterations = req
11886        .params
11887        .get("max_iterations")
11888        .and_then(|v| v.as_u64())
11889        .map(|v| v as u32);
11890
11891    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11892        channel: session.channel.clone(),
11893        host: session.host.clone(),
11894        client_id: session.client_id.clone(),
11895    });
11896    let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
11897    let executions = run_scheduler_task_loop(&mut task, max_iterations, runner, &store).await;
11898
11899    serde_json::to_value(&executions).map_err(|e| e.to_string())
11900}
11901
11902// ---------------------------------------------------------------------------
11903// Inference handlers
11904// ---------------------------------------------------------------------------
11905
11906pub(crate) fn get_inference_engine(state: &ServerState) -> &Arc<car_inference::InferenceEngine> {
11907    state.inference.get_or_init(|| {
11908        let engine = Arc::new(car_inference::InferenceEngine::new(
11909            car_inference::InferenceConfig::default(),
11910        ));
11911        // Isolate on-device (MLX/Candle) generation in a worker subprocess so a
11912        // native Metal/MLX abort on a heavy local inference can't take down the
11913        // shared daemon (car-releases#74). The engine's on-device branch routes
11914        // through this offloader when installed; a worker crash fails one RPC
11915        // and respawns rather than killing the daemon. Skipped inside a worker
11916        // process (it runs generation for real) and opt-out via
11917        // CAR_NO_INFERENCE_WORKER=1 for debugging the in-process path.
11918        let worker_disabled = std::env::var_os("CAR_NO_INFERENCE_WORKER").is_some();
11919        if !car_inference::is_offload_worker() && !worker_disabled {
11920            match crate::inference_worker::WorkerOffload::new() {
11921                Ok(offload) => {
11922                    car_inference::set_local_offload(Some(std::sync::Arc::new(offload)));
11923                    info!("on-device inference isolated in a worker subprocess (car-releases#74)");
11924                }
11925                Err(e) => tracing::warn!(
11926                    error = %e,
11927                    "could not install the inference worker offloader; on-device \
11928                     generation will run in-process (a native MLX abort could crash the daemon)"
11929                ),
11930            }
11931        }
11932        // Phase E2: keep the model catalog current automatically. OPT-IN via
11933        // `CAR_MODEL_DISCOVERY=1` (off by default — it makes an outbound
11934        // provider call to `/v1/models` on a timer, which not every operator
11935        // wants). When enabled, run provider model discovery on first engine
11936        // init then re-run daily, caching newly-released chat/reasoning models
11937        // (e.g. a new gpt-5.x) as Community entries that load at the next
11938        // daemon start — so the router picks them up without a release.
11939        // Best-effort: no key / no provider is a no-op.
11940        let discovery_enabled = std::env::var("CAR_MODEL_DISCOVERY")
11941            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
11942            .unwrap_or(false);
11943        if discovery_enabled {
11944            // `get_or_init` can run outside a tokio runtime (a sync or
11945            // non-async first caller); `tokio::spawn` panics there. Only spawn
11946            // when a runtime is actually present, otherwise log and skip.
11947            match tokio::runtime::Handle::try_current() {
11948                Ok(handle) => {
11949                    let disco = Arc::clone(&engine);
11950                    handle.spawn(async move {
11951                        loop {
11952                            match disco.discover_models().await {
11953                                Ok(n) => info!("model discovery: {n} discovered models cached"),
11954                                Err(e) => tracing::debug!("model discovery skipped: {e}"),
11955                            }
11956                            tokio::time::sleep(std::time::Duration::from_secs(24 * 3600)).await;
11957                        }
11958                    });
11959                }
11960                Err(_) => tracing::debug!(
11961                    "model discovery enabled (CAR_MODEL_DISCOVERY) but no tokio runtime at \
11962                     engine init — skipping the background task"
11963                ),
11964            }
11965        }
11966        engine
11967    })
11968}
11969
11970/// `foreman.plan` — decompose a coding `goal` into a footprint-annotated,
11971/// scheduled subtask plan. The planner's repair loop runs against the daemon's
11972/// inference engine. Returns a [`car_multi::PlanReport`] (the SCHEDULER surface;
11973/// the gate verdict is a separate surface produced by `foreman.run`).
11974async fn handle_foreman_plan(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
11975    let goal = msg
11976        .params
11977        .get("goal")
11978        .and_then(|v| v.as_str())
11979        .ok_or("missing 'goal'")?
11980        .to_string();
11981    let repo = msg
11982        .params
11983        .get("repo")
11984        .and_then(|v| v.as_str())
11985        .map(std::path::PathBuf::from)
11986        .or_else(|| std::env::current_dir().ok())
11987        .ok_or("no 'repo' and cwd is unavailable")?;
11988    let max_attempts = msg
11989        .params
11990        .get("max_attempts")
11991        .and_then(|v| v.as_u64())
11992        .unwrap_or(3) as u32;
11993
11994    let engine = std::sync::Arc::clone(get_inference_engine(state));
11995    let result = car_multi::decompose(&repo, &goal, max_attempts, move |prompt| {
11996        let engine = std::sync::Arc::clone(&engine);
11997        async move {
11998            engine
11999                .generate(car_inference::GenerateRequest {
12000                    prompt,
12001                    ..Default::default()
12002                })
12003                .await
12004                .map_err(|e| e.to_string())
12005        }
12006    })
12007    .await;
12008
12009    serde_json::to_value(car_multi::PlanReport::from(&result))
12010        .map_err(|e| format!("serialize plan: {e}"))
12011}
12012
12013/// `foreman.run` — plan a coding `goal`, farm the independent subtasks to an
12014/// external coding CLI in isolated git worktrees, gate each worktree and the
12015/// integrated union. Returns `{ plan, ran, run? }`. **Spends real agent quota.**
12016async fn handle_foreman_run(
12017    msg: &JsonRpcMessage,
12018    state: &ServerState,
12019    session: &crate::session::ClientSession,
12020) -> Result<Value, String> {
12021    let goal = msg
12022        .params
12023        .get("goal")
12024        .and_then(|v| v.as_str())
12025        .ok_or("missing 'goal'")?
12026        .to_string();
12027    let repo = msg
12028        .params
12029        .get("repo")
12030        .and_then(|v| v.as_str())
12031        .map(std::path::PathBuf::from)
12032        .or_else(|| std::env::current_dir().ok())
12033        .ok_or("no 'repo' and cwd is unavailable")?;
12034    let max_attempts = msg
12035        .params
12036        .get("max_attempts")
12037        .and_then(|v| v.as_u64())
12038        .unwrap_or(3) as u32;
12039    let adapter = msg
12040        .params
12041        .get("adapter")
12042        .and_then(|v| v.as_str())
12043        .unwrap_or("claude-code")
12044        .to_string();
12045    let str_array = |key: &str| -> Option<Vec<String>> {
12046        msg.params.get(key).and_then(|v| v.as_array()).map(|a| {
12047            a.iter()
12048                .filter_map(|x| x.as_str().map(String::from))
12049                .collect()
12050        })
12051    };
12052    // Per-worktree regression gate, and the integrated-union goal gate. A subtask
12053    // does only part of the goal, so a goal-level test belongs on the union, not
12054    // per-worktree; `union_verify_command` falls back to `verify_command`.
12055    let verify_command = str_array("verify_command");
12056    let union_verify_command = str_array("union_verify_command");
12057
12058    // Multiplayer (car#1117): spread the subtasks over every CAR instance that
12059    // can serve this repository, instead of running them all here. Opt-in,
12060    // because it spends other machines' quota and needs their operators to have
12061    // enrolled them. `workers` narrows placement to named instances.
12062    let distributed = msg
12063        .params
12064        .get("distributed")
12065        .and_then(|v| v.as_bool())
12066        .unwrap_or(false);
12067    let worker_filter = str_array("workers");
12068
12069    // Run the full pipeline: decompose, then farm out + verify the union, OR —
12070    // when the plan doesn't decompose (invalid, or no parallelism worth it) —
12071    // fall back to a single whole-goal session. Always produces a result.
12072    let engine = std::sync::Arc::clone(get_inference_engine(state));
12073    let run_id = uuid::Uuid::new_v4().to_string();
12074    let agent = car_external_agents::ForemanExternalAgent::new(adapter.clone());
12075    // The pool always contains this host too, so a distributed run whose peers
12076    // all decline still completes locally.
12077    let (pool, pool_plan) = if distributed {
12078        let (pool, plan) =
12079            crate::fleet::build_pool(state, &repo, &run_id, &adapter, worker_filter.as_deref())
12080                .await?;
12081        (Some(pool), Some(plan))
12082    } else {
12083        (None, None)
12084    };
12085    let worktree_agent: &dyn car_multi::WorktreeAgent = match &pool {
12086        Some(pool) => pool,
12087        None => &agent,
12088    };
12089    // Reuse the session's runtime policies + event log so the merge-verify gate
12090    // consults the operator's `policy.register`'d rules (it can deny a merge) and
12091    // its GateAccepted/GateRejected events are audited in the session log —
12092    // instead of a fresh, empty engine.
12093    let infra = car_multi::SharedInfra::with_shared(
12094        std::sync::Arc::clone(&session.runtime.state),
12095        std::sync::Arc::clone(&session.runtime.log),
12096        std::sync::Arc::clone(&session.runtime.policies),
12097    );
12098    let config = car_multi::FarmOutConfig {
12099        verify_command,
12100        union_verify_command,
12101        // The daemon entrypoint is delivery-first: recover via a single session
12102        // if the parallel union fails, rather than hand back a non-delivery.
12103        recover_via_single_session: true,
12104        ..Default::default()
12105    };
12106    let outcome = car_multi::run_foreman(
12107        &repo,
12108        &goal,
12109        max_attempts,
12110        worktree_agent,
12111        &config,
12112        &infra,
12113        move |prompt| {
12114            let engine = std::sync::Arc::clone(&engine);
12115            async move {
12116                engine
12117                    .generate(car_inference::GenerateRequest {
12118                        prompt,
12119                        ..Default::default()
12120                    })
12121                    .await
12122                    .map_err(|e| e.to_string())
12123            }
12124        },
12125    )
12126    .await;
12127
12128    let mode = match outcome.mode {
12129        car_multi::RunMode::Parallel => "parallel",
12130        car_multi::RunMode::SingleSession => "single_session",
12131        car_multi::RunMode::ParallelThenSingleSession => "parallel_then_single_session",
12132        car_multi::RunMode::RegionalReplan => "regional_replan",
12133    };
12134    let report =
12135        car_multi::ForemanReport::from_run(&outcome.outcomes, outcome.integration.as_ref());
12136    Ok(serde_json::json!({
12137        "plan": car_multi::PlanReport::from(&outcome.plan),
12138        "mode": mode,
12139        "ran": true,
12140        "delivered": outcome.delivered(),
12141        "run": report,
12142        "run_id": run_id,
12143        "distributed": distributed,
12144        // Which instances were in the pool, and where each subtask actually
12145        // ran (including workers that dropped one before another picked it up).
12146        // Absent on a local run, where the answer is always "here".
12147        "workers": pool.as_ref().map(|p| p.worker_ids()),
12148        "placements": pool.as_ref().map(crate::fleet::placements_json),
12149        // What the pool left out, and — when it left out everything — the one
12150        // line saying this "distributed" run was in fact local. Without it a
12151        // degraded run is indistinguishable from a slow one.
12152        "pool": pool_plan.as_ref().map(|plan| serde_json::json!({
12153            "remote_workers": plan.remote_workers,
12154            "excluded": plan.excluded.iter().map(|(instance, reason)| serde_json::json!({
12155                "instance": instance,
12156                "reason": reason,
12157            })).collect::<Vec<_>>(),
12158            // Workers the run LOST, as opposed to the ones it never had. A peer
12159            // that dies mid-run is excluded for the remainder (car#1323), and
12160            // without naming it here that shows up only as a slower run. Every
12161            // other key in this object describes the pool as BUILT, so a reader
12162            // asking whether the run was effectively local subtracts this from
12163            // `remote_workers` — `local_only` and `degraded_reason` are computed
12164            // before any subtask ran and cannot see it.
12165            "quarantined": pool.as_ref().map(|p| p.quarantined()).unwrap_or_default(),
12166            "local_only": plan.local_only(),
12167            "degraded_reason": plan.degraded_reason(),
12168        })),
12169    }))
12170}
12171
12172/// Decrements a session's in-flight chat-infer counter on drop, so the
12173/// conversation-outcome concurrency guard (neo #1) is released on every return
12174/// path — including an early error from generation.
12175struct ChatInflightGuard<'a>(&'a std::sync::atomic::AtomicUsize);
12176impl Drop for ChatInflightGuard<'_> {
12177    fn drop(&mut self) {
12178        self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
12179    }
12180}
12181
12182/// Capture the conversation-outcome inputs for a (possibly) chat infer, shared
12183/// by the streaming and non-streaming `infer` paths so the gate + idle-guard
12184/// rules can't drift apart. Must be called BEFORE `req` is consumed by
12185/// generation. Returns `(chat_user_text, was_idle, guard)`:
12186/// - `chat_user_text` is `Some` only for an EXPLICITLY-declared chat turn
12187///   (`intent.task == Chat`) — "no tools" means "not an agent loop", not "is a
12188///   conversation", so a one-off summarize/classify infer never feeds the
12189///   signal (neo #2).
12190/// - `was_idle` is true only when this turn BEGAN while the session was idle;
12191///   recording a turn against a concurrent infer's user text would fabricate
12192///   adjacency and mislabel routing stats (neo #1).
12193/// - `guard` decrements the in-flight counter on drop; the caller must hold it
12194///   for the whole generation.
12195fn begin_chat_turn<'a>(
12196    req: &car_inference::GenerateRequest,
12197    session: &'a crate::session::ClientSession,
12198) -> (Option<String>, bool, Option<ChatInflightGuard<'a>>) {
12199    let is_chat = req
12200        .intent
12201        .as_ref()
12202        .is_some_and(|i| i.task == Some(car_inference::TaskHint::Chat));
12203    if !is_chat {
12204        return (None, false, None);
12205    }
12206    let chat_user_text = req
12207        .messages
12208        .as_ref()
12209        .and_then(|ms| {
12210            ms.iter().rev().find_map(|m| match m {
12211                car_inference::Message::User { content } => Some(content.clone()),
12212                _ => None,
12213            })
12214        })
12215        .or_else(|| {
12216            let p = req.prompt.trim();
12217            (!p.is_empty()).then(|| p.to_string())
12218        });
12219    let was_idle = session
12220        .chat_inflight
12221        .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
12222        == 0;
12223    (
12224        chat_user_text,
12225        was_idle,
12226        Some(ChatInflightGuard(&session.chat_inflight)),
12227    )
12228}
12229
12230/// Score the session's prior chat turn (`slot`) against `new_user_text` and feed
12231/// the tracker, then overwrite `slot` with this turn. Pure over the borrowed
12232/// state (no locks/I/O of its own) so it's unit-testable against a real
12233/// `OutcomeTracker`. trace_id/model are carried from the `InferenceResult` that
12234/// produced each turn — credit lands on the exact model that generated the turn.
12235fn score_and_remember_chat_turn(
12236    slot: &mut Option<crate::session::LastChatTurn>,
12237    tracker: &mut car_inference::OutcomeTracker,
12238    new_user_text: String,
12239    result_text: &str,
12240    trace_id: &str,
12241    model_id: &str,
12242) -> car_memgine::outcome_bridge::CreditTally {
12243    use car_memgine::outcome_signal::ConversationTurn;
12244    let mut tally = car_memgine::outcome_bridge::CreditTally::default();
12245    if let Some(prev) = slot.as_ref() {
12246        let turns = vec![
12247            ConversationTurn::user(prev.user_text.clone(), "u_prev"),
12248            ConversationTurn::assistant(
12249                prev.assistant_text.clone(),
12250                "a_prev",
12251                Some(prev.model_id.clone()),
12252                Some(prev.trace_id.clone()),
12253            ),
12254            ConversationTurn::user(new_user_text.clone(), "u_new"),
12255        ];
12256        tally = car_memgine::outcome_bridge::record_conversation_outcomes(tracker, &turns);
12257    }
12258    *slot = Some(crate::session::LastChatTurn {
12259        user_text: new_user_text,
12260        assistant_text: result_text.to_string(),
12261        trace_id: trace_id.to_string(),
12262        model_id: model_id.to_string(),
12263    });
12264    tally
12265}
12266
12267#[cfg(test)]
12268mod chat_outcome_tests {
12269    use super::score_and_remember_chat_turn;
12270    use crate::session::LastChatTurn;
12271    use car_inference::{InferenceTask, OutcomeTracker};
12272
12273    #[test]
12274    fn first_turn_records_nothing_and_stashes() {
12275        let mut slot: Option<LastChatTurn> = None;
12276        let mut tracker = OutcomeTracker::new();
12277        let tally = score_and_remember_chat_turn(
12278            &mut slot,
12279            &mut tracker,
12280            "hello".into(),
12281            "hi there",
12282            "t1",
12283            "m1",
12284        );
12285        assert_eq!(tally.submitted, 0, "no prior turn → nothing to score");
12286        let stashed = slot.expect("this turn must be remembered");
12287        assert_eq!(stashed.trace_id, "t1");
12288        assert_eq!(stashed.user_text, "hello");
12289    }
12290
12291    #[test]
12292    fn second_turn_scores_prior_turn_and_reverses_mechanical_success() {
12293        let mut tracker = OutcomeTracker::new();
12294        // Prior assistant turn: trace t1 / model m1, completed with output →
12295        // mechanical success booked.
12296        let tid = tracker.record_start("m1", InferenceTask::Generate, "test");
12297        tracker.record_complete(&tid, 10, 1, 2);
12298        assert_eq!(tracker.profile("m1").unwrap().success_count, 1);
12299
12300        let mut slot = Some(LastChatTurn {
12301            user_text: "convert this to async".into(),
12302            assistant_text: "here's a threaded version".into(),
12303            trace_id: tid.clone(),
12304            model_id: "m1".into(),
12305        });
12306        // The next user turn circles (repair marker) → the prior turn is a failure.
12307        let tally = score_and_remember_chat_turn(
12308            &mut slot,
12309            &mut tracker,
12310            "no, that's not what i asked".into(),
12311            "async version",
12312            "t2",
12313            "m1",
12314        );
12315        assert_eq!(
12316            tally.resolved, 1,
12317            "prior turn's trace was pending → resolved"
12318        );
12319        let p = tracker.profile("m1").unwrap();
12320        assert_eq!(p.success_count, 0, "mechanical success reversed");
12321        assert_eq!(p.fail_count, 1, "circle booked as a failure for m1");
12322        assert_eq!(slot.unwrap().trace_id, "t2", "this turn is now remembered");
12323    }
12324}
12325
12326/// Raw infer RPCs treat a named model as a hard pin.  The inference layer can
12327/// otherwise append an on-device last-resort model after a remote failure,
12328/// which is appropriate only for adaptive (model-less) routing.
12329fn parse_inference_request(params: &Value) -> Result<car_inference::GenerateRequest, String> {
12330    let exact_model_id = match params.get("model_id") {
12331        None | Some(Value::Null) => None,
12332        Some(Value::String(model_id)) if !model_id.trim().is_empty() => Some(model_id.clone()),
12333        Some(_) => return Err("`model_id` must be a non-empty string".to_string()),
12334    };
12335    if exact_model_id.is_some() && params.get("model").is_some_and(|value| !value.is_null()) {
12336        return Err("`model` and `model_id` are mutually exclusive".to_string());
12337    }
12338
12339    let mut normalized = params.clone();
12340    if let Some(object) = normalized.as_object_mut() {
12341        object.remove("model_id");
12342    }
12343    let mut req: car_inference::GenerateRequest = typed_params(&normalized)?;
12344    if let Some(model_id) = exact_model_id {
12345        car_inference::pin_exact_model_id(&mut req, model_id)?;
12346    }
12347    if req.model.is_some() {
12348        req.params.strict_model = true;
12349    }
12350    Ok(req)
12351}
12352
12353#[cfg(test)]
12354mod inference_request_tests {
12355    use super::parse_inference_request;
12356    use serde_json::json;
12357
12358    #[test]
12359    fn explicit_model_is_strict() {
12360        let req = parse_inference_request(&json!({
12361            "prompt": "hello",
12362            "model": "openrouter/qwen/qwen3-coder-next",
12363        }))
12364        .expect("valid explicit-model request");
12365
12366        assert!(req.params.strict_model);
12367    }
12368
12369    #[test]
12370    fn explicit_model_overrides_requested_non_strict_mode() {
12371        let req = parse_inference_request(&json!({
12372            "prompt": "hello",
12373            "model": "openrouter/qwen/qwen3-coder-next",
12374            "params": { "strict_model": false },
12375        }))
12376        .expect("valid explicit-model request");
12377
12378        assert!(req.params.strict_model);
12379    }
12380
12381    #[test]
12382    fn exact_model_id_is_strict_and_does_not_alias() {
12383        let req = parse_inference_request(&json!({
12384            "prompt": "hello",
12385            "model_id": "openai/gpt-5.4:latest",
12386            "params": { "strict_model": false },
12387        }))
12388        .expect("valid exact-id request");
12389
12390        assert_eq!(
12391            car_inference::exact_pinned_model_id(&req),
12392            Some("openai/gpt-5.4:latest")
12393        );
12394        assert!(req.params.strict_model);
12395    }
12396
12397    #[test]
12398    fn legacy_model_and_exact_model_id_are_rejected_together() {
12399        let error = parse_inference_request(&json!({
12400            "prompt": "hello",
12401            "model": "GPT 5.4",
12402            "model_id": "openai/gpt-5.4:latest",
12403        }))
12404        .expect_err("ambiguous dual pin must fail");
12405
12406        assert!(error.contains("mutually exclusive"));
12407    }
12408
12409    #[test]
12410    fn model_less_requests_preserve_requested_strict_mode() {
12411        let loose = parse_inference_request(&json!({
12412            "prompt": "hello",
12413            "params": { "strict_model": false },
12414        }))
12415        .expect("valid adaptive request");
12416        let strict = parse_inference_request(&json!({
12417            "prompt": "hello",
12418            "params": { "strict_model": true },
12419        }))
12420        .expect("valid strict adaptive request");
12421
12422        assert!(!loose.params.strict_model);
12423        assert!(strict.params.strict_model);
12424    }
12425}
12426
12427/// Stringify an inference failure for the dispatcher, tagging a content refusal
12428/// with its stable wire prefix.
12429///
12430/// This is the WRITER half of the pair whose reader is
12431/// [`HandlerFailure::from_dispatch`]; both sides key off
12432/// [`car_proto::CONTENT_REFUSED_MESSAGE_PREFIX`] and must be changed together.
12433///
12434/// Classification happens HERE, by variant, because this is the last point that
12435/// still holds the typed `InferenceError` — `car-inference` already decided
12436/// whether the gateway refused on content grounds (it excludes the case from the
12437/// circuit breaker and books it as a capability rejection), and re-deriving that
12438/// verdict by substring-matching Display text downstream would be a guess.
12439/// `ContentRefused`'s Display carries the gateway's own `type=` / `code=` tags
12440/// and message, so tagging preserves them rather than replacing them.
12441fn inference_dispatch_error(e: &car_inference::InferenceError) -> String {
12442    match e {
12443        car_inference::InferenceError::ContentRefused { .. } => {
12444            format!("{} {e}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX)
12445        }
12446        car_inference::InferenceError::CatalogPreconditionMismatch { detail } => format!(
12447            "{} {detail}",
12448            car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX
12449        ),
12450        _ => e.to_string(),
12451    }
12452}
12453
12454/// Same tagging, for a stream failure that reaches us as flattened TEXT.
12455///
12456/// A refusal raised BEFORE the stream opens is a typed `InferenceError` and goes
12457/// through [`inference_dispatch_error`]. One raised mid-stream does not: the
12458/// provider layer has already turned it into `StreamEvent::Error(String)` by the
12459/// time it gets here, so the variant is gone and only the message survives.
12460///
12461/// That is not a licence to invent a substring rule. The verdict is taken from
12462/// `car_inference::stream::content_refusal_tags`, which reads the `type=` /
12463/// `code=` tags the same module's SSE writer appended — the same function
12464/// `car-inference` itself uses on the non-streaming path, so both paths agree on
12465/// what a refusal IS by construction rather than by two rules kept in step by
12466/// hand. A stream whose gateway sent no tags stays a generic failure, which is
12467/// the safe direction (Parslee-ai/car#796).
12468fn stream_dispatch_error(detail: String) -> String {
12469    match car_inference::stream::content_refusal_tags(&detail) {
12470        Some(_) => format!("{} {detail}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
12471        None => detail,
12472    }
12473}
12474
12475async fn run_owned_inference_backend<F>(
12476    registry: Arc<crate::inference_control::InferenceRegistry>,
12477    inference_id: String,
12478    result_tx: tokio::sync::oneshot::Sender<Result<Value, String>>,
12479    work: F,
12480) where
12481    F: std::future::Future<Output = Result<Value, String>>,
12482{
12483    use futures::FutureExt;
12484    use std::panic::AssertUnwindSafe;
12485
12486    let result = match AssertUnwindSafe(work).catch_unwind().await {
12487        Ok(result) => result,
12488        Err(payload) => {
12489            let detail = payload
12490                .downcast_ref::<&str>()
12491                .map(|message| (*message).to_string())
12492                .or_else(|| payload.downcast_ref::<String>().cloned())
12493                .unwrap_or_else(|| "unknown panic payload".to_string());
12494            Err(format!("inference backend panicked: {detail}"))
12495        }
12496    };
12497    registry.backend_terminated(&inference_id);
12498    let _ = result_tx.send(result);
12499}
12500
12501async fn handle_infer(
12502    msg: &JsonRpcMessage,
12503    state: Arc<ServerState>,
12504    session: Arc<crate::session::ClientSession>,
12505) -> Result<Value, String> {
12506    reject_client_inference_id(&msg.params)?;
12507    let current_run = session.current_run_id.lock().await.clone();
12508    let request_id = msg.id.as_str().map(str::to_string);
12509    let (inference_id, mut terminal) = session
12510        .inference_control
12511        .begin_for_run(current_run, request_id)
12512        .map_err(|error| format!("cannot start inference: {error:?}"))?;
12513    let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
12514    let task_msg = msg.clone();
12515    let task_state = state.clone();
12516    let task_session = session.clone();
12517    let task_registry = session.inference_control.clone();
12518    let task_id = inference_id.clone();
12519    let backend = tokio::spawn(run_owned_inference_backend(
12520        task_registry,
12521        task_id.clone(),
12522        result_tx,
12523        async move {
12524            car_inference::scope_inference_control_id(
12525                task_id.clone(),
12526                handle_infer_active(&task_msg, &task_state, &task_session, &task_id),
12527            )
12528            .await
12529        },
12530    ));
12531    let mut owner =
12532        attach_owned_inference_backend(session.inference_control.clone(), &inference_id, backend)
12533            .map_err(|error| format!("cannot own inference backend: {error:?}"))?;
12534    let exposes_control = session_has_capability(&session, car_proto::INFER_CANCEL_CAPABILITY)
12535        || session_has_capability(&session, car_proto::INFER_DEADLINE_CAPABILITY);
12536    if exposes_control {
12537        if let Err(error) = send_infer_started(&session, &msg.id, &inference_id).await {
12538            return Err(clean_up_failed_infer_started(
12539                &session.inference_control,
12540                &inference_id,
12541                error,
12542            ));
12543        }
12544    }
12545
12546    let result = tokio::select! {
12547        biased;
12548        result = &mut result_rx => match result {
12549          Ok(Ok(mut value)) => {
12550            if session.inference_control.complete(&inference_id) {
12551                if exposes_control {
12552                    insert_inference_id(&mut value, &inference_id)?;
12553                }
12554                Ok(value)
12555            } else {
12556                Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12557            }
12558          }
12559          Ok(Err(error)) => {
12560            if session.inference_control.complete(&inference_id) {
12561                Err(error)
12562            } else {
12563                Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12564            }
12565          }
12566          Err(_) => Err("owned inference backend ended without a result".to_string()),
12567        },
12568        _ = terminal.changed() => {
12569            Ok(wait_terminal_control_value(terminal, &inference_id).await)
12570        }
12571    };
12572    // The engine-side deadline entry must not outlive the inference: a reused
12573    // control id inheriting a stale deadline would cut an innocent request.
12574    car_inference::clear_remote_deadline(&inference_id);
12575    owner.complete_normally();
12576    result
12577}
12578
12579#[derive(Deserialize)]
12580#[serde(deny_unknown_fields)]
12581struct InferCancelParams {
12582    inference_id: String,
12583}
12584
12585#[derive(Deserialize)]
12586#[serde(deny_unknown_fields)]
12587struct InferDeadlineParams {
12588    inference_id: String,
12589    timeout_ms: u64,
12590}
12591
12592async fn exact_backend_termination_ack(inference_id: &str) -> bool {
12593    let Some(offload) = car_inference::current_local_offload() else {
12594        return false;
12595    };
12596    matches!(
12597        offload.terminate_inference(inference_id).await,
12598        car_inference::InferenceTerminationAck::Confirmed
12599    )
12600}
12601
12602async fn handle_infer_cancel(
12603    msg: &JsonRpcMessage,
12604    session: &crate::session::ClientSession,
12605) -> Result<Value, String> {
12606    if !session_has_capability(session, car_proto::INFER_CANCEL_CAPABILITY) {
12607        return Err(format!(
12608            "{} negotiate `{}` as a required or optional capability before calling `infer.cancel`",
12609            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12610            car_proto::INFER_CANCEL_CAPABILITY
12611        ));
12612    }
12613    let params: InferCancelParams = typed_params(&msg.params)?;
12614    if params.inference_id.is_empty() {
12615        return Err("`inference_id` must be a non-empty opaque string".into());
12616    }
12617    let _pending = session
12618        .inference_control
12619        .try_acquire_control()
12620        .map_err(|error| format!("infer.cancel control admission rejected: {error:?}"))?;
12621    let status = session
12622        .inference_control
12623        .control(
12624            &params.inference_id,
12625            crate::inference_control::ControlCause::Cancel,
12626            exact_backend_termination_ack(&params.inference_id),
12627        )
12628        .await;
12629    serde_json::to_value(car_proto::InferenceControlResponse {
12630        inference_id: params.inference_id,
12631        status,
12632    })
12633    .map_err(|error| format!("serialize infer.cancel response: {error}"))
12634}
12635
12636async fn handle_infer_deadline(
12637    msg: &JsonRpcMessage,
12638    session: &crate::session::ClientSession,
12639) -> Result<Value, String> {
12640    if !session_has_capability(session, car_proto::INFER_DEADLINE_CAPABILITY) {
12641        return Err(format!(
12642            "{} negotiate `{}` as a required or optional capability before calling `infer.deadline`",
12643            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12644            car_proto::INFER_DEADLINE_CAPABILITY
12645        ));
12646    }
12647    let params: InferDeadlineParams = typed_params(&msg.params)?;
12648    if params.inference_id.is_empty() {
12649        return Err("`inference_id` must be a non-empty opaque string".into());
12650    }
12651    if params.timeout_ms == 0
12652        || params.timeout_ms > crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12653    {
12654        return Err(format!(
12655            "`timeout_ms` must be between 1 and {}",
12656            crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12657        ));
12658    }
12659
12660    let ack_id = params.inference_id.clone();
12661    let status = match session
12662        .inference_control
12663        .schedule_deadline(
12664            &params.inference_id,
12665            std::time::Duration::from_millis(params.timeout_ms),
12666            || exact_backend_termination_ack(&ack_id),
12667        )
12668        .await
12669    {
12670        Ok(status) => {
12671            // Beyond the registry's kill-switch, arm the ENGINE-side deadline:
12672            // the remote retry loop derives its per-phase and retry budgets
12673            // from it, so the provider request survives past the unarmed
12674            // transport defaults up to the caller's bound, and a termination
12675            // names the deadline that was applied (car-eyj).
12676            car_inference::set_remote_deadline(
12677                &params.inference_id,
12678                std::time::Duration::from_millis(params.timeout_ms),
12679            );
12680            status
12681        }
12682        Err(crate::inference_control::DeadlineReservationError::Terminal(status)) => {
12683            return serde_json::to_value(car_proto::InferenceControlResponse {
12684                inference_id: params.inference_id,
12685                status,
12686            })
12687            .map_err(|error| format!("serialize infer.deadline response: {error}"));
12688        }
12689        Err(crate::inference_control::DeadlineReservationError::Registry(
12690            crate::inference_control::RegistryError::PendingControlLimitReached,
12691        )) => {
12692            return Err(
12693                "infer.deadline control admission rejected: PendingControlLimitReached".into(),
12694            );
12695        }
12696        Err(crate::inference_control::DeadlineReservationError::Registry(error)) => {
12697            return Err(format!("infer.deadline rejected: {error:?}"));
12698        }
12699    };
12700    serde_json::to_value(car_proto::InferenceControlResponse {
12701        inference_id: params.inference_id,
12702        status,
12703    })
12704    .map_err(|error| format!("serialize infer.deadline response: {error}"))
12705}
12706
12707fn reject_client_inference_id(params: &Value) -> Result<(), String> {
12708    if params.get("inference_id").is_some() {
12709        return Err("`inference_id` is server-assigned and cannot be supplied or reused".into());
12710    }
12711    Ok(())
12712}
12713
12714#[derive(Debug)]
12715enum InferStartedSendError {
12716    BeforeAdmission(String),
12717    AfterAdmission(String),
12718}
12719
12720impl InferStartedSendError {
12721    fn may_have_been_admitted(&self) -> bool {
12722        matches!(self, Self::AfterAdmission(_))
12723    }
12724}
12725
12726impl std::fmt::Display for InferStartedSendError {
12727    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12728        match self {
12729            Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
12730                formatter.write_str(message)
12731            }
12732        }
12733    }
12734}
12735
12736fn clean_up_failed_infer_started(
12737    registry: &crate::inference_control::InferenceRegistry,
12738    inference_id: &str,
12739    error: InferStartedSendError,
12740) -> String {
12741    if error.may_have_been_admitted() {
12742        registry.abort_after_started_admission(inference_id);
12743    } else {
12744        registry.abandon(inference_id);
12745    }
12746    error.to_string()
12747}
12748
12749struct InferenceOwnerGuard {
12750    registry: Arc<crate::inference_control::InferenceRegistry>,
12751    inference_id: String,
12752    completed_normally: bool,
12753}
12754
12755impl InferenceOwnerGuard {
12756    fn new(
12757        registry: Arc<crate::inference_control::InferenceRegistry>,
12758        inference_id: String,
12759    ) -> Self {
12760        Self {
12761            registry,
12762            inference_id,
12763            completed_normally: false,
12764        }
12765    }
12766
12767    fn complete_normally(&mut self) {
12768        self.completed_normally = true;
12769    }
12770}
12771
12772impl Drop for InferenceOwnerGuard {
12773    fn drop(&mut self) {
12774        if !self.completed_normally {
12775            self.registry.abort_owned_backend(&self.inference_id);
12776        }
12777    }
12778}
12779
12780fn attach_owned_inference_backend(
12781    registry: Arc<crate::inference_control::InferenceRegistry>,
12782    inference_id: &str,
12783    backend: tokio::task::JoinHandle<()>,
12784) -> Result<InferenceOwnerGuard, crate::inference_control::RegistryError> {
12785    registry.attach_backend(inference_id, backend)?;
12786    Ok(InferenceOwnerGuard::new(registry, inference_id.to_string()))
12787}
12788
12789async fn send_infer_started(
12790    session: &crate::session::ClientSession,
12791    request_id: &Value,
12792    inference_id: &str,
12793) -> Result<(), InferStartedSendError> {
12794    use futures::SinkExt;
12795    use tokio_tungstenite::tungstenite::Message;
12796
12797    let frame = serde_json::json!({
12798        "jsonrpc": "2.0",
12799        "method": "infer.started",
12800        "params": {
12801            "request_id": request_id,
12802            "inference_id": inference_id,
12803        },
12804    });
12805    let text = serde_json::to_string(&frame)
12806        .map_err(|error| InferStartedSendError::BeforeAdmission(error.to_string()))?;
12807    let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
12808    let mut write = tokio::time::timeout_at(deadline, session.channel.write.lock())
12809        .await
12810        .map_err(|_| {
12811            InferStartedSendError::BeforeAdmission(format!(
12812                "send infer.started timed out before admission after {}s",
12813                KEEPALIVE_WRITE_TIMEOUT.as_secs()
12814            ))
12815        })?;
12816
12817    match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
12818        Ok(Ok(())) => {}
12819        Ok(Err(error)) => {
12820            // A custom sink may enqueue in `start_send` before returning an
12821            // error. Retain the ID as terminal rather than assuming it stayed
12822            // invisible.
12823            return Err(InferStartedSendError::AfterAdmission(format!(
12824                "send infer.started admission failed: {error}"
12825            )));
12826        }
12827        Err(_) => {
12828            // `feed` only yields Pending while waiting for poll_ready; it has
12829            // not called synchronous start_send yet.
12830            return Err(InferStartedSendError::BeforeAdmission(format!(
12831                "send infer.started timed out before admission after {}s",
12832                KEEPALIVE_WRITE_TIMEOUT.as_secs()
12833            )));
12834        }
12835    }
12836
12837    match tokio::time::timeout_at(deadline, write.flush()).await {
12838        Ok(Ok(())) => Ok(()),
12839        Ok(Err(error)) => Err(InferStartedSendError::AfterAdmission(format!(
12840            "flush infer.started: {error}"
12841        ))),
12842        Err(_) => Err(InferStartedSendError::AfterAdmission(format!(
12843            "flush infer.started timed out after {}s; inference is terminal",
12844            KEEPALIVE_WRITE_TIMEOUT.as_secs()
12845        ))),
12846    }
12847}
12848
12849#[cfg(test)]
12850mod inference_backend_reliability_tests {
12851    use super::{
12852        attach_owned_inference_backend, clean_up_failed_infer_started, run_owned_inference_backend,
12853        send_infer_started, send_inference_notification_if_active, KEEPALIVE_WRITE_TIMEOUT,
12854    };
12855    use crate::inference_control::{InferenceRegistry, RegistryConfig};
12856    use crate::session::{ServerState, WsChannel, WsSink};
12857    use futures::{Sink, SinkExt};
12858    use serde_json::json;
12859    use std::collections::HashMap;
12860    use std::pin::Pin;
12861    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
12862    use std::sync::Arc;
12863    use std::task::{Context, Poll};
12864    use std::time::Duration;
12865    use tokio::sync::Mutex;
12866    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
12867
12868    struct FlushStallingSink {
12869        pending: Arc<std::sync::Mutex<Vec<Message>>>,
12870        visible: Arc<std::sync::Mutex<Vec<Message>>>,
12871        flush_ready: Arc<AtomicBool>,
12872    }
12873
12874    impl Sink<Message> for FlushStallingSink {
12875        type Error = WsError;
12876
12877        fn poll_ready(
12878            self: Pin<&mut Self>,
12879            _context: &mut Context<'_>,
12880        ) -> Poll<Result<(), Self::Error>> {
12881            Poll::Ready(Ok(()))
12882        }
12883
12884        fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
12885            self.pending
12886                .lock()
12887                .expect("pending frames lock")
12888                .push(message);
12889            Ok(())
12890        }
12891
12892        fn poll_flush(
12893            self: Pin<&mut Self>,
12894            _context: &mut Context<'_>,
12895        ) -> Poll<Result<(), Self::Error>> {
12896            if !self.flush_ready.load(Ordering::SeqCst) {
12897                return Poll::Pending;
12898            }
12899            let mut pending = self.pending.lock().expect("pending frames lock");
12900            self.visible
12901                .lock()
12902                .expect("visible frames lock")
12903                .extend(pending.drain(..));
12904            Poll::Ready(Ok(()))
12905        }
12906
12907        fn poll_close(
12908            self: Pin<&mut Self>,
12909            context: &mut Context<'_>,
12910        ) -> Poll<Result<(), Self::Error>> {
12911            self.poll_flush(context)
12912        }
12913    }
12914
12915    fn one_slot_registry() -> Arc<InferenceRegistry> {
12916        Arc::new(InferenceRegistry::with_config(RegistryConfig {
12917            max_active: 1,
12918            max_tombstones: 1,
12919            max_pending_controls: 1,
12920            max_orphans: 1,
12921            tombstone_ttl: Duration::from_secs(300),
12922            termination_ack_timeout: Duration::from_millis(20),
12923        }))
12924    }
12925
12926    #[tokio::test]
12927    async fn panicking_backend_publishes_terminal_error_and_releases_registry_capacity() {
12928        let registry = one_slot_registry();
12929        registry
12930            .begin_with_id("panic-stream")
12931            .expect("register panicking stream");
12932        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
12933        let task = tokio::spawn(run_owned_inference_backend(
12934            registry.clone(),
12935            "panic-stream".to_string(),
12936            result_tx,
12937            async {
12938                panic!("fixture stream backend panic");
12939                #[allow(unreachable_code)]
12940                Ok(json!({}))
12941            },
12942        ));
12943        registry
12944            .attach_backend("panic-stream", task)
12945            .expect("attach panicking stream");
12946
12947        let error = result_rx
12948            .await
12949            .expect("panic must be converted into a published result")
12950            .expect_err("panic must be a terminal error");
12951        assert!(error.contains("backend panicked"), "{error}");
12952        assert!(
12953            registry.complete("panic-stream"),
12954            "client-visible failure must terminalize the active entry"
12955        );
12956        assert_eq!(registry.counts().0, 0);
12957        registry
12958            .begin_with_id("replacement-stream")
12959            .expect("panicking backend must not consume the one session slot forever");
12960    }
12961
12962    #[tokio::test]
12963    async fn panic_before_attach_and_receiver_drop_still_releases_backend_ownership() {
12964        let registry = one_slot_registry();
12965        registry
12966            .begin_with_id("panic-before-attach")
12967            .expect("register panicking backend");
12968        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
12969        drop(result_rx);
12970        run_owned_inference_backend(
12971            registry.clone(),
12972            "panic-before-attach".to_string(),
12973            result_tx,
12974            async {
12975                panic!("panic before JoinHandle attachment");
12976                #[allow(unreachable_code)]
12977                Ok(json!({}))
12978            },
12979        )
12980        .await;
12981        registry
12982            .attach_backend("panic-before-attach", tokio::spawn(async {}))
12983            .expect("late attachment observes prior termination");
12984        assert!(registry.complete("panic-before-attach"));
12985        assert_eq!(registry.counts().0, 0);
12986        registry
12987            .begin_with_id("after-panic-before-attach")
12988            .expect("panic before attachment must not strand capacity");
12989    }
12990
12991    #[tokio::test(start_paused = true)]
12992    async fn infer_started_write_timeout_cleans_up_instead_of_waiting_on_the_socket_forever() {
12993        let tmp = tempfile::TempDir::new().expect("temporary server state");
12994        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
12995        let channel = Arc::new(WsChannel::test_stub());
12996        let session = state
12997            .create_session("stalled-infer-start", channel.clone())
12998            .await
12999            .expect("create test session");
13000        let registry = session.inference_control.clone();
13001        registry
13002            .begin_with_id("stalled-start")
13003            .expect("register pending inference");
13004        let backend = tokio::spawn(std::future::pending::<()>());
13005        registry
13006            .attach_backend("stalled-start", backend)
13007            .expect("attach pending backend");
13008
13009        let _held_write = channel.write.lock().await;
13010        let task_session = session.clone();
13011        let send = tokio::spawn(async move {
13012            let result = send_infer_started(&task_session, &json!(7), "stalled-start").await;
13013            if result.is_err() {
13014                task_session.inference_control.abandon("stalled-start");
13015            }
13016            result
13017        });
13018        tokio::task::yield_now().await;
13019        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13020
13021        let error = tokio::time::timeout(Duration::from_millis(1), send)
13022            .await
13023            .expect("infer.started must carry its own bounded-write policy")
13024            .expect("send task joins")
13025            .expect_err("held write lock must time out");
13026        assert!(error.to_string().contains("timed out"), "{error}");
13027        assert_eq!(registry.counts(), (0, 0), "failed start must be removed");
13028    }
13029
13030    #[tokio::test(start_paused = true)]
13031    async fn infer_started_flush_timeout_keeps_any_later_visible_id_terminally_queryable() {
13032        let tmp = tempfile::TempDir::new().expect("temporary server state");
13033        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
13034        let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13035        let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13036        let flush_ready = Arc::new(AtomicBool::new(false));
13037        let sink: WsSink = Box::pin(FlushStallingSink {
13038            pending: pending.clone(),
13039            visible: visible.clone(),
13040            flush_ready: flush_ready.clone(),
13041        });
13042        let channel = Arc::new(WsChannel {
13043            write: Mutex::new(sink),
13044            pending: Mutex::new(HashMap::new()),
13045            active_actions: Mutex::new(HashMap::new()),
13046            next_id: AtomicU64::new(0),
13047        });
13048        let session = state
13049            .create_session("partial-infer-start", channel.clone())
13050            .await
13051            .expect("create test session");
13052        let registry = session.inference_control.clone();
13053        registry
13054            .begin_with_id("partially-admitted-start")
13055            .expect("register pending inference");
13056        registry
13057            .attach_backend(
13058                "partially-admitted-start",
13059                tokio::spawn(std::future::pending::<()>()),
13060            )
13061            .expect("attach pending backend");
13062
13063        let task_session = session.clone();
13064        let send = tokio::spawn(async move {
13065            let error = send_infer_started(&task_session, &json!(8), "partially-admitted-start")
13066                .await
13067                .expect_err("flush must stall");
13068            clean_up_failed_infer_started(
13069                &task_session.inference_control,
13070                "partially-admitted-start",
13071                error,
13072            )
13073        });
13074        tokio::task::yield_now().await;
13075        assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13076        assert!(visible.lock().expect("visible frames lock").is_empty());
13077
13078        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13079        let error = send.await.expect("send task joins");
13080        assert!(error.contains("terminal"), "{error}");
13081        assert_eq!(registry.counts(), (0, 1));
13082        assert!(
13083            registry
13084                .terminal_outcome("partially-admitted-start")
13085                .is_some(),
13086            "a queued infer.started ID must remain queryable"
13087        );
13088
13089        flush_ready.store(true, Ordering::SeqCst);
13090        channel
13091            .write
13092            .lock()
13093            .await
13094            .send(Message::Text(
13095                json!({"jsonrpc":"2.0","id":8,"error":{"code":-32603}})
13096                    .to_string()
13097                    .into(),
13098            ))
13099            .await
13100            .expect("later dispatcher response flushes queued frames");
13101        let visible = visible.lock().expect("visible frames lock");
13102        assert_eq!(visible.len(), 2);
13103        let started: serde_json::Value = match &visible[0] {
13104            Message::Text(text) => serde_json::from_str(text).expect("started JSON"),
13105            other => panic!("expected text frame, got {other:?}"),
13106        };
13107        assert_eq!(
13108            started["params"]["inference_id"],
13109            "partially-admitted-start"
13110        );
13111        assert!(
13112            registry
13113                .terminal_outcome("partially-admitted-start")
13114                .is_some(),
13115            "later visibility must not turn the admitted ID into Unknown"
13116        );
13117    }
13118
13119    #[tokio::test(start_paused = true)]
13120    async fn notification_write_budget_reports_whether_a_frame_was_admitted() {
13121        let registry = one_slot_registry();
13122        registry
13123            .begin_with_id("notification-lock-timeout")
13124            .expect("register lock-stalled notification");
13125        let channel = Arc::new(WsChannel::test_stub());
13126        let held_write = channel.write.lock().await;
13127        let task_channel = channel.clone();
13128        let task_registry = registry.clone();
13129        let lock_timeout = tokio::spawn(async move {
13130            send_inference_notification_if_active(
13131                &task_channel,
13132                &task_registry,
13133                "notification-lock-timeout",
13134                json!({"method":"infer.progress"}).to_string(),
13135            )
13136            .await
13137            .expect_err("held writer lock must time out")
13138        });
13139        tokio::task::yield_now().await;
13140        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13141        let error = lock_timeout.await.expect("lock-timeout task joins");
13142        assert!(!error.may_have_been_admitted(), "{error}");
13143        assert!(
13144            error.to_string().contains("write lock timed out"),
13145            "{error}"
13146        );
13147        drop(held_write);
13148        registry.abandon("notification-lock-timeout");
13149
13150        let registry = one_slot_registry();
13151        registry
13152            .begin_with_id("progress-flush-timeout")
13153            .expect("register flush-stalled notification");
13154        let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13155        let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13156        let flush_ready = Arc::new(AtomicBool::new(false));
13157        let sink: WsSink = Box::pin(FlushStallingSink {
13158            pending: pending.clone(),
13159            visible: visible.clone(),
13160            flush_ready,
13161        });
13162        let channel = Arc::new(WsChannel {
13163            write: Mutex::new(sink),
13164            pending: Mutex::new(HashMap::new()),
13165            active_actions: Mutex::new(HashMap::new()),
13166            next_id: AtomicU64::new(0),
13167        });
13168        let task_registry = registry.clone();
13169        let flush_timeout = tokio::spawn(async move {
13170            send_inference_notification_if_active(
13171                &channel,
13172                &task_registry,
13173                "progress-flush-timeout",
13174                json!({"method":"infer.progress"}).to_string(),
13175            )
13176            .await
13177            .expect_err("flush-stalled notification must time out")
13178        });
13179        tokio::task::yield_now().await;
13180        assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13181        assert!(visible.lock().expect("visible frames lock").is_empty());
13182        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13183        let error = flush_timeout.await.expect("flush-timeout task joins");
13184        assert!(error.may_have_been_admitted(), "{error}");
13185        assert!(error.to_string().contains("flush timed out"), "{error}");
13186        registry.abort_owned_backend("progress-flush-timeout");
13187        assert!(registry
13188            .terminal_outcome("progress-flush-timeout")
13189            .is_some());
13190    }
13191
13192    #[tokio::test]
13193    async fn dropping_an_attached_owner_aborts_infer_backend() {
13194        for method in ["infer"] {
13195            let registry = one_slot_registry();
13196            let inference_id = format!("dropped-owner-{method}");
13197            registry
13198                .begin_with_id(&inference_id)
13199                .expect("register owned backend");
13200            let admission = Arc::new(tokio::sync::Semaphore::new(1));
13201            let task_admission = admission.clone();
13202            let backend = tokio::spawn(async move {
13203                let _permit = task_admission
13204                    .acquire_owned()
13205                    .await
13206                    .expect("admission open");
13207                std::future::pending::<()>().await;
13208            });
13209            let owner_guard =
13210                attach_owned_inference_backend(registry.clone(), &inference_id, backend)
13211                    .expect("attach through the shared handler ownership path");
13212            let owner = tokio::spawn(async move {
13213                let _owner = owner_guard;
13214                std::future::pending::<()>().await;
13215            });
13216            tokio::task::yield_now().await;
13217            assert_eq!(admission.available_permits(), 0);
13218
13219            owner.abort();
13220            assert!(owner
13221                .await
13222                .expect_err("owner future is cancelled")
13223                .is_cancelled());
13224            tokio::task::yield_now().await;
13225
13226            assert_eq!(admission.available_permits(), 1, "{method}");
13227            assert_eq!(registry.counts(), (0, 1), "{method}");
13228            assert_eq!(registry.orphan_count(), 0, "{method}");
13229            assert!(
13230                registry.terminal_outcome(&inference_id).is_some(),
13231                "{method}"
13232            );
13233            registry
13234                .begin_with_id(&format!("replacement-{method}"))
13235                .expect("owner drop releases per-session capacity");
13236            registry.abort_all();
13237        }
13238    }
13239}
13240
13241fn insert_inference_id(value: &mut Value, inference_id: &str) -> Result<(), String> {
13242    value
13243        .as_object_mut()
13244        .ok_or_else(|| "inference result must be a JSON object".to_string())?
13245        .insert(
13246            "inference_id".to_string(),
13247            Value::String(inference_id.to_string()),
13248        );
13249    Ok(())
13250}
13251
13252/// Serialize inference notifications with terminal responses on the WebSocket
13253/// write lock, then re-check lifecycle state at the last safe point before the
13254/// frame reaches the wire. A terminal response can therefore never be
13255/// overtaken by a stream/progress frame that observed stale active state while
13256/// waiting for the shared sink.
13257async fn send_inference_notification_if_active(
13258    channel: &WsChannel,
13259    registry: &crate::inference_control::InferenceRegistry,
13260    inference_id: &str,
13261    text: String,
13262) -> Result<bool, InferenceNotificationSendError> {
13263    let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
13264    let mut write = tokio::time::timeout_at(deadline, channel.write.lock())
13265        .await
13266        .map_err(|_| {
13267            InferenceNotificationSendError::BeforeAdmission(format!(
13268                "notification write lock timed out after {}s",
13269                KEEPALIVE_WRITE_TIMEOUT.as_secs()
13270            ))
13271        })?;
13272    if !registry.is_active(inference_id) {
13273        return Ok(false);
13274    }
13275
13276    match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
13277        Ok(Ok(())) => {}
13278        Ok(Err(error)) => {
13279            return Err(InferenceNotificationSendError::AfterAdmission(format!(
13280                "notification admission failed: {error}"
13281            )));
13282        }
13283        Err(_) => {
13284            return Err(InferenceNotificationSendError::BeforeAdmission(format!(
13285                "notification admission timed out after {}s",
13286                KEEPALIVE_WRITE_TIMEOUT.as_secs()
13287            )));
13288        }
13289    }
13290
13291    match tokio::time::timeout_at(deadline, write.flush()).await {
13292        Ok(Ok(())) => Ok(true),
13293        Ok(Err(error)) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13294            "notification flush failed: {error}"
13295        ))),
13296        Err(_) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13297            "notification flush timed out after {}s",
13298            KEEPALIVE_WRITE_TIMEOUT.as_secs()
13299        ))),
13300    }
13301}
13302
13303#[derive(Debug)]
13304enum InferenceNotificationSendError {
13305    BeforeAdmission(String),
13306    AfterAdmission(String),
13307}
13308
13309impl InferenceNotificationSendError {
13310    #[cfg(test)]
13311    fn may_have_been_admitted(&self) -> bool {
13312        matches!(self, Self::AfterAdmission(_))
13313    }
13314}
13315
13316impl std::fmt::Display for InferenceNotificationSendError {
13317    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13318        match self {
13319            Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
13320                formatter.write_str(message)
13321            }
13322        }
13323    }
13324}
13325
13326#[cfg(test)]
13327mod inference_notification_terminal_boundary_tests {
13328    use super::send_inference_notification_if_active;
13329    use crate::inference_control::{ControlCause, InferenceRegistry};
13330    use crate::session::WsChannel;
13331    use car_proto::InferenceControlStatus;
13332    use futures::SinkExt;
13333    use std::sync::Arc;
13334    use tokio::sync::oneshot;
13335    use tokio_tungstenite::tungstenite::Message;
13336
13337    #[tokio::test]
13338    async fn terminal_response_is_a_hard_boundary_for_progress_frames() {
13339        for (inference_id, cause, expected, wire_status) in [
13340            (
13341                "inf_terminal_boundary_cancel",
13342                ControlCause::Cancel,
13343                InferenceControlStatus::CancelledConfirmed,
13344                "cancelled_confirmed",
13345            ),
13346            (
13347                "inf_terminal_boundary_deadline",
13348                ControlCause::Deadline,
13349                InferenceControlStatus::DeadlineExceededConfirmed,
13350                "deadline_exceeded_confirmed",
13351            ),
13352        ] {
13353            let registry = Arc::new(InferenceRegistry::default());
13354            registry
13355                .begin_with_id(inference_id)
13356                .expect("register active inference");
13357            let (channel, captured) = WsChannel::test_capture();
13358            let channel = Arc::new(channel);
13359
13360            // The held write guard is the deterministic barrier: both notification
13361            // tasks have received and serialized their event, but neither can reach
13362            // the wire until after the terminal response is committed below.
13363            let mut boundary = channel.write.lock().await;
13364            assert!(registry.is_active(inference_id), "event precheck is active");
13365
13366            let frame = serde_json::json!({
13367                "jsonrpc": "2.0",
13368                "method": "infer.progress",
13369                "params": {"id": 1},
13370            });
13371            let channel = channel.clone();
13372            let notification_registry = registry.clone();
13373            let text = serde_json::to_string(&frame).expect("serialize notification");
13374            let (started_tx, started_rx) = oneshot::channel();
13375            let pending = tokio::spawn(async move {
13376                started_tx.send(()).expect("signal event receipt");
13377                send_inference_notification_if_active(
13378                    &channel,
13379                    &notification_registry,
13380                    inference_id,
13381                    text,
13382                )
13383                .await
13384            });
13385            started_rx.await.expect("notification task reached barrier");
13386
13387            let terminal = registry.control(inference_id, cause, async { true }).await;
13388            assert_eq!(terminal, expected);
13389            boundary
13390                .send(Message::Text(
13391                    serde_json::json!({
13392                        "jsonrpc": "2.0",
13393                        "id": "terminal-1",
13394                        "result": {
13395                            "inference_id": inference_id,
13396                            "status": wire_status,
13397                        },
13398                    })
13399                    .to_string()
13400                    .into(),
13401                ))
13402                .await
13403                .expect("write terminal response");
13404            drop(boundary);
13405
13406            assert!(
13407                !pending
13408                    .await
13409                    .expect("notification task")
13410                    .expect("notification send"),
13411                "terminal notification must be suppressed"
13412            );
13413
13414            let frames = captured.lock().expect("capture lock");
13415            assert_eq!(frames.len(), 1, "no frame may follow terminal response");
13416            assert!(frames[0].contains(wire_status));
13417        }
13418        eprintln!(
13419            "C3_TERMINAL_BOUNDARY_SHARED=terminals=cancelled_confirmed+deadline_exceeded_confirmed,late_progress=suppressed,frames=1_each"
13420        );
13421    }
13422}
13423
13424async fn wait_terminal_control_value(
13425    mut terminal: tokio::sync::watch::Receiver<Option<crate::inference_control::TerminalOutcome>>,
13426    inference_id: &str,
13427) -> Value {
13428    if terminal.borrow().is_none() {
13429        let _ = terminal.changed().await;
13430    }
13431    let status = match *terminal.borrow() {
13432        Some(crate::inference_control::TerminalOutcome::Controlled(status)) => status,
13433        _ => car_proto::InferenceControlStatus::AlreadyTerminal,
13434    };
13435    serde_json::to_value(car_proto::InferenceControlResponse {
13436        inference_id: inference_id.to_string(),
13437        status,
13438    })
13439    .unwrap_or_else(|_| {
13440        serde_json::json!({
13441            "inference_id": inference_id,
13442            "status": "already_terminal",
13443        })
13444    })
13445}
13446
13447async fn handle_infer_active(
13448    msg: &JsonRpcMessage,
13449    state: &ServerState,
13450    session: &crate::session::ClientSession,
13451    inference_id: &str,
13452) -> Result<Value, String> {
13453    use futures::FutureExt;
13454    use std::panic::AssertUnwindSafe;
13455    use std::time::Duration;
13456
13457    let engine = get_inference_engine(state);
13458    let mut req = parse_inference_request(&msg.params)?;
13459
13460    // If context_query is provided, build context from memgine and inject it
13461    if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13462        // `effective_memgine` (#82): injected context must come from the graph
13463        // the session's facts went into, not the ephemeral one.
13464        let memgine_arc = session.effective_memgine().await;
13465        let mut memgine = memgine_arc.lock().await;
13466        // Split at the stable Identity+Constraints boundary so an Anthropic
13467        // cached request breaks after the stable prefix (which hits) instead of
13468        // over the whole churning context. `full` is byte-identical to the old
13469        // `build_context`, so non-Anthropic providers and StateBench see the
13470        // same context; the prefix is just a hint the handler validates.
13471        let split = memgine.build_context_split_for_model(cq, None);
13472        if !split.full.is_empty() {
13473            req.context_stable_prefix = split.stable_prefix();
13474            req.context = Some(split.full);
13475        }
13476    }
13477    // Stamp the originating session so a delegated call reaches the runner THIS
13478    // host registered, not whichever registered last (car-releases#77). Never
13479    // serialized — `caller` is `#[serde(skip)]`, so it does not reach the host.
13480    req.caller = Some(session.client_id.clone());
13481    maybe_apply_proactive_memory(msg, session, &mut req).await?;
13482
13483    // Stakes-aware routing: a session authorized for FullAccess (irreversible /
13484    // externally-consequential) actions routes its inference quality-first — we
13485    // never economize on a session that can take actions it can't take back. The
13486    // policy→intent mapping lives here (car-server-core has both the session
13487    // tier and the request); car-inference stays policy-free.
13488    if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
13489    {
13490        req.intent.get_or_insert_with(Default::default).high_stakes = true;
13491    }
13492
13493    // Conversation-outcome (Part B): capture the chat user turn + idle-guard
13494    // verdict before `req` is consumed by generate_tracked. Shared with the
13495    // streaming path via `begin_chat_turn` (gate + concurrency rules live there).
13496    let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
13497
13498    // Heartbeat (car#476). A cold local model-load, a queued admission slot, or
13499    // a slow remote can each keep this handler busy well past the FFI client's
13500    // old flat 30s read window. Emit an `infer.progress` notification carrying
13501    // this request id every INFER_HEARTBEAT_INTERVAL for the whole in-flight
13502    // span, so the client holds an *idle* read deadline (reset per heartbeat)
13503    // and only reaps true daemon silence. Model-agnostic on purpose: a local
13504    // cold-start and a slow remote are both covered without the client having
13505    // to know which model the router picked. The guard aborts the ticker the
13506    // instant this handler returns (any path); an older client that doesn't
13507    // idle-reset simply drops the unknown notification.
13508    const INFER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
13509    struct HeartbeatGuard(tokio::task::JoinHandle<()>);
13510    impl Drop for HeartbeatGuard {
13511        fn drop(&mut self) {
13512            self.0.abort();
13513        }
13514    }
13515    let _heartbeat = {
13516        let channel = session.channel.clone();
13517        let request_id = msg.id.clone();
13518        let registry = session.inference_control.clone();
13519        let inference_id = inference_id.to_string();
13520        HeartbeatGuard(tokio::spawn(async move {
13521            let mut ticker = tokio::time::interval(INFER_HEARTBEAT_INTERVAL);
13522            ticker.tick().await; // consume the immediate first tick (fires at t=0)
13523            loop {
13524                ticker.tick().await;
13525                let notif = serde_json::json!({
13526                    "jsonrpc": "2.0",
13527                    "method": "infer.progress",
13528                    "params": { "id": request_id },
13529                });
13530                let Ok(text) = serde_json::to_string(&notif) else {
13531                    break;
13532                };
13533                match send_inference_notification_if_active(
13534                    &channel,
13535                    &registry,
13536                    &inference_id,
13537                    text,
13538                )
13539                .await
13540                {
13541                    Ok(true) => {}
13542                    Ok(false) | Err(_) => break,
13543                }
13544            }
13545        }))
13546    };
13547
13548    // Process-wide admission gate. Held for the duration of the
13549    // generation so a burst of concurrent infer RPCs can't multiply
13550    // KV-cache + activation memory and take the host out. The
13551    // `_permit` binding is intentional — its `Drop` releases the slot
13552    // when this future returns.
13553    //
13554    // Which POOL depends on whether this host will do the compute. A remote
13555    // call allocates no KV cache here, so charging it a RAM-sized permit capped
13556    // remote throughput by local memory (car#800). We can only tell when the
13557    // request PINS a model: routing picks the model later, inside
13558    // generate_tracked, and by then the permit is already held. Unpinned — and
13559    // delegated, which may be a cloud API or a host driving a local llama.cpp —
13560    // stays on the local pool. Guessing "remote" would strip the guard from
13561    // exactly the call that needs it.
13562    let does_local_compute =
13563        match car_inference::exact_pinned_model_id(&req).or(req.model.as_deref()) {
13564            Some(pinned) => !engine
13565                .registered_schema(pinned)
13566                .map(|s| s.is_remote())
13567                .unwrap_or(false),
13568            None => true,
13569        };
13570    let _permit = state.admission.acquire_for(does_local_compute).await;
13571
13572    // Use generate_tracked() so tool_calls, usage, model_used, trace_id, and
13573    // latency_ms are preserved in the response. Plain `generate()` discards
13574    // everything except `.text`, which silently breaks tool-use over the
13575    // WebSocket protocol (issue #43).
13576    //
13577    // NOTE: This directly serializes `InferenceResult`. Any field added to
13578    // that struct in `car-inference` becomes part of the public WebSocket
13579    // protocol. The shape is locked by `inference_result_serializes_*` tests
13580    // in car-inference; updating those tests is part of intentionally
13581    // changing the wire contract.
13582    // Bound the inference so a dead compute backend can never hang the client
13583    // forever. The 10s heartbeat above keeps the client's idle read deadline
13584    // alive for the whole in-flight span, so without this bound a compute that
13585    // dies silently reads as an infinite hang rather than an error. Two failure
13586    // modes are covered: (1) the compute panics and unwinds through this future
13587    // (e.g. candle re-panicking a spawn_blocking JoinError — the observed
13588    // cudarc/cuBLAS-load case) → caught by catch_unwind; (2) the compute dies on
13589    // a detached task, leaving this await pending forever → caught by the
13590    // timeout. Tunable via CAR_INFER_TIMEOUT_SECS (default 600s — generous
13591    // enough for a cold large-model load, which the heartbeat also covers).
13592    let infer_timeout = inference_total_timeout();
13593    let result = match tokio::time::timeout(
13594        infer_timeout,
13595        AssertUnwindSafe(engine.generate_tracked(req)).catch_unwind(),
13596    )
13597    .await
13598    {
13599        Ok(Ok(r)) => r.map_err(|e| inference_dispatch_error(&e))?,
13600        Ok(Err(panic)) => {
13601            let detail = panic
13602                .downcast_ref::<&str>()
13603                .map(|s| (*s).to_string())
13604                .or_else(|| panic.downcast_ref::<String>().cloned())
13605                .unwrap_or_else(|| "unknown panic".to_string());
13606            return Err(format!(
13607                "inference failed (internal panic): {detail}. If this is a CUDA build, \
13608                 ensure the CUDA toolkit `bin` directory is on PATH so cudarc can load \
13609                 cublas/cudart."
13610            ));
13611        }
13612        Err(_) => {
13613            return Err(format!(
13614                "inference timed out after {}s with no result — the compute backend may \
13615                 have failed to initialize (e.g. a CUDA/cuBLAS library load error). \
13616                 Set CAR_INFER_TIMEOUT_SECS to adjust the limit.",
13617                infer_timeout.as_secs()
13618            ));
13619        }
13620    };
13621
13622    // Conversation-outcome (Part B): score the session's PRIOR assistant turn by
13623    // this new user turn (the next-turn-is-the-label signal), then remember this
13624    // turn for the next round. trace_id/model are carried straight from the
13625    // result that produced each turn — never reconstructed from order — so credit
13626    // lands on the exact model that generated the judged turn. Gated on
13627    // `chat_was_idle` so overlapping infers don't fabricate adjacency (neo #1).
13628    // Best-effort: never fails the inference response.
13629    if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
13630        let mut slot = session.last_chat_turn.lock().await;
13631        let tracker_arc = engine.outcome_tracker();
13632        let mut tracker = tracker_arc.write().await;
13633        let tally = score_and_remember_chat_turn(
13634            &mut slot,
13635            &mut tracker,
13636            user_text,
13637            &result.text,
13638            &result.trace_id,
13639            &result.model_used,
13640        );
13641        if tally.submitted > 0 {
13642            tracing::debug!(
13643                submitted = tally.submitted,
13644                resolved = tally.resolved,
13645                "conversation-outcome recorded for prior chat turn"
13646            );
13647        }
13648    }
13649
13650    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13651}
13652
13653/// Streaming inference — mirrors NAPI `inferStream`. Closes
13654/// Parslee-ai/car-releases#30. Same `GenerateRequest` shape as
13655/// `infer`; emits `inference.stream.event` JSON-RPC notifications
13656/// during the run, and returns the final `InferenceResult` as the
13657/// JSON-RPC response when the stream completes.
13658///
13659/// Notification shape (server → client):
13660/// ```jsonc
13661/// {
13662///   "jsonrpc": "2.0",
13663///   "method": "inference.stream.event",
13664///   "params": {
13665///     "request_id": "<original RPC id>",
13666///     "event": { "type": "text" | "tool_start" | "tool_delta" | "usage", ... }
13667///   }
13668/// }
13669/// ```
13670///
13671/// The final `done` event is not pushed as a notification — it's
13672/// the JSON-RPC response with the accumulated `InferenceResult`.
13673/// `video.generate` — daemon-side wrapper for
13674/// `InferenceEngine::generate_video`. Mirrors `handle_infer`'s
13675/// admission gate + JSON request shape (Parslee-ai/car#185).
13676///
13677/// Previously the CLI's `cmd_video` constructed an in-process
13678/// engine and called `generate_video` directly — a v0.7 holdover
13679/// that bypassed the daemon. With this handler the CLI proxies
13680/// here, so the engine-level audio_passthrough gate fires
13681/// inside the daemon process where all FFI surfaces converge.
13682async fn handle_image_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13683    let engine = get_inference_engine(state);
13684    let req: car_inference::GenerateImageRequest = typed_params(&msg.params)?;
13685    // Share the same admission gate as text/video generation — a burst
13686    // of image requests shouldn't smuggle around the concurrency cap.
13687    let _permit = state.admission.acquire().await;
13688    let result = engine
13689        .generate_image(req)
13690        .await
13691        .map_err(|e| e.to_string())?;
13692    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13693}
13694
13695async fn handle_video_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13696    let engine = get_inference_engine(state);
13697    let req: car_inference::GenerateVideoRequest = typed_params(&msg.params)?;
13698    let _permit = state.admission.acquire().await;
13699    let result = engine
13700        .generate_video(req)
13701        .await
13702        .map_err(|e| e.to_string())?;
13703    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13704}
13705
13706async fn handle_infer_stream(
13707    msg: &JsonRpcMessage,
13708    session: &crate::session::ClientSession,
13709    state: &ServerState,
13710) -> Result<Value, String> {
13711    let engine = get_inference_engine(state);
13712    let mut req = parse_inference_request(&msg.params)?;
13713
13714    // Same context-injection convenience as non-streaming `infer` so
13715    // the two methods have parity on the call shape.
13716    if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13717        // `effective_memgine` (#82): injected context must come from the graph
13718        // the session's facts went into, not the ephemeral one.
13719        let memgine_arc = session.effective_memgine().await;
13720        let mut memgine = memgine_arc.lock().await;
13721        // Split at the stable Identity+Constraints boundary so an Anthropic
13722        // cached request breaks after the stable prefix (which hits) instead of
13723        // over the whole churning context. `full` is byte-identical to the old
13724        // `build_context`, so non-Anthropic providers and StateBench see the
13725        // same context; the prefix is just a hint the handler validates.
13726        let split = memgine.build_context_split_for_model(cq, None);
13727        if !split.full.is_empty() {
13728            req.context_stable_prefix = split.stable_prefix();
13729            req.context = Some(split.full);
13730        }
13731    }
13732    // Stamp the originating session so a delegated call reaches the runner THIS
13733    // host registered, not whichever registered last (car-releases#77). Never
13734    // serialized — `caller` is `#[serde(skip)]`, so it does not reach the host.
13735    req.caller = Some(session.client_id.clone());
13736    maybe_apply_proactive_memory(msg, session, &mut req).await?;
13737
13738    // Stakes-aware routing (parity with non-streaming `infer`): a FullAccess
13739    // session routes inference quality-first.
13740    if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
13741    {
13742        req.intent.get_or_insert_with(Default::default).high_stakes = true;
13743    }
13744
13745    // Conversation-outcome (Part B, streaming parity): capture the chat user
13746    // turn + idle-guard verdict before `req` is consumed. Same gate + rules as
13747    // the non-streaming path (shared `begin_chat_turn`).
13748    let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
13749
13750    let _permit = state.admission.acquire().await;
13751    // Same classification as the non-streaming path: `generate_tracked_stream`
13752    // returns a typed `InferenceError` when the call is rejected BEFORE the
13753    // stream opens, which is where a gateway content refusal lands. (A refusal
13754    // that arrives mid-stream reaches us as `StreamEvent::Error(String)` —
13755    // already flattened to text by the provider layer — so it cannot be
13756    // classified by variant here.)
13757    let mut handle = engine
13758        .generate_tracked_stream(req)
13759        .await
13760        .map_err(|e| inference_dispatch_error(&e))?;
13761
13762    let mut accumulator = car_inference::StreamAccumulator::default();
13763    let mut stream_error: Option<String> = None;
13764    let mut saw_done = false;
13765    let request_id = msg.id.clone();
13766
13767    while let Some(event) = handle.events.recv().await {
13768        let event_payload = match &event {
13769            car_inference::StreamEvent::TextDelta(text) => {
13770                serde_json::json!({"type": "text", "data": text})
13771            }
13772            car_inference::StreamEvent::ToolCallStart { name, index, .. } => {
13773                serde_json::json!({"type": "tool_start", "name": name, "index": index})
13774            }
13775            car_inference::StreamEvent::ToolCallDelta {
13776                index,
13777                arguments_delta,
13778            } => serde_json::json!({
13779                "type": "tool_delta",
13780                "index": index,
13781                "data": arguments_delta,
13782            }),
13783            car_inference::StreamEvent::Usage {
13784                input_tokens,
13785                output_tokens,
13786                cache_read_input_tokens,
13787                cache_creation_input_tokens,
13788            } => serde_json::json!({
13789                "type": "usage",
13790                "input_tokens": input_tokens,
13791                "output_tokens": output_tokens,
13792                "cache_read_input_tokens": cache_read_input_tokens,
13793                "cache_creation_input_tokens": cache_creation_input_tokens,
13794            }),
13795            car_inference::StreamEvent::StopReason(reason) => {
13796                serde_json::json!({"type": "stop_reason", "data": reason})
13797            }
13798            car_inference::StreamEvent::ProviderOutputItem(item) => {
13799                serde_json::json!({"type": "provider_output_item", "item": item})
13800            }
13801            car_inference::StreamEvent::Error(message) => {
13802                stream_error = Some(message.clone());
13803                serde_json::json!({"type": "error", "message": message})
13804            }
13805            // Done is delivered as the JSON-RPC response, not a
13806            // notification — matches the NAPI contract where the
13807            // standalone function's return value is the accumulated
13808            // result and the callback only sees in-progress events.
13809            car_inference::StreamEvent::Done { .. } => {
13810                saw_done = true;
13811                accumulator.push(&event);
13812                continue;
13813            }
13814        };
13815
13816        let notif = serde_json::json!({
13817            "jsonrpc": "2.0",
13818            "method": "inference.stream.event",
13819            "params": {
13820                "request_id": request_id,
13821                "event": event_payload,
13822            },
13823        });
13824        if let Ok(text) = serde_json::to_string(&notif) {
13825            let _ = session
13826                .channel
13827                .write
13828                .lock()
13829                .await
13830                .send(Message::Text(text.into()))
13831                .await;
13832        }
13833        accumulator.push(&event);
13834    }
13835
13836    let (text, tool_calls, usage, stop_reason) = accumulator.finish_with_usage();
13837
13838    if let Some(error) = stream_error {
13839        // A refusal that lands mid-stream is the same ruling as one that lands
13840        // before the stream opens, and a benchmark scoring the tracked inference
13841        // path cannot see a difference it did not ask for. Classify it too, so
13842        // `infer_stream` reaches -32007 by both routes (Parslee-ai/car#796).
13843        return Err(stream_dispatch_error(error));
13844    }
13845    if !saw_done {
13846        return Err("stream ended without positive completion".to_string());
13847    }
13848
13849    // Record inference token telemetry into the session event log as deep
13850    // telemetry (§3.5.1) so trajectory-level token totals
13851    // (`EventLog::metrics_totals`) reflect model cost, not just tool
13852    // latency. Emitted **once** from the accumulator's reconciled usage —
13853    // providers (e.g. Anthropic) emit cumulative `Usage` multiple times
13854    // per stream, so metering inside the loop would double-count. The
13855    // dedicated `InferenceMetered` kind keeps this out of action stats.
13856    if let Some(u) = &usage {
13857        session.runtime.log.lock().await.append_metered(
13858            car_eventlog::EventKind::InferenceMetered,
13859            None,
13860            None,
13861            HashMap::new(),
13862            car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None),
13863        );
13864    }
13865
13866    // Conversation-outcome (Part B, streaming parity): score the session's PRIOR
13867    // chat turn by this new user turn, then stash this turn's trace_id/model for
13868    // the next round. They come from the `TrackedStream` handle (minted by the
13869    // same `record_start` the tap task resolves), so credit lands on the exact
13870    // model that produced the turn — the streaming analogue of `infer`'s
13871    // `InferenceResult`. What's load-bearing is that the PRIOR turn's trace still
13872    // survives in the tracker's `pending` set across the inter-turn gap; the only
13873    // thing that evicts it early is the 300s pending-sweep, identical to the
13874    // non-streaming path (a long-idle session loses the signal either way).
13875    // Best-effort: never fails the response.
13876    if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
13877        let mut slot = session.last_chat_turn.lock().await;
13878        let tracker_arc = engine.outcome_tracker();
13879        let mut tracker = tracker_arc.write().await;
13880        let tally = score_and_remember_chat_turn(
13881            &mut slot,
13882            &mut tracker,
13883            user_text,
13884            &text,
13885            &handle.trace_id,
13886            &handle.model_used,
13887        );
13888        if tally.submitted > 0 {
13889            tracing::debug!(
13890                submitted = tally.submitted,
13891                resolved = tally.resolved,
13892                "conversation-outcome recorded for prior chat turn (streaming)"
13893            );
13894        }
13895    }
13896
13897    Ok(serde_json::json!({
13898        "text": text,
13899        "tool_calls": tool_calls,
13900        "usage": usage,
13901        "stop_reason": stop_reason,
13902        // Parity with non-streaming `infer`: surface the trace + model so a
13903        // client can attribute the turn / resolve outcomes later.
13904        "trace_id": handle.trace_id,
13905        "model_used": handle.model_used,
13906    }))
13907}
13908
13909async fn handle_embed(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13910    let engine = get_inference_engine(state);
13911    let req: car_inference::EmbedRequest = typed_params(&msg.params)?;
13912    // Embeds load their own model weights; share the same admission
13913    // gate as generations so a burst of embed requests can't smuggle
13914    // around the concurrency cap.
13915    let _permit = state.admission.acquire().await;
13916    let result = engine.embed(req).await.map_err(|e| e.to_string())?;
13917    Ok(serde_json::json!({"embeddings": result}))
13918}
13919
13920async fn handle_classify(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13921    let engine = get_inference_engine(state);
13922    let req: car_inference::ClassifyRequest = typed_params(&msg.params)?;
13923    let _permit = state.admission.acquire().await;
13924    let result = engine.classify(req).await.map_err(|e| e.to_string())?;
13925    Ok(serde_json::json!({"classifications": result}))
13926}
13927
13928/// Surface the current admission state so the menubar tray and
13929/// `car daemon status` can show "queued: N" / "permits: P/T". Read-only
13930/// snapshot — racy by definition but correct enough for status panels.
13931fn handle_admission_status(state: &ServerState) -> Result<Value, String> {
13932    let total = state.admission.permits();
13933    let available = state.admission.permits_available();
13934    let in_use = total.saturating_sub(available);
13935    Ok(serde_json::json!({
13936        "permits_total": total,
13937        "permits_available": available,
13938        "permits_in_use": in_use,
13939        "env_override": crate::admission::ENV_MAX_CONCURRENT,
13940    }))
13941}
13942
13943async fn handle_tokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13944    let model = msg
13945        .params
13946        .get("model")
13947        .and_then(|v| v.as_str())
13948        .ok_or("missing 'model' parameter")?;
13949    let text = msg
13950        .params
13951        .get("text")
13952        .and_then(|v| v.as_str())
13953        .ok_or("missing 'text' parameter")?;
13954    let engine = get_inference_engine(state);
13955    let ids = engine
13956        .tokenize(model, text)
13957        .await
13958        .map_err(|e| e.to_string())?;
13959    Ok(serde_json::json!({"tokens": ids}))
13960}
13961
13962async fn handle_detokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13963    let model = msg
13964        .params
13965        .get("model")
13966        .and_then(|v| v.as_str())
13967        .ok_or("missing 'model' parameter")?;
13968    let tokens: Vec<u32> = msg
13969        .params
13970        .get("tokens")
13971        .and_then(|v| v.as_array())
13972        .ok_or("missing 'tokens' parameter")?
13973        .iter()
13974        .map(|t| {
13975            t.as_u64()
13976                .and_then(|n| u32::try_from(n).ok())
13977                .ok_or_else(|| "tokens[] must be u32 values".to_string())
13978        })
13979        .collect::<Result<Vec<_>, _>>()?;
13980    let engine = get_inference_engine(state);
13981    let text = engine
13982        .detokenize(model, &tokens)
13983        .await
13984        .map_err(|e| e.to_string())?;
13985    Ok(serde_json::json!({"text": text}))
13986}
13987
13988/// `models.register` — persist a user-supplied `ModelSchema` to `models.json`
13989/// under the CAR state root, i.e. `~/.car/models.json` unless `CAR_HOME` moves
13990/// it (Parslee-ai/car-releases#39). Replaces any existing entry with the same
13991/// `id`. Returns `{id, registered}`.
13992///
13993/// **Phase 1 limitation**: the daemon's live `UnifiedRegistry` is
13994/// not updated in-process — the new model becomes visible to
13995/// `models.list`, `infer`, `infer_stream` on the **next daemon
13996/// boot** when `load_user_config` re-reads the file. This is
13997/// enough to unblock opencode's setup flow (register ahead of
13998/// time, then start the daemon). Hot-update requires either an
13999/// `RwLock<InferenceEngine>` on `ServerState` or an
14000/// interior-mutable `UnifiedRegistry`; both touch 20+ call sites
14001/// and are tracked as a follow-up.
14002///
14003/// Until hot-update lands, callers SHOULD register their models
14004/// before issuing `infer` calls against them, and operators
14005/// SHOULD restart the daemon after batches of model
14006/// registrations.
14007fn parse_user_registered_model_schema(
14008    schema_value: Value,
14009) -> Result<car_inference::ModelSchema, String> {
14010    let mut schema: car_inference::ModelSchema =
14011        serde_json::from_value(schema_value).map_err(|e| format!("invalid ModelSchema: {e}"))?;
14012    if car_inference::openrouter::is_curated_managed_gateway_alias(&schema.id) {
14013        return Err(
14014            "Parslee-managed OpenRouter aliases are reserved and cannot be registered".into(),
14015        );
14016    }
14017    schema.mark_user_registered();
14018    Ok(schema)
14019}
14020
14021async fn handle_models_register(
14022    req: &JsonRpcMessage,
14023    _state: &Arc<ServerState>,
14024) -> Result<Value, String> {
14025    // The params shape mirrors v0.7's FFI `rt.registerModel(schemaJson)`:
14026    // either the bare `ModelSchema` value, OR `{ schema: ModelSchema }`.
14027    // Honor both so existing in-process callers don't have to reshape.
14028    let schema_value = match req.params.get("schema") {
14029        Some(v) => v.clone(),
14030        None => req.params.clone(),
14031    };
14032    let schema = parse_user_registered_model_schema(schema_value)?;
14033    let id = schema.id.clone();
14034
14035    // One resolver, shared with the read side: `models.json` under the CAR
14036    // state root (`~/.car/models.json` unless `CAR_HOME` moves it). The
14037    // registry's `user_config_path` is the same call, so what this writes is
14038    // what the next daemon boot loads. Read whatever's there, swap in the new
14039    // entry, write back atomically.
14040    let path = car_inference::registry::user_config_path()
14041        .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14042    if let Some(car_dir) = path.parent() {
14043        std::fs::create_dir_all(car_dir)
14044            .map_err(|e| format!("create {}: {e}", car_dir.display()))?;
14045    }
14046
14047    let mut models: Vec<car_inference::ModelSchema> = if path.exists() {
14048        let text =
14049            std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14050        if text.trim().is_empty() {
14051            Vec::new()
14052        } else {
14053            serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14054        }
14055    } else {
14056        Vec::new()
14057    };
14058    for model in &mut models {
14059        // Rewriting models.json is also a migration boundary for legacy rows
14060        // that omitted trust_tier or claimed Curated explicitly.
14061        model.mark_user_registered();
14062    }
14063    // Replace existing entry with the same id, else append.
14064    if let Some(slot) = models.iter_mut().find(|m| m.id == id) {
14065        *slot = schema;
14066    } else {
14067        models.push(schema);
14068    }
14069    let json =
14070        serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14071    let tmp = path.with_extension("json.tmp");
14072    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14073    std::fs::rename(&tmp, &path)
14074        .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14075    Ok(serde_json::json!({
14076        "id": id,
14077        "registered": true,
14078        "path": path.to_string_lossy(),
14079        "note": "Daemon restart required for live UnifiedRegistry visibility \
14080                 (Parslee-ai/car-releases#39 phase 1). The model is persisted; \
14081                 next car-server boot loads it via UnifiedRegistry::load_user_config.",
14082    }))
14083}
14084
14085#[cfg(test)]
14086mod model_registration_trust_tests {
14087    use super::parse_user_registered_model_schema;
14088
14089    #[test]
14090    fn daemon_models_register_rejects_reserved_managed_aliases() {
14091        let schema = car_inference::openrouter::curated_schemas()
14092            .into_iter()
14093            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14094            .unwrap();
14095        let error =
14096            parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap_err();
14097        assert_eq!(
14098            error,
14099            "Parslee-managed OpenRouter aliases are reserved and cannot be registered"
14100        );
14101    }
14102
14103    #[test]
14104    fn daemon_models_register_forces_non_reserved_schema_to_community() {
14105        let mut schema = car_inference::openrouter::curated_schemas()
14106            .into_iter()
14107            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14108            .unwrap();
14109        schema.id = "user/nearby-model".into();
14110
14111        let registered =
14112            parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap();
14113        assert_eq!(registered.trust_tier, car_inference::TrustTier::Community);
14114    }
14115}
14116
14117/// `models.unregister` — remove an entry by id from the same state-root
14118/// `models.json` `models.register` writes (`~/.car/models.json` unless
14119/// `CAR_HOME` moves it; Parslee-ai/car#186 — symmetric to `models.register`).
14120/// Returns `{ id, unregistered, path }` on success. Returns an error
14121/// when the model isn't present.
14122///
14123/// **Phase 1 limitation** (same as `models.register`): the daemon's
14124/// live `UnifiedRegistry` is not rebuilt — the removal takes effect
14125/// on the next daemon boot. Callers SHOULD restart the daemon after
14126/// a batch of unregistrations if they expect `models.list_unified`
14127/// to reflect the change immediately.
14128async fn handle_models_unregister(
14129    req: &JsonRpcMessage,
14130    _state: &Arc<ServerState>,
14131) -> Result<Value, String> {
14132    // Params shape mirrors the CLI flag: `{ id: string }`. Bare-string
14133    // params are honored for symmetry with the register handler's
14134    // tolerant shape (`{schema: ...}` OR bare schema).
14135    let id = match req.params.get("id") {
14136        Some(v) => v
14137            .as_str()
14138            .ok_or_else(|| "`id` must be a string".to_string())?
14139            .to_string(),
14140        None => match req.params.as_str() {
14141            Some(s) => s.to_string(),
14142            None => return Err("missing `id` parameter".to_string()),
14143        },
14144    };
14145
14146    // Same resolver `models.register` and the registry use, so an unregister
14147    // finds the file a register just wrote even when `CAR_HOME` moved the root.
14148    let path = car_inference::registry::user_config_path()
14149        .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14150
14151    if !path.exists() {
14152        // Idempotent no-op: with no models.json the desired end-state (id not in
14153        // the user registry) already holds. The UI's Remove on a builtin-catalog
14154        // model that was never user-registered hit this as an alarming -32603
14155        // "no models.json — nothing to unregister" (PAR-7266). Report success.
14156        return Ok(serde_json::json!({
14157            "id": id,
14158            "unregistered": false,
14159            "note": "no models.json — nothing to unregister (already absent)",
14160        }));
14161    }
14162    let text =
14163        std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14164    let mut models: Vec<car_inference::ModelSchema> = if text.trim().is_empty() {
14165        Vec::new()
14166    } else {
14167        serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14168    };
14169    let before = models.len();
14170    models.retain(|m| m.id != id);
14171    if models.len() == before {
14172        // Not in the user registry (e.g. a builtin-catalog model the UI offered a
14173        // Remove button for) — idempotent no-op, not a UI-surfaced error (PAR-7266).
14174        return Ok(serde_json::json!({
14175            "id": id,
14176            "unregistered": false,
14177            "note": "model not in models.json — nothing to unregister",
14178        }));
14179    }
14180    let json =
14181        serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14182    let tmp = path.with_extension("json.tmp");
14183    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14184    std::fs::rename(&tmp, &path)
14185        .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14186    Ok(serde_json::json!({
14187        "id": id,
14188        "unregistered": true,
14189        "path": path.to_string_lossy(),
14190        "note": "Daemon restart required for live UnifiedRegistry visibility \
14191                 (phase 1, matching models.register).",
14192    }))
14193}
14194
14195fn handle_models_list(state: &ServerState) -> Result<Value, String> {
14196    let engine = get_inference_engine(state);
14197    let models = engine.list_models();
14198    serde_json::to_value(&models).map_err(|e| e.to_string())
14199}
14200
14201#[derive(Debug, Default, Deserialize)]
14202struct ModelsListUnifiedParams {
14203    #[serde(default)]
14204    refresh_openrouter_status: bool,
14205}
14206
14207async fn handle_models_list_unified(
14208    message: &JsonRpcMessage,
14209    state: &ServerState,
14210) -> Result<Value, String> {
14211    let params = if message.params.is_null() {
14212        ModelsListUnifiedParams::default()
14213    } else {
14214        typed_params(&message.params)?
14215    };
14216    if params.refresh_openrouter_status {
14217        let _ = car_inference::openrouter::refresh_credential_source_for_status().await;
14218    }
14219    let engine = get_inference_engine(state);
14220    let models = engine.list_models_unified();
14221    serde_json::to_value(&models).map_err(|e| e.to_string())
14222}
14223
14224#[cfg(test)]
14225mod models_list_unified_status_tests {
14226    use super::{handle_models_list_unified, JsonRpcMessage};
14227    use crate::session::{ServerState, ServerStateConfig};
14228    use serde_json::{json, Value};
14229    use std::sync::Arc;
14230
14231    #[tokio::test]
14232    async fn daemon_list_refreshes_openrouter_only_when_explicitly_requested() {
14233        const SENTINEL: &str = "CAR_SERVER_MODELS_LIST_STATUS_CHILD";
14234        if std::env::var_os(SENTINEL).is_none() {
14235            let status = std::process::Command::new(std::env::current_exe().unwrap())
14236                .arg("--exact")
14237                .arg(
14238                    "handler::models_list_unified_status_tests::daemon_list_refreshes_openrouter_only_when_explicitly_requested",
14239                )
14240                .arg("--nocapture")
14241                .arg("--test-threads=1")
14242                .env(SENTINEL, "1")
14243                .env_remove(car_home::ENV_VAR)
14244                .env_remove("CAR_SECRETS_FILE_DIR")
14245                .env_remove(car_inference::openrouter::API_KEY_ENV)
14246                .status()
14247                .expect("spawn isolated daemon models-list status test");
14248            assert!(status.success(), "isolated daemon models-list test failed");
14249            return;
14250        }
14251
14252        let fixture = tempfile::TempDir::new().unwrap();
14253        unsafe {
14254            std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
14255            std::env::set_var(
14256                "CAR_SECRETS_FILE_DIR",
14257                fixture.path().join("isolated-secrets"),
14258            );
14259            std::env::remove_var(car_inference::openrouter::API_KEY_ENV);
14260        }
14261        car_secrets::SecretStore::new()
14262            .put(
14263                &car_secrets::SecretRef::with_default_service(
14264                    car_inference::openrouter::API_KEY_ENV,
14265                ),
14266                "fixture-value",
14267            )
14268            .unwrap();
14269        let engine = Arc::new(car_inference::InferenceEngine::new(
14270            car_inference::InferenceConfig {
14271                state_root: fixture.path().join("state"),
14272                models_dir: fixture.path().join("models"),
14273                ..car_inference::InferenceConfig::default()
14274            },
14275        ));
14276        let state = ServerState::with_config(
14277            ServerStateConfig::new(fixture.path().join("journal")).with_inference(engine),
14278        );
14279        let request = |params| JsonRpcMessage {
14280            jsonrpc: "2.0".into(),
14281            method: Some("models.list_unified".into()),
14282            params,
14283            id: json!(1),
14284            result: None,
14285            error: None,
14286        };
14287
14288        assert_eq!(car_inference::openrouter::credential_source(), None);
14289        let before = car_secrets::secret_store_activity();
14290        let passive = handle_models_list_unified(&request(Value::Null), &state)
14291            .await
14292            .unwrap();
14293        let after_passive = car_secrets::secret_store_activity();
14294        assert_eq!(after_passive.status_attempts, before.status_attempts);
14295        assert_eq!(after_passive.get_attempts, before.get_attempts);
14296        let passive_openrouter = passive
14297            .as_array()
14298            .unwrap()
14299            .iter()
14300            .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14301            .collect::<Vec<_>>();
14302        assert!(!passive_openrouter.is_empty());
14303        assert!(passive_openrouter
14304            .iter()
14305            .all(|model| model["available"] == false));
14306
14307        let refreshed = handle_models_list_unified(
14308            &request(json!({ "refresh_openrouter_status": true })),
14309            &state,
14310        )
14311        .await
14312        .unwrap();
14313        let after_refresh = car_secrets::secret_store_activity();
14314        assert!(after_refresh.status_attempts > after_passive.status_attempts);
14315        assert_eq!(after_refresh.get_attempts, after_passive.get_attempts);
14316        let refreshed_openrouter = refreshed
14317            .as_array()
14318            .unwrap()
14319            .iter()
14320            .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14321            .collect::<Vec<_>>();
14322        assert!(!refreshed_openrouter.is_empty());
14323        assert!(refreshed_openrouter
14324            .iter()
14325            .all(|model| model["available"] == true));
14326    }
14327}
14328
14329fn handle_models_catalog_snapshot(
14330    session: &crate::session::ClientSession,
14331    state: &ServerState,
14332) -> Result<Value, String> {
14333    if !session_has_capability(session, car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY) {
14334        return Err(format!(
14335            "{} negotiate `{}` as a required or optional capability before calling `models.catalog_snapshot`",
14336            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
14337            car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY,
14338        ));
14339    }
14340    let engine = get_inference_engine(state);
14341    let snapshot = engine.catalog_snapshot()?;
14342    serde_json::to_value(snapshot).map_err(|error| error.to_string())
14343}
14344
14345#[derive(Debug, Default, Deserialize)]
14346#[serde(deny_unknown_fields)]
14347struct EmptyModelManagementParams {}
14348
14349#[derive(Debug, Deserialize)]
14350#[serde(deny_unknown_fields)]
14351struct ModelPreflightParams {
14352    model_id: String,
14353    #[serde(default)]
14354    context_tokens: usize,
14355}
14356
14357#[derive(Debug, Deserialize)]
14358#[serde(deny_unknown_fields)]
14359struct ModelIdParams {
14360    model_id: String,
14361}
14362
14363#[derive(Debug, Deserialize)]
14364#[serde(deny_unknown_fields)]
14365struct ModelPullParams {
14366    #[serde(alias = "id", alias = "model")]
14367    name: String,
14368}
14369
14370fn strict_model_params<T: serde::de::DeserializeOwned>(
14371    message: &JsonRpcMessage,
14372) -> Result<T, String> {
14373    let params = if message.params.is_null() {
14374        serde_json::json!({})
14375    } else {
14376        message.params.clone()
14377    };
14378    serde_json::from_value(params)
14379        .map_err(|error| format!("invalid model-management params: {error}"))
14380}
14381
14382fn handle_models_resource_policy_get(
14383    message: &JsonRpcMessage,
14384    state: &ServerState,
14385) -> Result<Value, String> {
14386    let _: EmptyModelManagementParams = strict_model_params(message)?;
14387    let engine = get_inference_engine(state);
14388    let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14389        engine.config.state_root.clone(),
14390    );
14391    let evidence = repository
14392        .load_with_evidence()
14393        .map_err(|error| error.to_string())?;
14394    let hardware = car_inference::HardwareInfo::detect();
14395    let evaluated_budget = evidence.policy.effective_budget(hardware.total_ram_mb);
14396    Ok(serde_json::json!({
14397        "policy": evidence.policy,
14398        "evaluated_budget": evaluated_budget,
14399        "hardware_total_mb": hardware.total_ram_mb,
14400        "source": evidence.source,
14401        "warning": evidence.warning,
14402    }))
14403}
14404
14405fn handle_models_resource_policy_set(
14406    message: &JsonRpcMessage,
14407    session: &crate::session::ClientSession,
14408    state: &ServerState,
14409) -> Result<Value, String> {
14410    require_approval_authority(session, state)?;
14411    let policy: car_inference::resource_policy::ResourcePolicy = strict_model_params(message)?;
14412    policy.validate().map_err(|error| error.to_string())?;
14413    let engine = get_inference_engine(state);
14414    let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14415        engine.config.state_root.clone(),
14416    );
14417    car_inference::resource_policy::ResourcePolicyRepository::save(&repository, &policy)
14418        .map_err(|error| error.to_string())?;
14419    engine.apply_local_resource_policy(policy.clone());
14420    let hardware = car_inference::HardwareInfo::detect();
14421    let evaluated_budget = policy.effective_budget(hardware.total_ram_mb);
14422    Ok(serde_json::json!({
14423        "policy": policy,
14424        "evaluated_budget": evaluated_budget,
14425        "hardware_total_mb": hardware.total_ram_mb,
14426        "notice": evaluated_budget.normalization_notice,
14427    }))
14428}
14429
14430fn handle_models_preflight(message: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14431    let params: ModelPreflightParams = strict_model_params(message)?;
14432    let preflight = get_inference_engine(state)
14433        .local_model_preflight(&params.model_id, params.context_tokens)
14434        .map_err(|error| error.to_string())?;
14435    serde_json::to_value(preflight).map_err(|error| error.to_string())
14436}
14437
14438fn handle_models_storage_roots(
14439    message: &JsonRpcMessage,
14440    session: &crate::session::ClientSession,
14441    state: &ServerState,
14442) -> Result<Value, String> {
14443    require_approval_authority(session, state)?;
14444    let _: EmptyModelManagementParams = strict_model_params(message)?;
14445    let engine = get_inference_engine(state);
14446    let store = engine.model_management_store();
14447    let hf_home = std::env::var_os("HF_HOME")
14448        .map(std::path::PathBuf::from)
14449        .or_else(|| {
14450            std::env::var_os("HOME")
14451                .or_else(|| std::env::var_os("USERPROFILE"))
14452                .map(|home| std::path::PathBuf::from(home).join(".cache/huggingface"))
14453        })
14454        .unwrap_or_else(|| std::path::PathBuf::from(".cache/huggingface"));
14455    let normalize = car_inference::resource_policy::normalized_state_root_key;
14456    Ok(serde_json::json!({
14457        "state_root": normalize(&engine.config.state_root),
14458        "models_dir": normalize(&engine.config.models_dir),
14459        "hf_home": normalize(&hf_home),
14460        "hf_hub": normalize(&hf_home.join("hub")),
14461        "install_receipts_dir": normalize(store.receipts_root()),
14462        "management_state_dir": normalize(store.management_state_dir()),
14463    }))
14464}
14465
14466async fn handle_models_remove(
14467    message: &JsonRpcMessage,
14468    session: &crate::session::ClientSession,
14469    state: &ServerState,
14470) -> Result<Value, String> {
14471    require_approval_authority(session, state)?;
14472    let params: ModelIdParams = strict_model_params(message)?;
14473    let engine = Arc::clone(get_inference_engine(state));
14474    let operation = state
14475        .spawn_durable_operation("models.remove", async move {
14476            let result = engine
14477                .remove_model_from_car(&params.model_id)
14478                .await
14479                .map_err(|error| error.to_string())?;
14480            Ok(serde_json::json!({
14481                "model_id": result.model_id,
14482                "removed_from_car": true,
14483                "artifact_kind": result.artifact_kind,
14484                "shared_cache_preserved": true,
14485            }))
14486        })
14487        .await;
14488    operation.await.map_err(|_| {
14489        "daemon-owned models.remove operation stopped before publishing a result".to_string()
14490    })?
14491}
14492
14493async fn handle_models_adopt(
14494    message: &JsonRpcMessage,
14495    session: &crate::session::ClientSession,
14496    state: &ServerState,
14497) -> Result<Value, String> {
14498    require_approval_authority(session, state)?;
14499    let params: ModelIdParams = strict_model_params(message)?;
14500    let engine = Arc::clone(get_inference_engine(state));
14501    let operation = state
14502        .spawn_durable_operation("models.adopt", async move {
14503            let receipt = engine
14504                .adopt_model_into_car(&params.model_id)
14505                .await
14506                .map_err(|error| error.to_string())?;
14507            Ok(serde_json::json!({
14508                "model_id": receipt.model_id,
14509                "adopted": true,
14510                "can_remove": true,
14511            }))
14512        })
14513        .await;
14514    operation.await.map_err(|_| {
14515        "daemon-owned models.adopt operation stopped before publishing a result".to_string()
14516    })?
14517}
14518
14519/// Return the runtime-resolved, non-secret gateway base for one canonical
14520/// Parslee-managed OpenRouter alias. This is intentionally a separate,
14521/// authenticated read: `models.list_unified` remains the public catalog view
14522/// and never publishes routing authorities for every model. The registry must
14523/// already be initialized explicitly; provenance never triggers engine setup.
14524async fn handle_models_route_provenance(
14525    req: &JsonRpcMessage,
14526    state: &ServerState,
14527) -> Result<Value, String> {
14528    let id = require_str(&req.params, "id")?;
14529    let engine = state.inference.get().ok_or_else(|| {
14530        "models.route_provenance requires an initialized model registry; \
14531         call `models.list_unified` to initialize it, then retry"
14532            .to_string()
14533    })?;
14534    let schema = engine
14535        .registered_schema(id)
14536        .ok_or_else(|| format!("unknown model id `{id}`"))?;
14537    let canonical_id = canonical_managed_route_provenance_id(&schema)?;
14538
14539    let (endpoint, authority) = resolved_managed_parslee_base().await?;
14540    serde_json::to_value(ManagedRouteProvenance {
14541        id: canonical_id,
14542        provider: "parslee",
14543        source: "proprietary_oauth_pkce",
14544        is_local: false,
14545        endpoint,
14546        authority,
14547    })
14548    .map_err(|error| error.to_string())
14549}
14550
14551/// Keep provenance tied to the actual registered row (including a valid signed
14552/// catalog replacement), using the same managed transport predicate inference
14553/// uses for outbound requests.
14554fn canonical_managed_route_provenance_id(
14555    schema: &car_inference::ModelSchema,
14556) -> Result<&str, String> {
14557    let Some(id) = car_inference::openrouter::canonical_managed_gateway_selector(schema) else {
14558        return Err(
14559            "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14560                .into(),
14561        );
14562    };
14563    Ok(id)
14564}
14565
14566#[derive(Debug, Serialize)]
14567struct ManagedRouteProvenance<'a> {
14568    id: &'a str,
14569    provider: &'static str,
14570    source: &'static str,
14571    is_local: bool,
14572    endpoint: String,
14573    authority: String,
14574}
14575
14576/// Resolve through the exact runtime source used by Parslee inference, then
14577/// emit only its non-secret HTTP(S) origin. With no process override,
14578/// `car_auth::api_base` performs one authoritative V2 secret-store read, which
14579/// may invoke a platform credential helper. Keep that synchronous local I/O off
14580/// Tokio's worker threads; the lookup neither refreshes, writes, nor contacts
14581/// the resolved base.
14582async fn resolved_managed_parslee_base() -> Result<(String, String), String> {
14583    let api_base = tokio::task::spawn_blocking(|| car_auth::api_base(None))
14584        .await
14585        .map_err(|error| format!("managed inference API base worker failed: {error}"))?;
14586    redact_resolved_managed_api_base(&api_base)
14587}
14588
14589fn redact_resolved_managed_api_base(api_base: &str) -> Result<(String, String), String> {
14590    let url = reqwest::Url::parse(api_base)
14591        .map_err(|_| "invalid managed inference API base".to_string())?;
14592    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
14593        return Err("invalid managed inference API base".into());
14594    }
14595
14596    let authority = url.origin().ascii_serialization();
14597    Ok((authority.clone(), authority))
14598}
14599
14600#[cfg(test)]
14601mod managed_route_provenance_tests {
14602    use super::{canonical_managed_route_provenance_id, redact_resolved_managed_api_base};
14603    use car_inference::openrouter::{
14604        canonical_managed_gateway_selector, curated_schemas, is_curated_managed_gateway_alias,
14605    };
14606
14607    #[test]
14608    fn managed_route_provenance_redacts_the_runtime_resolved_base_without_refresh() {
14609        assert_eq!(
14610            redact_resolved_managed_api_base("https://persisted.example/").unwrap(),
14611            (
14612                "https://persisted.example".into(),
14613                "https://persisted.example".into()
14614            )
14615        );
14616    }
14617
14618    #[test]
14619    fn managed_route_provenance_normalizes_and_redacts_url_components() {
14620        assert_eq!(
14621            redact_resolved_managed_api_base("HTTPS://Staging-Api.Example:443/").unwrap(),
14622            (
14623                "https://staging-api.example".into(),
14624                "https://staging-api.example".into()
14625            )
14626        );
14627        for (base, expected) in [
14628            ("https://gateway.example/prefix", "https://gateway.example"),
14629            (
14630                "https://user:sensitive@gateway.example/private?token=value#fragment",
14631                "https://gateway.example",
14632            ),
14633        ] {
14634            assert_eq!(
14635                redact_resolved_managed_api_base(base).unwrap(),
14636                (expected.into(), expected.into())
14637            );
14638        }
14639        for invalid in [
14640            "ftp://gateway.example",
14641            "mailto:ops@example.com",
14642            "https://",
14643        ] {
14644            let error = redact_resolved_managed_api_base(invalid).unwrap_err();
14645            assert_eq!(error, "invalid managed inference API base");
14646        }
14647    }
14648
14649    #[test]
14650    fn managed_route_provenance_accepts_only_canonical_managed_aliases() {
14651        assert!(is_curated_managed_gateway_alias(
14652            "parslee/openrouter/frontier-general"
14653        ));
14654        assert!(!is_curated_managed_gateway_alias(
14655            "openrouter/openai/gpt-5.4"
14656        ));
14657        assert!(!is_curated_managed_gateway_alias("parslee/not-managed"));
14658
14659        let schemas = curated_schemas();
14660        let managed = schemas
14661            .iter()
14662            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14663            .unwrap();
14664        assert_eq!(
14665            canonical_managed_gateway_selector(managed),
14666            Some("parslee/openrouter/frontier-general")
14667        );
14668
14669        let personal = schemas
14670            .iter()
14671            .find(|schema| schema.id == "openrouter/openai/gpt-5.4")
14672            .unwrap();
14673        assert!(canonical_managed_gateway_selector(personal).is_none());
14674
14675        let mut non_parslee = managed.clone();
14676        non_parslee.provider = "other".into();
14677        assert!(canonical_managed_gateway_selector(&non_parslee).is_none());
14678
14679        let mut noncanonical_id = managed.clone();
14680        noncanonical_id.id = "parslee/not-managed".into();
14681        assert!(canonical_managed_gateway_selector(&noncanonical_id).is_none());
14682    }
14683
14684    #[test]
14685    fn managed_route_provenance_uses_live_signed_transport_contract() {
14686        let mut signed_override = curated_schemas()
14687            .into_iter()
14688            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14689            .unwrap();
14690        signed_override.name = "signed-catalog-display-metadata-drift".into();
14691        assert_eq!(
14692            canonical_managed_route_provenance_id(&signed_override),
14693            Ok("parslee/openrouter/frontier-general")
14694        );
14695
14696        signed_override.provider = "not-parslee".into();
14697        assert_eq!(
14698            canonical_managed_route_provenance_id(&signed_override).unwrap_err(),
14699            "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14700        );
14701    }
14702}
14703
14704#[derive(Debug, Deserialize)]
14705#[serde(rename_all = "camelCase")]
14706struct ModelSearchParams {
14707    #[serde(default)]
14708    query: Option<String>,
14709    #[serde(default)]
14710    capability: Option<car_inference::ModelCapability>,
14711    #[serde(default)]
14712    provider: Option<String>,
14713    #[serde(default)]
14714    local_only: bool,
14715    #[serde(default)]
14716    available_only: bool,
14717    #[serde(default)]
14718    limit: Option<usize>,
14719}
14720
14721#[derive(Debug, Serialize)]
14722#[serde(rename_all = "camelCase")]
14723struct ModelSearchEntry {
14724    /// Carries `family` and `version` for EVERY row (the search view is the
14725    /// documented place that names an alias's upstream family), where the
14726    /// flattened `ModelInfo` publishes them for local rows only. The search
14727    /// handler overwrites both on the info before wrapping it.
14728    #[serde(flatten)]
14729    info: car_inference::ModelInfo,
14730    tags: Vec<String>,
14731    pullable: bool,
14732    upgrade: Option<car_inference::ModelUpgrade>,
14733}
14734
14735#[derive(Debug, Serialize)]
14736#[serde(rename_all = "camelCase")]
14737struct ModelSearchResponse {
14738    models: Vec<ModelSearchEntry>,
14739    upgrades: Vec<car_inference::ModelUpgrade>,
14740    total: usize,
14741    available: usize,
14742    local: usize,
14743    remote: usize,
14744}
14745
14746async fn handle_models_search(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14747    let params: ModelSearchParams =
14748        serde_json::from_value(req.params.clone()).unwrap_or(ModelSearchParams {
14749            query: None,
14750            capability: None,
14751            provider: None,
14752            local_only: false,
14753            available_only: false,
14754            limit: None,
14755        });
14756    let engine = get_inference_engine(state);
14757    let upgrades = engine.available_model_upgrades();
14758    let upgrades_by_from: HashMap<String, car_inference::ModelUpgrade> = upgrades
14759        .iter()
14760        .cloned()
14761        .map(|upgrade| (upgrade.from_id.clone(), upgrade))
14762        .collect();
14763    let query = params
14764        .query
14765        .as_deref()
14766        .map(str::trim)
14767        .filter(|q| !q.is_empty())
14768        .map(|q| q.to_ascii_lowercase());
14769    let provider = params
14770        .provider
14771        .as_deref()
14772        .map(str::trim)
14773        .filter(|p| !p.is_empty())
14774        .map(|p| p.to_ascii_lowercase());
14775
14776    let mut entries: Vec<ModelSearchEntry> = engine
14777        .list_schemas()
14778        .into_iter()
14779        .filter(|schema| {
14780            if let Some(capability) = params.capability {
14781                if !schema.has_capability(capability) {
14782                    return false;
14783                }
14784            }
14785            if let Some(provider) = provider.as_deref() {
14786                if schema.provider.to_ascii_lowercase() != provider {
14787                    return false;
14788                }
14789            }
14790            if params.local_only && !schema.is_local() {
14791                return false;
14792            }
14793            if params.available_only && !schema.available {
14794                return false;
14795            }
14796            if let Some(query) = query.as_deref() {
14797                let capability_text = schema
14798                    .capabilities
14799                    .iter()
14800                    .map(|cap| format!("{cap:?}").to_ascii_lowercase())
14801                    .collect::<Vec<_>>()
14802                    .join(" ");
14803                let haystack = format!(
14804                    "{} {} {} {} {} {}",
14805                    schema.id,
14806                    schema.name,
14807                    schema.provider,
14808                    schema.family,
14809                    schema.tags.join(" "),
14810                    capability_text
14811                )
14812                .to_ascii_lowercase();
14813                if !haystack.contains(query) {
14814                    return false;
14815                }
14816            }
14817            true
14818        })
14819        .map(|schema| {
14820            let pullable = model_is_pullable(&schema);
14821            let mut info =
14822                car_inference::ModelInfo::from(&schema).with_fit(engine.model_fit(&schema));
14823            info.family = Some(schema.family);
14824            info.version = Some(schema.version);
14825            let upgrade = upgrades_by_from.get(&schema.id).cloned();
14826            ModelSearchEntry {
14827                info,
14828                tags: schema.tags,
14829                pullable,
14830                upgrade,
14831            }
14832        })
14833        .collect();
14834    entries.sort_by(|a, b| {
14835        b.info
14836            .available
14837            .cmp(&a.info.available)
14838            .then(b.info.is_local.cmp(&a.info.is_local))
14839            .then(a.info.name.cmp(&b.info.name))
14840    });
14841    if let Some(limit) = params.limit {
14842        entries.truncate(limit);
14843    }
14844
14845    let total = entries.len();
14846    let available = entries.iter().filter(|entry| entry.info.available).count();
14847    let local = entries.iter().filter(|entry| entry.info.is_local).count();
14848    let response = ModelSearchResponse {
14849        models: entries,
14850        upgrades,
14851        total,
14852        available,
14853        local,
14854        remote: total.saturating_sub(local),
14855    };
14856    serde_json::to_value(response).map_err(|e| e.to_string())
14857}
14858
14859fn model_is_pullable(schema: &car_inference::ModelSchema) -> bool {
14860    schema.downloads_weights() && !schema.weights_ready
14861}
14862
14863/// Parse an optional serde enum param: absent/null → `None` (caller defaults);
14864/// present-but-unparseable → a clear invalid-params error rather than a silent
14865/// fallback, so a host app learns it sent a bad value.
14866fn optional_enum_param<T: serde::de::DeserializeOwned>(
14867    req: &JsonRpcMessage,
14868    key: &str,
14869) -> Result<Option<T>, String> {
14870    match req.params.get(key) {
14871        None | Some(Value::Null) => Ok(None),
14872        Some(v) => serde_json::from_value(v.clone())
14873            .map(Some)
14874            .map_err(|_| format!("invalid '{key}': {v}")),
14875    }
14876}
14877
14878/// Parse `use_case`/`tier`/`cloud_ok` from JSON-RPC params and run the
14879/// recommender. `HardwareInfo::detect()` runs per call — acceptable because
14880/// recommend/setup_plan are onboarding-frequency (not the inference hot path),
14881/// and detection is a couple of cheap, non-blocking system probes.
14882fn recommend_from_params(
14883    req: &JsonRpcMessage,
14884    engine: &car_inference::InferenceEngine,
14885) -> Result<car_inference::RecommendationSet, String> {
14886    let hw = car_inference::HardwareInfo::detect();
14887    recommend_from_params_with_hardware(req, engine, &hw)
14888}
14889
14890fn recommend_from_params_with_hardware(
14891    req: &JsonRpcMessage,
14892    engine: &car_inference::InferenceEngine,
14893    hw: &car_inference::HardwareInfo,
14894) -> Result<car_inference::RecommendationSet, String> {
14895    let schemas = engine.list_schemas();
14896    let policy = engine.active_local_resource_policy();
14897    recommend_from_params_with_policy(req, &schemas, hw, &policy.policy, policy.warning)
14898}
14899
14900/// Dependency-injected core for recommendation-policy fallback tests. Keeping
14901/// hardware, registry rows, and policy storage explicit makes corrupt/missing
14902/// file behavior testable without touching a developer's machine state.
14903#[cfg(test)]
14904fn recommend_from_params_with_inputs(
14905    req: &JsonRpcMessage,
14906    schemas: &[car_inference::ModelSchema],
14907    hw: &car_inference::HardwareInfo,
14908    policy_repository: &car_inference::FileResourcePolicyRepository,
14909) -> Result<car_inference::RecommendationSet, String> {
14910    let (policy, _, warning) = load_recommendation_policy(policy_repository);
14911    recommend_from_params_with_policy(req, schemas, hw, &policy, warning)
14912}
14913
14914#[cfg(test)]
14915fn load_recommendation_policy(
14916    policy_repository: &car_inference::FileResourcePolicyRepository,
14917) -> (
14918    car_inference::ResourcePolicy,
14919    car_inference::ResourcePolicyLoadSource,
14920    Option<String>,
14921) {
14922    match policy_repository.load_with_evidence() {
14923        Ok(evidence) => (evidence.policy, evidence.source, evidence.warning),
14924        Err(error) => (
14925            car_inference::ResourcePolicy::everyday(),
14926            car_inference::ResourcePolicyLoadSource::CorruptDefault,
14927            Some(format!(
14928                "The local-model resource policy could not be read ({error}); CAR used Everyday."
14929            )),
14930        ),
14931    }
14932}
14933
14934fn recommend_from_params_with_policy(
14935    req: &JsonRpcMessage,
14936    schemas: &[car_inference::ModelSchema],
14937    hw: &car_inference::HardwareInfo,
14938    policy: &car_inference::ResourcePolicy,
14939    warning: Option<String>,
14940) -> Result<car_inference::RecommendationSet, String> {
14941    let use_case =
14942        optional_enum_param::<car_inference::UseCase>(req, "use_case")?.unwrap_or_default();
14943    let tier = optional_enum_param::<car_inference::QualityTier>(req, "tier")?.unwrap_or_default();
14944    let privacy = if req
14945        .params
14946        .get("cloud_ok")
14947        .and_then(|v| v.as_bool())
14948        .unwrap_or(false)
14949    {
14950        car_inference::Privacy::CloudOk
14951    } else {
14952        car_inference::Privacy::OnDevice
14953    };
14954    let refs: Vec<&car_inference::ModelSchema> = schemas.iter().collect();
14955    let mut set = car_inference::recommend_with_policy(&refs, hw, policy, use_case, tier, privacy);
14956    if let Some(warning) = warning {
14957        set.note = Some(match set.note.take() {
14958            Some(note) => format!("Resource policy warning: {warning} {note}"),
14959            None => format!("Resource policy warning: {warning}"),
14960        });
14961    }
14962    Ok(set)
14963}
14964
14965/// `models.recommend` — ranked, explained model picks for this machine + intent.
14966fn handle_models_recommend(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14967    let engine = get_inference_engine(state);
14968    let set = recommend_from_params(req, engine)?;
14969    serde_json::to_value(set).map_err(|e| e.to_string())
14970}
14971
14972/// `models.setup_plan` — a concrete onboarding plan the host/SDK can present:
14973/// machine description, the top pick, alternatives, and what needs more memory.
14974fn handle_models_setup_plan(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14975    let engine = get_inference_engine(state);
14976    let hw = car_inference::HardwareInfo::detect();
14977    let schemas = engine.list_schemas();
14978    let policy = engine.active_local_resource_policy();
14979    setup_plan_from_params_with_policy_inputs(
14980        req,
14981        &schemas,
14982        &hw,
14983        &policy.policy,
14984        policy.source,
14985        policy.warning,
14986    )
14987}
14988
14989#[cfg(test)]
14990fn setup_plan_from_params_with_inputs(
14991    req: &JsonRpcMessage,
14992    schemas: &[car_inference::ModelSchema],
14993    hw: &car_inference::HardwareInfo,
14994    policy_repository: &car_inference::FileResourcePolicyRepository,
14995) -> Result<Value, String> {
14996    let (policy, source, warning) = load_recommendation_policy(policy_repository);
14997    setup_plan_from_params_with_policy_inputs(req, schemas, hw, &policy, source, warning)
14998}
14999
15000fn setup_plan_from_params_with_policy_inputs(
15001    req: &JsonRpcMessage,
15002    schemas: &[car_inference::ModelSchema],
15003    hw: &car_inference::HardwareInfo,
15004    active_policy: &car_inference::ResourcePolicy,
15005    active_policy_source: car_inference::ResourcePolicyLoadSource,
15006    active_policy_warning: Option<String>,
15007) -> Result<Value, String> {
15008    let preview_policy = match req.params.get("resource_policy") {
15009        None => None,
15010        Some(value) => {
15011            let policy: car_inference::ResourcePolicy = serde_json::from_value(value.clone())
15012                .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15013            policy
15014                .validate()
15015                .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15016            Some(policy)
15017        }
15018    };
15019    let (policy, policy_source, warning) = match preview_policy {
15020        Some(policy) => (policy, serde_json::json!("preview"), None),
15021        None => (
15022            active_policy.clone(),
15023            serde_json::json!(active_policy_source),
15024            active_policy_warning,
15025        ),
15026    };
15027    let evaluated_budget = policy.effective_budget(hw.total_ram_mb);
15028    let mut set = recommend_from_params_with_policy(req, schemas, hw, &policy, warning)?;
15029    let (recommended, alternatives) = split_setup_recommendations(set.picks);
15030    if recommended.is_none() {
15031        let notice = "No model is within CAR's automatic recommendation target; heavier or unknown-memory alternatives require an explicit choice.";
15032        set.note = Some(match set.note.take() {
15033            Some(note) => format!("{note} {notice}"),
15034            None => notice.into(),
15035        });
15036    }
15037    serde_json::to_value(serde_json::json!({
15038        "machine": describe_machine_for_plan(hw),
15039        "recommended": recommended,
15040        "alternatives": alternatives,
15041        "needs_more_memory": set.not_enough_memory,
15042        "note": set.note,
15043        "resource_policy": policy,
15044        "evaluated_budget": evaluated_budget,
15045        "policy_source": policy_source,
15046    }))
15047    .map_err(|e| e.to_string())
15048}
15049
15050fn split_setup_recommendations(
15051    mut picks: Vec<car_inference::Recommendation>,
15052) -> (
15053    Option<car_inference::Recommendation>,
15054    Vec<car_inference::Recommendation>,
15055) {
15056    let recommended_index = picks
15057        .iter()
15058        .position(|pick| pick.within_recommendation_target);
15059    let recommended = recommended_index.map(|index| picks.remove(index));
15060    (recommended, picks)
15061}
15062
15063/// Plain-language one-liner about the host machine for setup plans.
15064fn describe_machine_for_plan(hw: &car_inference::HardwareInfo) -> String {
15065    use car_inference::hardware::SupportedAcceleration::*;
15066    match hw.supported_acceleration() {
15067        Apple { unified_memory_mb } => format!(
15068            "Apple Silicon, {} GB unified memory (Metal)",
15069            unified_memory_mb / 1024
15070        ),
15071        Cuda { device_memory_mb } => match device_memory_mb {
15072            Some(mb) => format!("NVIDIA GPU, {} GB VRAM (CUDA)", mb / 1024),
15073            None => "NVIDIA GPU (CUDA)".to_string(),
15074        },
15075        UnsupportedDiscreteGpu { name, .. } => format!(
15076            "{} GB RAM, CPU inference ({name} not yet supported)",
15077            hw.total_ram_mb / 1024
15078        ),
15079        Cpu => format!("{} GB RAM, CPU inference", hw.total_ram_mb / 1024),
15080    }
15081}
15082
15083#[cfg(test)]
15084mod model_resource_policy_recommendation_tests {
15085    use super::{
15086        model_is_pullable, recommend_from_params_with_inputs, setup_plan_from_params_with_inputs,
15087        split_setup_recommendations, JsonRpcMessage,
15088    };
15089    use car_inference::hardware::GpuBackend;
15090    use car_inference::{
15091        FileResourcePolicyRepository, HardwareInfo, ModelSchema, ResourcePolicy,
15092        ResourcePolicyRepository,
15093    };
15094    use serde_json::json;
15095
15096    fn request() -> JsonRpcMessage {
15097        JsonRpcMessage {
15098            jsonrpc: "2.0".into(),
15099            method: Some("models.recommend".into()),
15100            params: json!({"use_case": "assistant", "tier": "balanced"}),
15101            id: json!(1),
15102            result: None,
15103            error: None,
15104        }
15105    }
15106
15107    fn mac_32gb() -> HardwareInfo {
15108        HardwareInfo {
15109            os: "macos".into(),
15110            arch: "aarch64".into(),
15111            cpu_cores: 10,
15112            total_ram_mb: 32 * 1024,
15113            gpu_backend: GpuBackend::Metal,
15114            gpu_memory_mb: None,
15115            gpu_devices: vec![],
15116            recommended_model: String::new(),
15117            recommended_context: 8_192,
15118            max_model_mb: 0,
15119        }
15120    }
15121
15122    fn setup_schemas(home: &tempfile::TempDir) -> Vec<ModelSchema> {
15123        let models_dir = home.path().join("weights");
15124        let huggingface_hub_root = home.path().join("huggingface-hub");
15125        std::fs::create_dir_all(&huggingface_hub_root).unwrap();
15126        car_inference::registry::builtin_catalog_with_huggingface_hub_for_testing(
15127            &models_dir,
15128            &huggingface_hub_root,
15129        )
15130        .into_iter()
15131        .filter(|model| matches!(model.id.as_str(), "mlx/qwen3-4b:4bit" | "mlx/qwen3-8b:4bit"))
15132        .collect()
15133    }
15134
15135    fn setup_request(params: serde_json::Value) -> JsonRpcMessage {
15136        JsonRpcMessage {
15137            jsonrpc: "2.0".into(),
15138            method: Some("models.setup_plan".into()),
15139            params,
15140            id: json!(1),
15141            result: None,
15142            error: None,
15143        }
15144    }
15145
15146    #[test]
15147    fn setup_plan_preview_custom_zero_changes_eligibility_without_persisting() {
15148        let home = tempfile::tempdir().unwrap();
15149        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15150        repository.save(&ResourcePolicy::everyday()).unwrap();
15151        let policy_path = repository.path();
15152        let saved_bytes = std::fs::read(&policy_path).unwrap();
15153        let schemas = setup_schemas(&home);
15154
15155        // The preview core intentionally has no engine/mutation dependency.
15156        let preview = setup_plan_from_params_with_inputs(
15157            &setup_request(json!({
15158                "use_case": "assistant",
15159                "tier": "balanced",
15160                "resource_policy": {
15161                    "profile": "custom",
15162                    "custom_max_model_mb": 0
15163                }
15164            })),
15165            &schemas,
15166            &mac_32gb(),
15167            &repository,
15168        )
15169        .unwrap();
15170
15171        assert_eq!(preview["resource_policy"]["profile"], "custom");
15172        assert_eq!(preview["resource_policy"]["custom_max_model_mb"], 0);
15173        assert_eq!(
15174            preview["evaluated_budget"]["configured_model_ceiling_mb"],
15175            0
15176        );
15177        assert_eq!(
15178            preview["evaluated_budget"]["effective_new_load_ceiling_mb"],
15179            0
15180        );
15181        assert_eq!(preview["policy_source"], "preview");
15182        assert!(preview["recommended"].is_null());
15183        assert_eq!(preview["alternatives"].as_array().unwrap().len(), 0);
15184        assert_eq!(preview["needs_more_memory"].as_array().unwrap().len(), 2);
15185
15186        assert_eq!(std::fs::read(&policy_path).unwrap(), saved_bytes);
15187        let fresh_repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15188        assert_eq!(fresh_repository.load().unwrap(), ResourcePolicy::everyday());
15189
15190        let persisted = setup_plan_from_params_with_inputs(
15191            &setup_request(json!({"use_case":"assistant","tier":"balanced"})),
15192            &schemas,
15193            &mac_32gb(),
15194            &fresh_repository,
15195        )
15196        .unwrap();
15197        assert_eq!(persisted["policy_source"], "loaded");
15198        assert_eq!(persisted["resource_policy"]["profile"], "everyday");
15199        assert_eq!(persisted["recommended"]["model_id"], "mlx/qwen3-4b:4bit");
15200        assert!(persisted["alternatives"]
15201            .as_array()
15202            .unwrap()
15203            .iter()
15204            .any(|pick| pick["model_id"] == "mlx/qwen3-8b:4bit"));
15205    }
15206
15207    #[test]
15208    fn setup_plan_preview_preserves_exact_custom_ten_and_a_half_gb() {
15209        let home = tempfile::tempdir().unwrap();
15210        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15211        repository.save(&ResourcePolicy::everyday()).unwrap();
15212
15213        let plan = setup_plan_from_params_with_inputs(
15214            &setup_request(json!({
15215                "resource_policy": {
15216                    "profile": "custom",
15217                    "custom_max_model_mb": 10_752
15218                }
15219            })),
15220            &setup_schemas(&home),
15221            &mac_32gb(),
15222            &repository,
15223        )
15224        .unwrap();
15225
15226        assert_eq!(plan["resource_policy"]["custom_max_model_mb"], 10_752);
15227        assert_eq!(
15228            plan["evaluated_budget"]["configured_model_ceiling_mb"],
15229            10_752
15230        );
15231        assert_eq!(
15232            plan["evaluated_budget"]["effective_new_load_ceiling_mb"],
15233            10_752
15234        );
15235        assert_eq!(repository.load().unwrap(), ResourcePolicy::everyday());
15236    }
15237
15238    #[test]
15239    fn setup_plan_preview_rejects_invalid_policy_shapes() {
15240        let home = tempfile::tempdir().unwrap();
15241        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15242        let schemas = setup_schemas(&home);
15243        let invalid = [
15244            json!({"profile":"custom","custom_max_model_mb":10_752,"unknown":true}),
15245            json!({"profile":"custom"}),
15246            json!({"profile":"everyday","custom_max_model_mb":512}),
15247            json!({"profile":"custom","custom_max_model_mb":10_547}),
15248            json!({"profile":"custom","custom_max_model_mb":-512}),
15249            json!({"profile":"custom","custom_max_model_mb":10_752.0}),
15250            serde_json::Value::Null,
15251            json!("custom"),
15252            json!([]),
15253            json!(true),
15254        ];
15255
15256        for resource_policy in invalid {
15257            let error = setup_plan_from_params_with_inputs(
15258                &setup_request(json!({"resource_policy": resource_policy.clone()})),
15259                &schemas,
15260                &mac_32gb(),
15261                &repository,
15262            )
15263            .expect_err(&format!(
15264                "invalid preview must fail closed: {resource_policy}"
15265            ));
15266            assert!(
15267                error.contains("invalid 'resource_policy'"),
15268                "unexpected error for {resource_policy}: {error}"
15269            );
15270        }
15271    }
15272
15273    #[test]
15274    fn setup_plan_without_preview_keeps_corrupt_policy_warning_and_everyday_fallback() {
15275        let home = tempfile::tempdir().unwrap();
15276        std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15277        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15278
15279        let plan = setup_plan_from_params_with_inputs(
15280            &setup_request(json!({})),
15281            &setup_schemas(&home),
15282            &mac_32gb(),
15283            &repository,
15284        )
15285        .unwrap();
15286
15287        assert_eq!(plan["policy_source"], "corrupt_default");
15288        assert_eq!(
15289            plan["resource_policy"],
15290            json!({
15291                "profile": "everyday",
15292                "custom_max_model_mb": null
15293            })
15294        );
15295        assert_eq!(
15296            plan["evaluated_budget"]["configured_model_ceiling_mb"],
15297            13_107
15298        );
15299        assert!(plan["note"]
15300            .as_str()
15301            .is_some_and(|note| note.contains("saved resource policy could not be loaded")));
15302    }
15303
15304    #[test]
15305    fn isolated_car_home_everyday_policy_recommends_real_4b_and_keeps_8b_heavier() {
15306        let home = tempfile::tempdir().unwrap();
15307        let schemas = setup_schemas(&home);
15308        assert_eq!(schemas.len(), 2, "real built-in 4B/8B rows must exist");
15309
15310        let set = recommend_from_params_with_inputs(
15311            &request(),
15312            &schemas,
15313            &mac_32gb(),
15314            &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15315        )
15316        .unwrap();
15317
15318        assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
15319        assert!(set.picks[0].within_recommendation_target);
15320        let eight = set
15321            .picks
15322            .iter()
15323            .find(|pick| pick.model_id == "mlx/qwen3-8b:4bit")
15324            .expect("8B remains visible as a heavier alternative");
15325        assert_eq!(eight.display_name, "Qwen3-8B-MLX");
15326        let mut over_target = eight.clone();
15327        over_target.within_recommendation_target = false;
15328        let (automatic, visible) = split_setup_recommendations(vec![over_target]);
15329        assert!(automatic.is_none());
15330        assert_eq!(visible.len(), 1);
15331
15332        let (recommended, alternatives) = split_setup_recommendations(set.picks);
15333        assert_eq!(recommended.unwrap().model_id, "mlx/qwen3-4b:4bit");
15334        assert!(alternatives
15335            .iter()
15336            .any(|pick| pick.model_id == "mlx/qwen3-8b:4bit"));
15337    }
15338
15339    #[test]
15340    fn corrupt_policy_is_visible_in_recommendation_notice() {
15341        let home = tempfile::tempdir().unwrap();
15342        std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15343        let schemas: Vec<ModelSchema> = setup_schemas(&home)
15344            .into_iter()
15345            .filter(|model| model.id == "mlx/qwen3-4b:4bit")
15346            .collect();
15347
15348        let set = recommend_from_params_with_inputs(
15349            &request(),
15350            &schemas,
15351            &mac_32gb(),
15352            &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15353        )
15354        .unwrap();
15355        assert!(
15356            set.note
15357                .as_deref()
15358                .is_some_and(|note| note.contains("saved resource policy could not be loaded")),
15359            "corrupt policy recovery must remain visible: {:?}",
15360            set.note
15361        );
15362    }
15363
15364    #[test]
15365    fn routable_mlx_model_without_weights_remains_pullable() {
15366        let home = tempfile::tempdir().unwrap();
15367        let mut schema = setup_schemas(&home)
15368            .into_iter()
15369            .find(|model| model.id == "mlx/qwen3-4b:4bit")
15370            .expect("built-in 4B MLX row from an explicit empty test hub");
15371        // Exercise the routable-but-not-downloaded lifecycle on every test
15372        // platform instead of assuming the runner itself supports MLX or has
15373        // no shared Hugging Face snapshot outside this temporary state root.
15374        schema.available = true;
15375        schema.weights_ready = false;
15376
15377        assert!(schema.available, "the MLX runtime is routable on this host");
15378        assert!(
15379            !schema.weights_ready,
15380            "fresh test root has no model weights"
15381        );
15382        assert!(model_is_pullable(&schema));
15383    }
15384}
15385
15386fn handle_models_upgrades(state: &ServerState) -> Result<Value, String> {
15387    let engine = get_inference_engine(state);
15388    serde_json::to_value(serde_json::json!({
15389        "upgrades": engine.available_model_upgrades()
15390    }))
15391    .map_err(|e| e.to_string())
15392}
15393
15394/// `models.detect_upgrades` — curated + upstream-aware findings (channel-gated,
15395/// cached, offline-safe).
15396async fn handle_models_detect_upgrades(state: &ServerState) -> Result<Value, String> {
15397    let engine = get_inference_engine(state);
15398    let findings = engine.detect_upgrades().await;
15399    serde_json::to_value(serde_json::json!({ "upgrades": findings })).map_err(|e| e.to_string())
15400}
15401
15402/// `models.check_upgrade_nudge` — the current nudge decision (poll form). The
15403/// daemon also pushes `models.upgrade_available` proactively; this lets a
15404/// client ask on demand. `inference_active` defaults to false.
15405async fn handle_models_check_upgrade_nudge(
15406    req: &JsonRpcMessage,
15407    state: &ServerState,
15408) -> Result<Value, String> {
15409    let engine = get_inference_engine(state);
15410    let inference_active = req
15411        .params
15412        .get("inference_active")
15413        .and_then(|v| v.as_bool())
15414        .unwrap_or(false);
15415    let (decision, _state) = engine.check_upgrade_nudge(inference_active).await;
15416    serde_json::to_value(decision).map_err(|e| e.to_string())
15417}
15418
15419/// `models.dismiss_upgrade` — remember that the user waved away a nudge.
15420fn handle_models_dismiss_upgrade(
15421    req: &JsonRpcMessage,
15422    state: &ServerState,
15423) -> Result<Value, String> {
15424    let key = req
15425        .params
15426        .get("dismiss_key")
15427        .and_then(|v| v.as_str())
15428        .map(str::trim)
15429        .filter(|s| !s.is_empty())
15430        .ok_or("missing or empty 'dismiss_key' parameter")?;
15431    let engine = get_inference_engine(state);
15432    engine
15433        .dismiss_upgrade_nudge(key)
15434        .map_err(|e| e.to_string())?;
15435    Ok(serde_json::json!({ "dismissed": key }))
15436}
15437
15438/// `models.check_concierge` — the current concierge suggestions (poll form).
15439/// The daemon also pushes `models.suggestion_available` proactively; this lets
15440/// a client ask on demand. `inference_active` defaults to false.
15441async fn handle_models_check_concierge(
15442    req: &JsonRpcMessage,
15443    state: &ServerState,
15444) -> Result<Value, String> {
15445    let engine = get_inference_engine(state);
15446    let inference_active = req
15447        .params
15448        .get("inference_active")
15449        .and_then(|v| v.as_bool())
15450        .unwrap_or(false);
15451    let (suggestions, _state) = engine.check_concierge(inference_active).await;
15452    serde_json::to_value(serde_json::json!({ "suggestions": suggestions }))
15453        .map_err(|e| e.to_string())
15454}
15455
15456/// `models.dismiss_suggestion` — remember that the user waved away a concierge
15457/// suggestion, so it is never surfaced again.
15458fn handle_models_dismiss_suggestion(
15459    req: &JsonRpcMessage,
15460    state: &ServerState,
15461) -> Result<Value, String> {
15462    let key = req
15463        .params
15464        .get("dismiss_key")
15465        .and_then(|v| v.as_str())
15466        .map(str::trim)
15467        .filter(|s| !s.is_empty())
15468        .ok_or("missing or empty 'dismiss_key' parameter")?;
15469    let engine = get_inference_engine(state);
15470    engine
15471        .dismiss_concierge_suggestion(key)
15472        .map_err(|e| e.to_string())?;
15473    Ok(serde_json::json!({ "dismissed": key }))
15474}
15475
15476/// `concierge.status` — the ambient concierge view (Phase C1): per-lane
15477/// usage + friction, the current grounded decision (Observe or
15478/// Act+suggestion), and per-model health. Pull, not push.
15479async fn handle_concierge_status(
15480    req: &JsonRpcMessage,
15481    state: &ServerState,
15482) -> Result<Value, String> {
15483    let engine = get_inference_engine(state);
15484    // Don't let a status pull surface a proactive Act while the user is
15485    // mid-inference — mirror the proactive path's defer.
15486    let inference_active = req
15487        .params
15488        .get("inference_active")
15489        .and_then(|v| v.as_bool())
15490        .unwrap_or_else(|| state.admission.in_flight() > 0);
15491    let status = engine.concierge_status(inference_active).await;
15492    serde_json::to_value(status).map_err(|e| e.to_string())
15493}
15494
15495/// `concierge.dismiss` — record a *labeled* dismissal (Phase B4/C1):
15496/// `{ dismiss_key, reason }` where reason is one of not_now / wrong /
15497/// too_expensive / privacy / never_for_project. Permanent reasons
15498/// suppress the suggestion forever; not_now only cools it down.
15499fn handle_concierge_dismiss(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15500    let key = req
15501        .params
15502        .get("dismiss_key")
15503        .and_then(|v| v.as_str())
15504        .map(str::trim)
15505        .filter(|s| !s.is_empty())
15506        .ok_or("missing or empty 'dismiss_key' parameter")?;
15507    let reason: car_inference::DismissReason = req
15508        .params
15509        .get("reason")
15510        .cloned()
15511        .map(serde_json::from_value)
15512        .transpose()
15513        .map_err(|e| format!("invalid 'reason': {e}"))?
15514        .unwrap_or(car_inference::DismissReason::NotNow);
15515    let engine = get_inference_engine(state);
15516    engine.dismiss_concierge_labeled(key, reason)?;
15517    Ok(serde_json::json!({ "dismissed": key, "reason": reason }))
15518}
15519
15520/// `concierge.defaults` — all configured lane→model defaults (Phase D1).
15521fn handle_concierge_defaults(state: &ServerState) -> Result<Value, String> {
15522    let engine = get_inference_engine(state);
15523    serde_json::to_value(engine.lane_defaults()).map_err(|e| e.to_string())
15524}
15525
15526/// `concierge.set_default { use_case, model_id, project? }` — set the
15527/// default model for a lane (Phase D1). `use_case` is the snake_case
15528/// lane name (assistant/coding/…).
15529async fn handle_concierge_set_default(
15530    req: &JsonRpcMessage,
15531    state: &ServerState,
15532) -> Result<Value, String> {
15533    let use_case: car_inference::UseCase = req
15534        .params
15535        .get("use_case")
15536        .cloned()
15537        .map(serde_json::from_value)
15538        .transpose()
15539        .map_err(|e| format!("invalid 'use_case': {e}"))?
15540        .ok_or("missing 'use_case'")?;
15541    let model_id = req
15542        .params
15543        .get("model_id")
15544        .and_then(|v| v.as_str())
15545        .map(str::trim)
15546        .filter(|s| !s.is_empty())
15547        .ok_or("missing or empty 'model_id'")?;
15548    let project = req
15549        .params
15550        .get("project")
15551        .and_then(|v| v.as_str())
15552        .map(str::to_string);
15553    let engine = get_inference_engine(state);
15554    engine
15555        .user_set_lane_default(use_case, model_id, project)
15556        .await?;
15557    Ok(serde_json::json!({ "set": model_id, "use_case": use_case }))
15558}
15559
15560/// `concierge.clear_default { use_case, project? }` — remove a lane
15561/// default (Phase D1; also the rollback path in D3).
15562async fn handle_concierge_clear_default(
15563    req: &JsonRpcMessage,
15564    state: &ServerState,
15565) -> Result<Value, String> {
15566    let use_case: car_inference::UseCase = req
15567        .params
15568        .get("use_case")
15569        .cloned()
15570        .map(serde_json::from_value)
15571        .transpose()
15572        .map_err(|e| format!("invalid 'use_case': {e}"))?
15573        .ok_or("missing 'use_case'")?;
15574    let project = req
15575        .params
15576        .get("project")
15577        .and_then(|v| v.as_str())
15578        .map(str::to_string);
15579    let engine = get_inference_engine(state);
15580    let removed = engine.user_clear_lane_default(use_case, project).await?;
15581    Ok(serde_json::json!({ "cleared": removed }))
15582}
15583
15584/// `concierge.apply { use_case, model_id, project? }` — closed-loop
15585/// "set it up" (Phase D3): acquire the model + set it as the lane
15586/// default, capturing the prior default so it's reversible. User-
15587/// consented (this is a direct user action).
15588async fn handle_concierge_apply(
15589    req: &JsonRpcMessage,
15590    state: &ServerState,
15591) -> Result<Value, String> {
15592    let use_case: car_inference::UseCase = req
15593        .params
15594        .get("use_case")
15595        .cloned()
15596        .map(serde_json::from_value)
15597        .transpose()
15598        .map_err(|e| format!("invalid 'use_case': {e}"))?
15599        .ok_or("missing 'use_case'")?;
15600    let model_id = req
15601        .params
15602        .get("model_id")
15603        .and_then(|v| v.as_str())
15604        .map(str::trim)
15605        .filter(|s| !s.is_empty())
15606        .ok_or("missing or empty 'model_id'")?;
15607    let project = req
15608        .params
15609        .get("project")
15610        .and_then(|v| v.as_str())
15611        .map(str::to_string);
15612    let engine = get_inference_engine(state);
15613    let result = engine.apply_concierge(use_case, model_id, project).await?;
15614    serde_json::to_value(result).map_err(|e| e.to_string())
15615}
15616
15617/// `concierge.rollback { use_case, project? }` — revert a lane default to
15618/// its value before the last `apply` (Phase D3).
15619async fn handle_concierge_rollback(
15620    req: &JsonRpcMessage,
15621    state: &ServerState,
15622) -> Result<Value, String> {
15623    let use_case: car_inference::UseCase = req
15624        .params
15625        .get("use_case")
15626        .cloned()
15627        .map(serde_json::from_value)
15628        .transpose()
15629        .map_err(|e| format!("invalid 'use_case': {e}"))?
15630        .ok_or("missing 'use_case'")?;
15631    let project = req
15632        .params
15633        .get("project")
15634        .and_then(|v| v.as_str())
15635        .map(str::to_string);
15636    let engine = get_inference_engine(state);
15637    let restored = engine.rollback_lane(use_case, project).await?;
15638    Ok(serde_json::json!({ "restored": restored }))
15639}
15640
15641/// `concierge.actions { limit? }` — the audit log of consented concierge
15642/// actions (Phase D2), most recent last.
15643fn handle_concierge_actions(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15644    let limit = req
15645        .params
15646        .get("limit")
15647        .and_then(|v| v.as_u64())
15648        .unwrap_or(50) as usize;
15649    let engine = get_inference_engine(state);
15650    serde_json::to_value(serde_json::json!({ "actions": engine.concierge_actions(limit) }))
15651        .map_err(|e| e.to_string())
15652}
15653
15654/// `concierge.refresh_catalog` — fetch + verify the signed model catalog
15655/// from the configured source (Phase E1). New models apply at next
15656/// daemon start (the registry is immutable at runtime).
15657async fn handle_concierge_refresh_catalog(state: &ServerState) -> Result<Value, String> {
15658    let engine = get_inference_engine(state);
15659    let count = engine.refresh_catalog().await?;
15660    Ok(serde_json::json!({
15661        "refreshed": count,
15662        "note": "verified catalog cached; new models apply on next daemon restart"
15663    }))
15664}
15665
15666/// `concierge.ask { question }` — conversational concierge (Phase
15667/// F1/F2): a grounded, ledger-backed answer about the user's models. The
15668/// LLM explains only from the assembled evidence; it can't invent models
15669/// or assert fit.
15670async fn handle_concierge_ask(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15671    let question = req
15672        .params
15673        .get("question")
15674        .and_then(|v| v.as_str())
15675        .map(str::trim)
15676        .filter(|s| !s.is_empty())
15677        .ok_or("missing or empty 'question'")?;
15678    let engine = get_inference_engine(state);
15679    let answer = engine.concierge_ask(question).await?;
15680    Ok(serde_json::json!({ "answer": answer }))
15681}
15682
15683/// `models.update_prefs_get` — current update preferences.
15684fn handle_models_update_prefs_get(state: &ServerState) -> Result<Value, String> {
15685    let engine = get_inference_engine(state);
15686    serde_json::to_value(engine.update_prefs()).map_err(|e| e.to_string())
15687}
15688
15689/// `models.update_prefs_set` — replace update preferences. Params are the
15690/// `UpdatePreferences` shape (all fields optional; missing → defaults).
15691fn handle_models_update_prefs_set(
15692    req: &JsonRpcMessage,
15693    state: &ServerState,
15694) -> Result<Value, String> {
15695    let prefs: car_inference::UpdatePreferences = serde_json::from_value(req.params.clone())
15696        .map_err(|e| format!("invalid preferences: {e}"))?;
15697    let engine = get_inference_engine(state);
15698    engine.set_update_prefs(&prefs).map_err(|e| e.to_string())?;
15699    serde_json::to_value(prefs).map_err(|e| e.to_string())
15700}
15701
15702/// Run one proactive upgrade check and push a `models.upgrade_available`
15703/// nudge to subscribers if warranted. Stamps `last_nudge_secs` after sending
15704/// so the per-day throttle holds across ticks. The daemon calls this on a
15705/// periodic timer; the nudge logic itself decides whether to actually surface
15706/// anything (policy/throttle/dismissals).
15707pub async fn run_upgrade_nudge_check(state: &Arc<ServerState>) {
15708    let engine = get_inference_engine(state);
15709    // Defer the nudge if any inference is in flight (held admission permits) —
15710    // the real "machine is busy" signal, not a guess.
15711    let inference_active = state.admission.in_flight() > 0;
15712    let (decision, mut nstate) = engine.check_upgrade_nudge(inference_active).await;
15713    if let Some(nudge) = decision.nudge {
15714        let delivered = broadcast_upgrade_nudge(state, &nudge).await;
15715        // Only burn the once-per-day throttle when the nudge actually reached
15716        // someone — otherwise a nudge fired with no UI connected would silence
15717        // the user for a day without them ever seeing it.
15718        if delivered > 0 {
15719            let now = std::time::SystemTime::now()
15720                .duration_since(std::time::UNIX_EPOCH)
15721                .map(|d| d.as_secs())
15722                .unwrap_or(0);
15723            nstate.last_nudge_secs = now;
15724            let _ = nstate.save_to(&car_inference::NudgeState::default_path());
15725        }
15726    }
15727}
15728
15729/// Push a `models.upgrade_available` notification to all subscribed UI clients,
15730/// returning how many it reached. Targets the same subscriber set the macOS
15731/// host already uses for pushed events (`a2ui_subscribers`) — the UI-push
15732/// channel — rather than standing up a parallel subscription. Mirrors
15733/// [`broadcast_a2ui_event`].
15734pub async fn broadcast_upgrade_nudge(
15735    state: &Arc<ServerState>,
15736    nudge: &car_inference::UpgradeNudge,
15737) -> usize {
15738    use futures::SinkExt;
15739    use tokio_tungstenite::tungstenite::Message;
15740    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
15741        .a2ui_subscribers
15742        .lock()
15743        .await
15744        .values()
15745        .cloned()
15746        .collect();
15747    if subscribers.is_empty() {
15748        return 0;
15749    }
15750    let Ok(json) = serde_json::to_string(&serde_json::json!({
15751        "jsonrpc": "2.0",
15752        "method": "models.upgrade_available",
15753        "params": nudge,
15754    })) else {
15755        return 0;
15756    };
15757    let mut delivered = 0;
15758    for channel in subscribers {
15759        if channel
15760            .write
15761            .lock()
15762            .await
15763            .send(Message::Text(json.clone().into()))
15764            .await
15765            .is_ok()
15766        {
15767            delivered += 1;
15768        }
15769    }
15770    delivered
15771}
15772
15773/// Run one proactive concierge check and push a `models.suggestion_available`
15774/// notification per unserved lane to subscribers. Stamps `last_concierge_secs`
15775/// after sending so the throttle holds across ticks. Mirrors
15776/// [`run_upgrade_nudge_check`] but on the concierge's independent cadence and
15777/// throttle field, so the two never starve each other.
15778pub async fn run_concierge_check(state: &Arc<ServerState>) {
15779    let engine = get_inference_engine(state);
15780    let inference_active = state.admission.in_flight() > 0;
15781    let (suggestions, mut nstate) = engine.check_concierge(inference_active).await;
15782    if suggestions.is_empty() {
15783        return;
15784    }
15785    let mut any_delivered = false;
15786    for suggestion in &suggestions {
15787        if broadcast_concierge_suggestion(state, suggestion).await > 0 {
15788            any_delivered = true;
15789        }
15790    }
15791    // Only burn the throttle when a suggestion actually reached someone —
15792    // otherwise a check that fired with no UI connected would silence the user
15793    // for a week without them ever seeing it.
15794    if any_delivered {
15795        let now = std::time::SystemTime::now()
15796            .duration_since(std::time::UNIX_EPOCH)
15797            .map(|d| d.as_secs())
15798            .unwrap_or(0);
15799        nstate.last_concierge_secs = now;
15800        let _ = nstate.save_to(&car_inference::NudgeState::default_path());
15801    }
15802}
15803
15804/// One pass of idle model-backend eviction. A long-running daemon under
15805/// the default 24 GB cache budget pins loaded model weights resident
15806/// forever — capacity eviction never fires below the cap — so RSS climbs
15807/// to a model's working set and never drops at idle (car-releases#67).
15808/// This sweeps backends untouched for `CAR_INFERENCE_MODEL_IDLE_SECS`
15809/// (default 300) out of the caches; their RAM is reclaimed once any
15810/// outstanding handles drop, which at idle is immediate.
15811///
15812/// Only sweeps if the engine has already been created — it never forces
15813/// lazy engine init just to sweep an empty cache. Meant to be driven on a
15814/// timer by the daemon.
15815pub async fn run_idle_backend_eviction(state: &Arc<ServerState>) {
15816    let Some(engine) = state.inference.get() else {
15817        return;
15818    };
15819    let (entries, bytes) = engine.evict_idle_backends();
15820    if entries > 0 {
15821        tracing::info!(
15822            entries,
15823            mb = bytes / (1024 * 1024),
15824            "evicted idle model backends; resident model memory released"
15825        );
15826    }
15827    // Supervised vllm-mlx server processes are managed alongside the in-process
15828    // backends: stop the ones idle past their TTL so a server-backed model
15829    // doesn't pin the GPU after use.
15830    let stopped = engine.evict_idle_vllm_servers().await;
15831    if stopped > 0 {
15832        tracing::info!(stopped, "stopped idle vllm-mlx servers");
15833    }
15834}
15835
15836/// Push a `models.suggestion_available` notification to subscribed UI clients,
15837/// returning how many it reached. Targets the same `a2ui_subscribers` UI-push
15838/// channel as [`broadcast_upgrade_nudge`].
15839pub async fn broadcast_concierge_suggestion(
15840    state: &Arc<ServerState>,
15841    suggestion: &car_inference::ConciergeSuggestion,
15842) -> usize {
15843    use futures::SinkExt;
15844    use tokio_tungstenite::tungstenite::Message;
15845    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
15846        .a2ui_subscribers
15847        .lock()
15848        .await
15849        .values()
15850        .cloned()
15851        .collect();
15852    if subscribers.is_empty() {
15853        return 0;
15854    }
15855    let Ok(json) = serde_json::to_string(&serde_json::json!({
15856        "jsonrpc": "2.0",
15857        "method": "models.suggestion_available",
15858        "params": suggestion,
15859    })) else {
15860        return 0;
15861    };
15862    let mut delivered = 0;
15863    for channel in subscribers {
15864        if channel
15865            .write
15866            .lock()
15867            .await
15868            .send(Message::Text(json.clone().into()))
15869            .await
15870            .is_ok()
15871        {
15872            delivered += 1;
15873        }
15874    }
15875    delivered
15876}
15877
15878/// Bridges the (sync) download progress sink to async WS broadcasts: each
15879/// `DownloadEvent` is pushed onto an unbounded channel that a concurrent task
15880/// drains and broadcasts as `models.pull_progress`.
15881struct PullProgressSink {
15882    tx: tokio::sync::mpsc::UnboundedSender<car_inference::DownloadEvent>,
15883}
15884
15885impl car_inference::DownloadProgress for PullProgressSink {
15886    fn on_event(&self, event: &car_inference::DownloadEvent) {
15887        // Unbounded send from a sync context; ignore if the receiver is gone.
15888        let _ = self.tx.send(event.clone());
15889    }
15890}
15891
15892async fn handle_models_pull(
15893    msg: &JsonRpcMessage,
15894    session: &crate::session::ClientSession,
15895    state: &Arc<ServerState>,
15896) -> Result<Value, String> {
15897    require_approval_authority(session, state)?;
15898    let params: ModelPullParams = strict_model_params(msg)?;
15899    let name = params.name;
15900    if name.is_empty() || name.trim() != name {
15901        return Err(
15902            "invalid model-management params: model id must be non-empty exact UTF-8 without surrounding whitespace"
15903                .into(),
15904        );
15905    }
15906    let engine = Arc::clone(get_inference_engine(state));
15907    let operation_state = Arc::clone(state);
15908    let operation = state
15909        .spawn_durable_operation("models.pull", async move {
15910            run_models_pull(name, engine, operation_state).await
15911        })
15912        .await;
15913    operation.await.map_err(|_| {
15914        "daemon-owned models.pull operation stopped before publishing a result".to_string()
15915    })?
15916}
15917
15918async fn run_models_pull(
15919    name: String,
15920    engine: Arc<car_inference::InferenceEngine>,
15921    state: Arc<ServerState>,
15922) -> Result<Value, String> {
15923    // Stream progress live: events flow sink → channel → broadcaster task,
15924    // which runs concurrently with the download. Unbounded is safe here because
15925    // progress is file-level (O(files): Started + per-file Started/Completed +
15926    // Completed — a few dozen events per pull), not byte-level, so the channel
15927    // can't grow without bound.
15928    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<car_inference::DownloadEvent>();
15929    let sink = car_inference::ProgressSink::new(Arc::new(PullProgressSink { tx }));
15930    let broadcaster_state = state.clone();
15931    let model_label = name.clone();
15932    let broadcaster = tokio::spawn(async move {
15933        while let Some(event) = rx.recv().await {
15934            broadcast_pull_progress(&broadcaster_state, &model_label, &event).await;
15935        }
15936    });
15937
15938    let result = engine.pull_model_with_progress(&name, &sink).await;
15939    // Drop the sink so its sender closes, ending the broadcaster cleanly.
15940    drop(sink);
15941    // A broadcaster panic must not poison the (successful) pull — log and move on.
15942    if let Err(e) = broadcaster.await {
15943        tracing::warn!(error = %e, "pull-progress broadcaster task failed");
15944    }
15945
15946    let path = result.map_err(|e| e.to_string())?;
15947    Ok(serde_json::json!({"path": path.display().to_string()}))
15948}
15949
15950/// Push a `models.pull_progress` notification to subscribed UI clients.
15951/// Mirrors [`broadcast_upgrade_nudge`]; best-effort, no-op with no subscribers.
15952async fn broadcast_pull_progress(
15953    state: &Arc<ServerState>,
15954    model: &str,
15955    event: &car_inference::DownloadEvent,
15956) {
15957    use futures::SinkExt;
15958    use tokio_tungstenite::tungstenite::Message;
15959    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
15960        .a2ui_subscribers
15961        .lock()
15962        .await
15963        .values()
15964        .cloned()
15965        .collect();
15966    if subscribers.is_empty() {
15967        return;
15968    }
15969    let Ok(json) = serde_json::to_string(&serde_json::json!({
15970        "jsonrpc": "2.0",
15971        "method": "models.pull_progress",
15972        "params": { "model": model, "event": event },
15973    })) else {
15974        return;
15975    };
15976    for channel in subscribers {
15977        let _ = channel
15978            .write
15979            .lock()
15980            .await
15981            .send(Message::Text(json.clone().into()))
15982            .await;
15983    }
15984}
15985
15986async fn handle_skills_distill(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15987    let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
15988        msg.params
15989            .get("events")
15990            .cloned()
15991            .unwrap_or(msg.params.clone()),
15992    )
15993    .map_err(|e| format!("invalid events: {}", e))?;
15994
15995    let inference = get_inference_engine(state).clone();
15996    let engine = car_memgine::MemgineEngine::new(None).with_inference(inference);
15997
15998    let skills = engine.distill_skills(&events).await;
15999    serde_json::to_value(&skills).map_err(|e| e.to_string())
16000}
16001
16002/// Run memory consolidation against this client's session memgine
16003/// (or the daemon-owned per-agent memgine when bound — #170).
16004/// Returns the JSON `ConsolidationReport`.
16005async fn handle_memory_consolidate(
16006    session: &crate::session::ClientSession,
16007) -> Result<Value, String> {
16008    let engine_arc = session.effective_memgine().await;
16009    let report = {
16010        let mut engine = engine_arc.lock().await;
16011        engine.consolidate().await
16012    };
16013    if let Some(id) = session.agent_id.lock().await.clone() {
16014        if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
16015            tracing::warn!(agent_id = %id, error = %e,
16016                "agent memgine persist after consolidate failed");
16017        }
16018    }
16019    serde_json::to_value(&report).map_err(|e| e.to_string())
16020}
16021
16022/// `memory.utility_get` — read the live engine's utility-aware
16023/// retrieval blend (U-Mem). Returns `{ utility_weight,
16024/// utility_exploration }`.
16025///
16026/// Engine source: `session.memgine` — deliberately the same engine
16027/// `handle_memory_build_context`/`_fast` retrieve from, so this getter
16028/// and the setter always report/mutate the engine that live context
16029/// assembly reads. NOTE: that's `session.memgine`, not
16030/// `effective_memgine()` — `build_context` is itself the outlier that
16031/// skipped the #170 bound-memgine routing; when that gap is closed,
16032/// these two utility handlers must move with it. For a bound agent the
16033/// persistent default still arrives via the `.car/`-seeded per-agent
16034/// engine (`get_or_load_agent_memgine`); this runtime override is a
16035/// daemon-global knob over the shared engine.
16036async fn handle_memory_utility_get(
16037    session: &crate::session::ClientSession,
16038) -> Result<Value, String> {
16039    let engine = session.memgine.lock().await;
16040    let (weight, exploration) = engine.utility_retrieval();
16041    Ok(serde_json::json!({
16042        "utility_weight": weight,
16043        "utility_exploration": exploration,
16044    }))
16045}
16046
16047/// `memory.utility_set` — runtime override of the utility-aware
16048/// retrieval blend on `session.memgine` (see `memory.utility_get` for
16049/// the engine-source rationale). The **persistent** baseline comes from
16050/// `.car/config.toml` at engine construction; this mutates the live
16051/// engine on top of that and does NOT persist across daemon restarts.
16052///
16053/// Read-modify-write: an omitted field keeps the engine's current value,
16054/// so `{ utility_weight: 0.5 }` tunes weight without resetting
16055/// exploration (matching the optional param typing + paired getter).
16056/// Takes effect on the next context build. Returns the applied
16057/// `{ utility_weight, utility_exploration }` (post-clamp).
16058///
16059/// NOTE: the shared engine is daemon-global (one `shared_memgine` across
16060/// all WS sessions + MCP), so this shifts retrieval ranking for every
16061/// client — last-writer-wins. For per-team persistent tuning, prefer the
16062/// `.car/config.toml` `utility_weight`/`utility_exploration` keys.
16063async fn handle_memory_utility_set(
16064    msg: &JsonRpcMessage,
16065    session: &crate::session::ClientSession,
16066) -> Result<Value, String> {
16067    let (weight, exploration) = {
16068        let mut engine = session.memgine.lock().await;
16069        // Read-modify-write: default each missing field to the engine's
16070        // current value rather than 0.0, so a partial set doesn't silently
16071        // zero the field the caller didn't pass.
16072        let (cur_weight, cur_exploration) = engine.utility_retrieval();
16073        let weight = msg
16074            .params
16075            .get("utility_weight")
16076            .and_then(|v| v.as_f64())
16077            .unwrap_or(cur_weight);
16078        let exploration = msg
16079            .params
16080            .get("utility_exploration")
16081            .and_then(|v| v.as_f64())
16082            .unwrap_or(cur_exploration);
16083        engine.set_utility_retrieval(weight, exploration);
16084        engine.utility_retrieval()
16085    };
16086    Ok(serde_json::json!({
16087        "utility_weight": weight,
16088        "utility_exploration": exploration,
16089    }))
16090}
16091
16092/// `cascade.run` — U-Mem Slice 5 live evolve loop. Runs the cost-aware
16093/// knowledge cascade (`car_memgine::cascade::run_cascade_async`) for real,
16094/// escalating cheapest-first on **observed** confidence: it walks the policy's
16095/// tiers within budget, runs each tier's real CAR mechanic, and stops once an
16096/// observed confidence meets the target.
16097///
16098/// Per the caller-injected-confidence design (CAR doesn't invent a confidence
16099/// its primitives don't produce): the daemon runs the tier **mechanics** and the
16100/// budget/target walk, while the **observed confidence** for `self_reflect` /
16101/// `tool_verify` is supplied by the caller in `observed` (they have no
16102/// authoritative confidence source). The mechanics per tier:
16103///   - `self_reflect` → `engine.reflect()` on the session memgine (a real side
16104///     effect: it ingests reflection insights into memory; skipped when
16105///     `dry_run` is set). Confidence: caller-supplied.
16106///   - `tool_verify`  → pass-through (CAR has no built-in verify tool — the
16107///     confidence/knowledge are **caller-attested** from whatever tool the caller
16108///     ran; the daemon does not verify).
16109///   - `human_expert` → the session's `ApprovalLedger` (the same HITL substrate
16110///     as `permission.*`) keyed by `cascade:<claim>` is the **authority**: a
16111///     prior **Approved** lets the tier contribute the caller's confidence, a
16112///     **Rejected** aborts the cascade, and an **undecided** claim forces the
16113///     tier's confidence to 0.0 (it can NEVER be accepted without a real
16114///     approval) and surfaces `pending_approval` so a host routes it through
16115///     `permission.approve` / `permission.reject` by that fingerprint. A
16116///     `human_expert` tier requires a non-empty `claim`.
16117///
16118/// Params: `{ current_confidence: f64, policy: CascadePolicy, observed:
16119/// { <tier>: { confidence: f64, knowledge?: string } }, claim?: string,
16120/// dry_run?: bool }`.
16121/// Returns `{ run: CascadeRun, pending_approval?: { fingerprint, claim } }`.
16122async fn handle_cascade_run(
16123    req: &JsonRpcMessage,
16124    session: &crate::session::ClientSession,
16125    state: &ServerState,
16126) -> Result<Value, String> {
16127    use car_memgine::cascade::{run_cascade_async, CascadePolicy, CascadeTier, TierResult};
16128
16129    #[derive(serde::Deserialize)]
16130    struct ObservedTier {
16131        confidence: f64,
16132        #[serde(default)]
16133        knowledge: Option<String>,
16134    }
16135
16136    let current_confidence = req
16137        .params
16138        .get("current_confidence")
16139        .and_then(|v| v.as_f64())
16140        .unwrap_or(0.0);
16141    let policy_val = req.params.get("policy").ok_or("missing 'policy'")?;
16142    let policy: CascadePolicy =
16143        serde_json::from_value(policy_val.clone()).map_err(|e| format!("invalid 'policy': {e}"))?;
16144    let observed: std::collections::HashMap<CascadeTier, ObservedTier> = req
16145        .params
16146        .get("observed")
16147        .map(|v| serde_json::from_value(v.clone()))
16148        .transpose()
16149        .map_err(|e| format!("invalid 'observed': {e}"))?
16150        .unwrap_or_default();
16151    let claim = req
16152        .params
16153        .get("claim")
16154        .and_then(|v| v.as_str())
16155        .unwrap_or("")
16156        .to_string();
16157    // When true, skip tier side effects (the `self_reflect` `reflect()` memory
16158    // mutation) — a side-effect-free preview that still walks the cascade on the
16159    // caller-supplied observed confidence. The `human_expert` ledger read is
16160    // read-only and always runs (the gate must hold even in a preview).
16161    let dry_run = req
16162        .params
16163        .get("dry_run")
16164        .and_then(|v| v.as_bool())
16165        .unwrap_or(false);
16166    let fingerprint = format!("cascade:{}", claim.trim());
16167
16168    // A `human_expert` tier needs a non-blank claim: the durable approval is
16169    // keyed on `cascade:<claim>`, so an empty claim would collapse every
16170    // claimless cascade onto the shared `cascade:` fingerprint — one stale
16171    // decision would then bleed across logically independent requests.
16172    let has_human_tier = policy
16173        .tiers
16174        .iter()
16175        .any(|t| t.tier == CascadeTier::HumanExpert);
16176    if has_human_tier && claim.trim().is_empty() {
16177        return Err(
16178            "a 'human_expert' tier requires a non-empty 'claim' (it keys the durable approval)"
16179                .into(),
16180        );
16181    }
16182
16183    // The engine that holds (and learns) this session's facts — `reflect()` must
16184    // ingest into the bound per-agent memgine when one is attached (#170), like
16185    // `consolidate`.
16186    let engine_arc = session.effective_memgine().await;
16187
16188    let observed_ref = &observed;
16189    let fp_ref = fingerprint.as_str();
16190    let run = move |tier: CascadeTier| {
16191        let engine_arc = engine_arc.clone();
16192        async move {
16193            let obs = observed_ref.get(&tier);
16194            let caller_confidence = obs.map(|o| o.confidence).unwrap_or(0.0);
16195            let caller_knowledge = obs.and_then(|o| o.knowledge.clone());
16196            // self_reflect / tool_verify have no authoritative confidence source,
16197            // so the caller's observed value drives escalation (the approved
16198            // caller-injected design). human_expert is different — the ledger is
16199            // the authority, so its confidence is GATED on a recorded decision,
16200            // never on the caller's number.
16201            let (knowledge, confidence) = match tier {
16202                CascadeTier::SelfReflect => {
16203                    let knowledge = if dry_run {
16204                        caller_knowledge
16205                            .unwrap_or_else(|| "self_reflect: dry_run (no reflect)".to_string())
16206                    } else {
16207                        // Real side effect: reflect over conversation, ingesting insights.
16208                        let report = {
16209                            let mut engine = engine_arc.lock().await;
16210                            engine.reflect().await
16211                        };
16212                        caller_knowledge.unwrap_or_else(|| {
16213                            format!(
16214                                "self_reflect: {} insight(s) ingested ({} corrections, {} preferences, {} friction)",
16215                                report.insights_ingested,
16216                                report.corrections_found,
16217                                report.preferences_found,
16218                                report.friction_points_found,
16219                            )
16220                        })
16221                    };
16222                    (knowledge, caller_confidence)
16223                }
16224                CascadeTier::ToolVerify => {
16225                    let knowledge = caller_knowledge
16226                        .unwrap_or_else(|| "tool_verify: caller-supplied".to_string());
16227                    (knowledge, caller_confidence)
16228                }
16229                CascadeTier::HumanExpert => {
16230                    // SHARED daemon ledger (C1): the approving host connection
16231                    // is not this connection.
16232                    let decision = {
16233                        let ledger = state.approval_ledger.read().await;
16234                        ledger.lookup(fp_ref).map(|r| r.decision)
16235                    };
16236                    match decision {
16237                        // A human rejected this exact claim — abort the cascade.
16238                        Some(car_policy::ApprovalDecision::Rejected) => {
16239                            return Err(format!("human_expert rejected: {fp_ref}"));
16240                        }
16241                        // Approved → the tier may contribute the caller's confidence
16242                        // (the human endorsed the claim; the caller scores how
16243                        // confident given that endorsement).
16244                        Some(car_policy::ApprovalDecision::Approved) => {
16245                            let knowledge = caller_knowledge
16246                                .unwrap_or_else(|| format!("human_expert: approved ({fp_ref})"));
16247                            (knowledge, caller_confidence)
16248                        }
16249                        // Undecided → 0.0, NOT the caller's number. The tier cannot
16250                        // satisfy the target without a real approval, so the run
16251                        // can never report `accepted_tier=human_expert` while the
16252                        // claim is still pending. Acceptance is gated on the ledger.
16253                        None => {
16254                            let knowledge = caller_knowledge.unwrap_or_else(|| {
16255                                format!("human_expert: awaiting approval ({fp_ref})")
16256                            });
16257                            (knowledge, 0.0)
16258                        }
16259                    }
16260                }
16261            };
16262            Ok::<TierResult, String>(TierResult {
16263                knowledge,
16264                confidence,
16265            })
16266        }
16267    };
16268
16269    let run_result = run_cascade_async(current_confidence, &policy, run).await?;
16270
16271    // Surface a pending human approval when the human_expert tier ran but no
16272    // durable decision exists yet — the host routes it through the existing
16273    // `permission.approve`/`permission.reject` flow by this fingerprint.
16274    let human_ran = run_result
16275        .steps
16276        .iter()
16277        .any(|s| s.tier == CascadeTier::HumanExpert);
16278    let pending = if human_ran {
16279        state
16280            .approval_ledger
16281            .read()
16282            .await
16283            .lookup(&fingerprint)
16284            .is_none()
16285    } else {
16286        false
16287    };
16288
16289    let mut resp = serde_json::json!({ "run": run_result });
16290    if pending {
16291        resp.as_object_mut().unwrap().insert(
16292            "pending_approval".into(),
16293            serde_json::json!({ "fingerprint": fingerprint, "claim": claim }),
16294        );
16295    }
16296    Ok(resp)
16297}
16298
16299/// Assemble the governor's full live component-state input for this session —
16300/// ALL FIVE components, each from its real signal source:
16301/// - **Memory / Skills / Context** from the session memgine
16302///   (`evolution_component_states` — Memory backlog/churn, Skills degradation
16303///   rate, Context conversation-token saturation);
16304/// - **Harness** from the session event log
16305///   (`crate::evolution::harness_component_from_events` — the share of logged
16306///   events implicated in a recurring interaction-failure pattern);
16307/// - **Tools** from live connector health
16308///   (`crate::evolution::tools_component_from_connectors` — disconnected /
16309///   total connectors).
16310///
16311/// A component with no observable signal source (empty store, empty log, no
16312/// connectors configured) is omitted rather than fabricated at zero.
16313async fn assemble_evolution_components(
16314    session: &crate::session::ClientSession,
16315    state: &Arc<ServerState>,
16316) -> Vec<car_memgine::self_evolution::ComponentState> {
16317    // Route through `effective_memgine` (S1) so a session bound to a
16318    // lifecycle agent (#169/#170) plans over the daemon-owned per-agent
16319    // engine, like every memory.* handler.
16320    let mut components = {
16321        let engine_arc = session.effective_memgine().await;
16322        let engine = engine_arc.lock().await;
16323        engine.evolution_component_states()
16324    };
16325    {
16326        let log = session.runtime.log.lock().await;
16327        if let Some(h) = crate::evolution::harness_component_from_events(log.events()) {
16328            components.push(h);
16329        }
16330    }
16331    state.ensure_connectors_loaded().await;
16332    let connectors = state.connectors().list().await;
16333    if let Some(t) = crate::evolution::tools_component_from_connectors(&connectors) {
16334        components.push(t);
16335    }
16336    components
16337}
16338
16339/// Return watch-only detector cadence, last-tick, detector, and active-count
16340/// state. The response reads the append-only ledger and performs no I/O beyond
16341/// that already-loaded daemon state.
16342async fn handle_selfheal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16343    serde_json::to_value(state.selfheal.status().await).map_err(|error| error.to_string())
16344}
16345
16346/// List active (not dismissed) detections with bounded pagination. Optional
16347/// filters: `kind`, `severity`, `since`; pagination: `offset`, `limit` (max
16348/// 500). This is a watch-only ledger query.
16349async fn handle_selfheal_detections(
16350    req: &JsonRpcMessage,
16351    state: &Arc<ServerState>,
16352) -> Result<Value, String> {
16353    let query = serde_json::from_value::<crate::selfheal::DetectionQuery>(req.params.clone())
16354        .map_err(|error| format!("invalid selfheal.detections params: {error}"))?;
16355    serde_json::to_value(state.selfheal.detections(query).await).map_err(|error| error.to_string())
16356}
16357
16358/// Append an operator dismissal marker for one stable SHA-256 dedup key. A
16359/// dismissal suppresses later detector ticks for that key; it never deletes or
16360/// rewrites prior ledger records.
16361async fn handle_selfheal_dismiss(
16362    req: &JsonRpcMessage,
16363    state: &Arc<ServerState>,
16364) -> Result<Value, String> {
16365    let key = req
16366        .params
16367        .get("dedup_key")
16368        .and_then(Value::as_str)
16369        .ok_or_else(|| "selfheal.dismiss requires string param 'dedup_key'".to_string())?;
16370    serde_json::to_value(state.selfheal.dismiss(key).await?).map_err(|error| error.to_string())
16371}
16372
16373/// Start one bounded, template-owned coder round for an eligible stable key.
16374async fn handle_selfheal_fix(
16375    req: &JsonRpcMessage,
16376    state: &Arc<ServerState>,
16377) -> Result<Value, String> {
16378    let key = req
16379        .params
16380        .get("dedup_key")
16381        .and_then(Value::as_str)
16382        .ok_or_else(|| "selfheal.fix requires string param 'dedup_key'".to_string())?;
16383    serde_json::to_value(state.selfheal.fix(key).await?).map_err(|error| error.to_string())
16384}
16385
16386/// What the repair loop is configured to do, and why it is not doing it.
16387///
16388/// Answerable whether or not the loop is enabled — a disabled loop and an idle
16389/// one are indistinguishable from outside, and on first setup the operator is
16390/// almost always looking at the first while believing it is the second.
16391async fn handle_heal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16392    serde_json::to_value(state.heal.status(get_inference_engine(state)))
16393        .map_err(|error| error.to_string())
16394}
16395
16396/// Run one sweep now, instead of waiting for the cadence.
16397///
16398/// Goes through the same `HealService` the cadence uses, so it takes the same
16399/// try-lock and reads the same claim ledger. A manual run that built its own
16400/// loop could act on an item a running sweep already holds.
16401async fn handle_heal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16402    // SPAWNED, then awaited. Awaiting the sweep directly would tie it to this
16403    // future's lifetime, so any drop — a deadline, a client disconnect — would
16404    // take a live coder session with it, and a detached one at that. A spawned
16405    // task survives the drop: the sweep runs to completion, its claim is
16406    // already on disk, and its session reaches a terminal state and releases
16407    // its worktree. The caller may miss the answer; the daemon never loses
16408    // track of the work.
16409    let owned = state.clone();
16410    let handle = tokio::spawn(async move { owned.heal.run_tick(&owned).await });
16411    match handle.await {
16412        Ok(result) => serde_json::to_value(result?).map_err(|error| error.to_string()),
16413        Err(e) => Err(format!("heal sweep task failed: {e}")),
16414    }
16415}
16416
16417/// Run one detection tick now. The method uses the same non-overlap guard as
16418/// the cadence and may start one eligible default-on auto-fix round after the
16419/// deterministic detector pass.
16420
16421async fn handle_selfheal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16422    serde_json::to_value(state.selfheal.run_tick(state).await?).map_err(|error| error.to_string())
16423}
16424
16425/// Plan an evolution cycle over the session's **live** signals — the
16426/// self-evolution governor's host surface (arXiv 2507.21046, Slice 3 + the
16427/// daemon populaters). Folds all five components live via
16428/// [`assemble_evolution_components`] and runs the governor. `params.policy` is
16429/// an optional `EvolutionPolicy` `{ pressure_threshold?, budget? }`. Returns
16430/// the `EvolutionPlan` JSON `{ decisions, spent, evolve_now }`. Read-only — it
16431/// plans; dispatching is `evolution.run` (or the caller's own loop).
16432async fn handle_evolution_plan(
16433    req: &JsonRpcMessage,
16434    session: &crate::session::ClientSession,
16435    state: &Arc<ServerState>,
16436) -> Result<Value, String> {
16437    let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16438        req.params
16439            .get("policy")
16440            .cloned()
16441            .unwrap_or_else(|| serde_json::json!({})),
16442    )
16443    .map_err(|e| format!("invalid policy: {e}"))?;
16444    let components = assemble_evolution_components(session, state).await;
16445    let plan = car_memgine::self_evolution::plan_evolution(&components, &policy);
16446    serde_json::to_value(plan).map_err(|e| e.to_string())
16447}
16448
16449/// Run one evolution cycle over the session's live signals — the real executor
16450/// behind the self-evolution governor (arXiv 2507.21046; the "remaining daemon
16451/// step" of `docs/proposals/self-evolution-governor.md`). Plans exactly like
16452/// `evolution.plan` (all five components live), then dispatches each
16453/// `EvolveNow` component in priority order:
16454///
16455/// - **Memory** → `engine.consolidate()`, sized by
16456///   `maintenance::decide_maintenance` off the live `memory_stats` (the
16457///   localized-vs-global choice is recorded; `consolidate()` is the single
16458///   live mechanism for both today).
16459/// - **Skills** → `engine.evolve_skills(failed_events, domain)` for every
16460///   domain `domains_needing_evolution` flags, with `failed_events` folded
16461///   from the session event log's failure records
16462///   (`crate::evolution::failed_trace_events` — per-action failures; the log
16463///   carries no state-before/after trajectories, so those fields are honest
16464///   `None`s). Errors `"no inference engine"` when the session engine has no
16465///   model — never a stub.
16466/// - **Harness** → the `harness_evolution` diagnose→gate→apply loop, HITL-
16467///   gated on the daemon's SHARED durable `ApprovalLedger`
16468///   (`~/.car/approvals.jsonl` — the same store `permission.*` writes, from
16469///   ANY connection; C1), fingerprint `harness:<component>:<patch-digest>`
16470///   (bound to the patch *content*, not diagnostic prose, so an approval
16471///   matches every re-diagnosis of the same change; C2):
16472///   - a mutation whose fingerprint a human already **approved** applies its
16473///     patch to the session runtime's live `HarnessConfig` under one atomic
16474///     read-modify-write (`Governance::HumanApproved`); **rejected** →
16475///     blocked;
16476///   - undecided mutations surface in `pending_approvals` (resolve via
16477///     `permission.approve`/`permission.reject` by fingerprint — on any
16478///     connection — then re-run);
16479///   - the regression gate (`EvolutionAgent::evaluate`) needs *candidate*
16480///     metrics measured on held-out telemetry after applying the mutation, so
16481///     auto-promotion only runs when such metrics exist. Two ways to get them:
16482///     the caller supplies `harness_candidate_metrics` (and optionally
16483///     `harness_baseline_metrics`; baseline defaults to the live session
16484///     metrics), or the caller opts into `harness_measure` and the daemon
16485///     measures them ITSELF via the installed
16486///     [`HarnessMeasurer`](crate::evolution::HarnessMeasurer) — one baseline
16487///     replay under the session's live `HarnessConfig`, then one replay per
16488///     measurable mutation under that config plus the mutation's patch. A
16489///     gate-passed non-safety mutation then auto-applies
16490///     (`Governance::Promoted`); a safety-affecting one still goes to HITL.
16491///   - `harness_measure` is mutually exclusive with the two supplied-metrics
16492///     params (an ERROR naming both, never a silent winner); under `dry_run`
16493///     it measures NOTHING (a paid replay is a side effect) and reports
16494///     `measurement.status = "skipped_dry_run"`; with no measurer installed on
16495///     this build it ERRS rather than degrading to HITL, because an opt-in
16496///     that silently does nothing would report an unattended cycle that never
16497///     measured anything. Mutations with no patch and safety-affecting
16498///     mutations are never measured — the first has nothing to apply, the
16499///     second can never auto-promote, so a replay would spend real model calls
16500///     to reach an outcome already decided.
16501///   - a supplied (or daemon-measured) `harness_baseline_metrics` also
16502///     REPLACES the Harness planning signal (`harness_component_from_metrics`
16503///     — failed/total attempts) so the declared telemetry both elects and
16504///     diagnoses the component coherently.
16505///   - the harness step summary carries a `measurement` object (status, split,
16506///     model, seed and the full baseline document) and every measured
16507///     mutation's detail carries its full `candidate_metrics`: a promotion
16508///     nobody can re-derive is not an audited promotion.
16509/// - **Context** → [`run_context_evolution`](crate::evolution::run_context_evolution):
16510///   diagnose from the engine's live conversation-layer saturation, then
16511///   resolve each mutation most-binding-first over two authorization paths.
16512///   - A durable ledger decision wins outright (`rejected_by_operator`, or the
16513///     human-approved apply→compact→re-read→REVERT-unless-tokens-fell path,
16514///     whose reverted mutations report `rolled_back` and count as nothing
16515///     applied).
16516///   - Otherwise, if the caller opted into **`context_measure`**, the
16517///     **pre-activation grader** runs: two bench replays over the same
16518///     deterministic split, one under the engine's live `MemgineConfig` and one
16519///     under that config plus the mutation's patch, graded on TASK outcomes by
16520///     `EvolutionAgent::evaluate_context` — the same gate, with the same
16521///     guards, that grades a harness mutation. A promoted mutation applies with
16522///     `governance: "promoted"` and no human in the loop; a rejected one
16523///     reports `rejected_by_gate` and applies nothing (and does NOT then ask an
16524///     operator to approve what the daemon just measured as a regression); a
16525///     failed replay reports `measurement_failed`; `NeedsApproval` /
16526///     `Incomparable` fall through to `pending_approval` carrying the gate's
16527///     own reason.
16528///   - Otherwise the human gate, with a reason naming the missing precondition
16529///     (`context_measure` absent, `dry_run`, or a patchless mutation). As with
16530///     `harness_measure`, requesting `context_measure` on a build with no
16531///     evaluator installed is an ERROR, and `dry_run` performs no paid replay.
16532/// - **Tools** → recorded as `out_of_scope` with its reason: connector
16533///   remediation is a credential operation this loop holds no authority to
16534///   perform (reconnect/re-auth stay operator actions via `connectors.*`).
16535///   That is a decision, not a failure, and `out_of_scope: true` with
16536///   `ran: true` is how it is now reported — an `Err` here used to make it
16537///   indistinguishable from a crashed mechanism.
16538///
16539/// `params`: `{ policy?, dry_run?, harness_baseline_metrics?,
16540/// harness_candidate_metrics?, harness_measure?, context_measure? }`, where
16541/// `harness_measure` and `context_measure` are each a
16542/// [`HarnessMeasureRequest`](crate::evolution::HarnessMeasureRequest)
16543/// `{ model, split?, held_in_fraction?, split_seed?, max_turns?, tasks_dir? }`
16544/// — the same type, because the two arms replay the same suite over the same
16545/// split and differ only in which config is varied. They are NOT mutually
16546/// exclusive with each other (they grade different pillars); `harness_measure`
16547/// remains mutually exclusive with the two supplied-metrics params. `dry_run` skips every side effect (no
16548/// consolidate, no evolve, no config apply, no event append) and reports what
16549/// would run — including the `pending_approvals` that a real run would
16550/// surface (listing them is response data, not a side effect). `evolved`
16551/// lists only components that **applied a change** (S2) — an Ok-but-no-op
16552/// step (nothing to evolve, everything still pending approval, dry run) is
16553/// reported in `steps` with `applied: false` but not in `evolved`. At most
16554/// one `evolution.run` executes per session at a time; a concurrent second
16555/// call errs instead of overlapping (S3). Returns
16556/// `{ plan, steps, evolved, out_of_scope, pending_approvals? }`, where
16557/// `out_of_scope` lists the components that were planned but have no mechanism
16558/// by decision (their steps carry `out_of_scope: true` and the reason); a real
16559/// run appends one
16560/// `EvolutionTriggered` event (`data.source = "evolution.run"`) to the session
16561/// event log and, when bound to a lifecycle agent, persists the per-agent
16562/// memgine after a cycle that applied something (S1).
16563/// Where one mutation's candidate telemetry came from — or why there is none.
16564///
16565/// The gate's input is never fabricated, so "no candidate metrics" and "the
16566/// measurement failed" are separate outcomes with separate statuses, and
16567/// neither is ever rounded to a zero document.
16568enum CandidateSource {
16569    /// Measured out of process by the caller (`harness_candidate_metrics`).
16570    Supplied(car_eventlog::harness_metrics::HarnessMetrics),
16571    /// Measured in process by this cycle's [`HarnessMeasurer`](crate::evolution::HarnessMeasurer).
16572    Measured(car_eventlog::harness_metrics::HarnessMetrics),
16573    /// A measurement was attempted and errored. Nothing is applied, nothing is
16574    /// synthesized, and the cycle continues to the next mutation.
16575    Failed(String),
16576    /// No measurement exists or could be taken, with the reason — routes to
16577    /// HITL exactly as it did before in-daemon measurement existed.
16578    Unavailable(String),
16579}
16580
16581async fn handle_evolution_run(
16582    req: &JsonRpcMessage,
16583    session: &crate::session::ClientSession,
16584    state: &Arc<ServerState>,
16585) -> Result<Value, String> {
16586    use car_memgine::harness_evolution::{
16587        mutation_fingerprint, EvolutionAgent, Governance, PromotionDecision,
16588    };
16589    use car_memgine::self_evolution::{EvolutionOutcome, EvolvableComponent};
16590
16591    // Non-overlap guard (S3): the dispatcher spawns a task per request, so
16592    // even one connection can land two evolution.run frames concurrently —
16593    // which would double-dispatch consolidate/evolve and race the harness
16594    // apply. Held (RAII) until this handler returns.
16595    let _cycle_token = session
16596        .evolution_guard
16597        .try_begin()
16598        .ok_or("evolution.run already in flight on this session")?;
16599
16600    let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16601        req.params
16602            .get("policy")
16603            .cloned()
16604            .unwrap_or_else(|| serde_json::json!({})),
16605    )
16606    .map_err(|e| format!("invalid policy: {e}"))?;
16607    let dry_run = req
16608        .params
16609        .get("dry_run")
16610        .and_then(|v| v.as_bool())
16611        .unwrap_or(false);
16612    let baseline_override: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16613        match req.params.get("harness_baseline_metrics") {
16614            Some(v) if !v.is_null() => Some(
16615                serde_json::from_value(v.clone())
16616                    .map_err(|e| format!("invalid harness_baseline_metrics: {e}"))?,
16617            ),
16618            _ => None,
16619        };
16620    let candidate_metrics: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16621        match req.params.get("harness_candidate_metrics") {
16622            Some(v) if !v.is_null() => Some(
16623                serde_json::from_value(v.clone())
16624                    .map_err(|e| format!("invalid harness_candidate_metrics: {e}"))?,
16625            ),
16626            _ => None,
16627        };
16628
16629    // In-daemon measurement (opt-in). Parsed before anything runs so a
16630    // malformed request costs nothing.
16631    let measure_request = crate::evolution::parse_harness_measure_request(&req.params)?;
16632
16633    // 1. Mutual exclusion. Measuring and handing metrics in are two answers to
16634    //    the same question; picking a winner silently would let a caller
16635    //    believe the daemon graded a replay it never ran (or the reverse).
16636    if measure_request.is_some() && (baseline_override.is_some() || candidate_metrics.is_some()) {
16637        let supplied = match (baseline_override.is_some(), candidate_metrics.is_some()) {
16638            (true, true) => "harness_baseline_metrics and harness_candidate_metrics",
16639            (true, false) => "harness_baseline_metrics",
16640            _ => "harness_candidate_metrics",
16641        };
16642        return Err(format!(
16643            "harness_measure cannot be combined with {supplied}: harness_measure makes the \
16644             daemon measure the baseline and every candidate itself, so supplied metrics would \
16645             have to be either ignored or silently preferred. Send one or the other."
16646        ));
16647    }
16648
16649    // 2. dry_run measures NOTHING. A benchmark replay is a paid side effect and
16650    //    dry_run skips every side effect.
16651    let measuring = measure_request.is_some() && !dry_run;
16652
16653    // 3. No measurer installed on this build → error, not a quiet fall back to
16654    //    HITL. An opt-in that silently does nothing would report an unattended
16655    //    cycle that never measured anything.
16656    let measurer = if measuring {
16657        Some(state.harness_measurer().ok_or(
16658            "harness_measure requested but this daemon build has no in-process harness \
16659             evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16660             startup; embedders must call ServerState::set_harness_measurer)",
16661        )?)
16662    } else {
16663        None
16664    };
16665
16666    // 3b. The CONTEXT arm's pre-activation grader, opt-in via `context_measure`
16667    //     and resolved off the same installed measurer — the replay is the same
16668    //     replay over the same split, and only which config is varied differs.
16669    //
16670    //     Two differences from `harness_measure` above, both deliberate:
16671    //
16672    //     - It is NOT mutually exclusive with `harness_measure`. They grade
16673    //       different pillars against different configs, and a caller who wants
16674    //       one cycle to do both is asking for two independent measurements,
16675    //       not two answers to the same question. (It costs two more replays;
16676    //       that is the caller's explicit choice.)
16677    //     - The measurer is resolved even under `dry_run`, where the harness
16678    //       arm skips resolution entirely. A dry run is supposed to report what
16679    //       a REAL run would do, and what a real run would do on a build with
16680    //       no evaluator installed is fail — reporting "nothing was measured
16681    //       because this was a dry run" would hide that. The dry-run rule
16682    //       itself (no paid replay) is enforced inside
16683    //       `run_context_evolution`, which is also what lets its pending reason
16684    //       name `dry_run` as the precondition that was missing.
16685    let context_measure_request = crate::evolution::parse_context_measure_request(&req.params)?;
16686    let context_measurer = match context_measure_request.as_ref() {
16687        Some(_) => Some(state.harness_measurer().ok_or(
16688            "context_measure requested but this daemon build has no in-process harness \
16689             evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16690             startup; embedders must call ServerState::set_harness_measurer)",
16691        )?),
16692        None => None,
16693    };
16694
16695    // The config the whole comparison is anchored on: the session runtime's
16696    // live `HarnessConfig` at cycle start (`None` = the runtime default).
16697    // Captured ONCE — a mutation that applies mid-cycle must not move the base
16698    // for the next mutation's candidate, or that candidate would be graded
16699    // against a baseline measured under a different config.
16700    let live_harness_config = session.runtime.harness_config().await;
16701
16702    // 4. The baseline, measured once under that live config. An error here
16703    //    fails the Harness STEP (recorded, nothing applied) rather than the
16704    //    whole cycle — Memory and Skills are unaffected by a bench failure.
16705    let mut baseline_measurement_error: Option<String> = None;
16706    let mut measured_baseline: Option<car_eventlog::harness_metrics::HarnessMetrics> = None;
16707    if let (Some(m), Some(rq)) = (measurer.as_ref(), measure_request.as_ref()) {
16708        match crate::evolution::measure_baseline(m.as_ref(), rq, live_harness_config.as_ref()).await
16709        {
16710            Ok(metrics) => measured_baseline = Some(metrics),
16711            Err(e) => baseline_measurement_error = Some(e),
16712        }
16713    }
16714
16715    let mut components = assemble_evolution_components(session, state).await;
16716    // S1: the same engine the assembler planned over — the daemon-owned
16717    // per-agent engine for bound sessions, else the session engine.
16718    let engine_arc = session.effective_memgine().await;
16719
16720    // Pre-fold the session-log-derived executor inputs once (single lock).
16721    let (failed_events, live_metrics) = {
16722        let log = session.runtime.log.lock().await;
16723        (
16724            crate::evolution::failed_trace_events(log.events()),
16725            car_eventlog::harness_metrics::compute_harness_metrics(log.events()),
16726        )
16727    };
16728    // A daemon-measured baseline is the declared telemetry exactly like a
16729    // supplied one — same document, same role — so the telemetry that elects
16730    // the Harness component is the telemetry that diagnoses it.
16731    let has_baseline_override = baseline_override.is_some() || measured_baseline.is_some();
16732    let baseline_metrics = measured_baseline
16733        .clone()
16734        .or(baseline_override)
16735        .unwrap_or(live_metrics);
16736    // A caller-supplied baseline is the declared harness telemetry for this
16737    // cycle: it drives the diagnosis below AND the Harness planning signal —
16738    // otherwise a held-out baseline could diagnose mutations the plan never
16739    // dispatches (the live session log may be empty).
16740    if has_baseline_override {
16741        components
16742            .retain(|c| c.component != car_memgine::self_evolution::EvolvableComponent::Harness);
16743        if let Some(h) = crate::evolution::harness_component_from_metrics(&baseline_metrics) {
16744            components.push(h);
16745        }
16746    }
16747    let harness_mutations = EvolutionAgent::new().diagnose(&baseline_metrics);
16748
16749    // 6. Auditability: what was measured, on which split, under which model,
16750    //    and the full baseline document the verdicts were computed against.
16751    //    Metrics are numbers; publishing them is what makes a promotion
16752    //    re-derivable by whoever reviews the cycle.
16753    //
16754    //    A FAILED baseline replay gets a shape of its own here rather than
16755    //    being left implicit: it is the one outcome where the response would
16756    //    otherwise read as a clean cycle. Reported at step 7 below whether or
16757    //    not the Harness component was ever elected.
16758    let measurement_summary: Option<Value> = measure_request.as_ref().map(|rq| {
16759        let mut o = serde_json::json!({
16760            "split": rq.split,
16761            "model": rq.model,
16762            "split_seed": rq.split_seed,
16763        });
16764        let obj = o.as_object_mut().expect("object literal");
16765        if dry_run {
16766            obj.insert("status".into(), Value::from("skipped_dry_run"));
16767        } else if let Some(b) = &measured_baseline {
16768            obj.insert("status".into(), Value::from("measured"));
16769            obj.insert(
16770                "baseline_metrics".into(),
16771                serde_json::to_value(b).unwrap_or(Value::Null),
16772            );
16773        } else {
16774            obj.insert("status".into(), Value::from("measurement_failed"));
16775            obj.insert(
16776                "error".into(),
16777                baseline_measurement_error
16778                    .clone()
16779                    .map(Value::from)
16780                    .unwrap_or(Value::Null),
16781            );
16782        }
16783        o
16784    });
16785
16786    let pending_approvals: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
16787    let failed_events = &failed_events;
16788    let harness_mutations = &harness_mutations;
16789    let baseline_metrics = &baseline_metrics;
16790    let candidate_metrics = &candidate_metrics;
16791    let pending_ref = &pending_approvals;
16792    let engine_ref = &engine_arc;
16793    let measurer_ref = &measurer;
16794    let measure_request_ref = &measure_request;
16795    let context_measurer_ref = &context_measurer;
16796    let context_measure_request_ref = &context_measure_request;
16797    // The base every candidate config is projected from: the live config, or
16798    // the runtime default when none is installed (which is what the baseline
16799    // replay ran under).
16800    let base_harness_config = live_harness_config.clone().unwrap_or_default();
16801    let base_harness_config = &base_harness_config;
16802    let baseline_measurement_error = &baseline_measurement_error;
16803    let measurement_summary = &measurement_summary;
16804
16805    let run = |component: EvolvableComponent| {
16806        let engine = engine_ref.clone();
16807        async move {
16808            match component {
16809                EvolvableComponent::Memory => {
16810                    crate::evolution::run_memory_evolution(&engine, dry_run).await
16811                }
16812                EvolvableComponent::Skills => {
16813                    crate::evolution::run_skills_evolution(&engine, failed_events, dry_run).await
16814                }
16815                EvolvableComponent::Harness => {
16816                    // A failed baseline replay fails THIS step and nothing
16817                    // else: no diagnosis it produced can be trusted, nothing is
16818                    // applied, and no metrics are synthesized to stand in.
16819                    if let Some(e) = baseline_measurement_error {
16820                        return Err(e.clone());
16821                    }
16822                    if harness_mutations.is_empty() {
16823                        return Ok(EvolutionOutcome::no_op(
16824                            "no harness mutations diagnosed from session telemetry",
16825                        ));
16826                    }
16827                    let agent = EvolutionAgent::new();
16828                    let mut details: Vec<Value> = Vec::new();
16829                    let mut applied = 0usize;
16830                    let mut pending = 0usize;
16831                    for m in harness_mutations {
16832                        // C2: the fingerprint binds the authorized CHANGE
16833                        // (component + patch content), not diagnostic prose —
16834                        // stable across re-diagnoses, so a standing approval
16835                        // matches.
16836                        let fingerprint = mutation_fingerprint(m);
16837                        // C1: prior decisions come from the daemon's SHARED
16838                        // durable ledger — the approver is typically another
16839                        // connection (a host UI).
16840                        let prior = {
16841                            let ledger = state.approval_ledger.read().await;
16842                            ledger.lookup(&fingerprint).map(|r| r.decision)
16843                        };
16844                        // Authorization resolution, most binding first: a
16845                        // durable human decision, then the regression gate
16846                        // (only when the caller measured candidate telemetry),
16847                        // else HITL. A pending entry is always LISTED —
16848                        // reporting what needs approval is response data;
16849                        // dry_run only skips real side effects.
16850                        let status: Value = match prior {
16851                            Some(car_policy::ApprovalDecision::Rejected) => {
16852                                serde_json::json!({ "status": "rejected_by_operator" })
16853                            }
16854                            Some(car_policy::ApprovalDecision::Approved) => {
16855                                if m.patch.is_none() {
16856                                    serde_json::json!({
16857                                        "status": "approved_no_patch",
16858                                        "note": "approved but carries no concrete config patch — a human designs this change",
16859                                    })
16860                                } else if dry_run {
16861                                    serde_json::json!({ "status": "would_apply", "governance": "human_approved" })
16862                                } else {
16863                                    // S3: one atomic read-modify-write under
16864                                    // the runtime's config write lock.
16865                                    match session
16866                                        .runtime
16867                                        .update_harness_config(|cfg| {
16868                                            cfg.apply(m, Governance::HumanApproved)
16869                                        })
16870                                        .await
16871                                    {
16872                                        Ok(inverse) => {
16873                                            applied += 1;
16874                                            serde_json::json!({
16875                                                "status": "applied",
16876                                                "governance": "human_approved",
16877                                                "rollback_patch": inverse,
16878                                            })
16879                                        }
16880                                        Err(e) => serde_json::json!({
16881                                            "status": "apply_failed", "error": e,
16882                                        }),
16883                                    }
16884                                }
16885                            }
16886                            None => {
16887                                // Resolve this mutation's candidate telemetry:
16888                                // the caller's measured document, or — when
16889                                // in-daemon measuring is on — a replay we run
16890                                // ourselves under the candidate config.
16891                                const NO_CANDIDATE_REASON: &str = "no held-out candidate telemetry supplied — the regression gate needs measured post-mutation metrics (pass harness_candidate_metrics, or harness_measure to have the daemon measure them), so activation requires operator approval";
16892                                const DRY_RUN_REASON: &str = "harness_measure was requested with dry_run — a benchmark replay is a paid side effect and dry_run performs none, so nothing was measured and activation requires operator approval";
16893                                const NO_PATCH_REASON: &str = "carries no concrete config patch, so there is nothing to apply and nothing to measure — a human designs this change";
16894                                const SAFETY_SKIP_REASON: &str = "measurement skipped: the component is safety-affecting, so it can never auto-promote however it measures — spending a benchmark replay to reach an outcome already decided would be waste. Activation requires operator approval";
16895
16896                                let source = if let Some(cand) = candidate_metrics {
16897                                    CandidateSource::Supplied(cand.clone())
16898                                } else if let (Some(measurer), Some(rq)) =
16899                                    (measurer_ref.as_ref(), measure_request_ref.as_ref())
16900                                {
16901                                    match m.patch.as_ref().filter(|p| !p.is_empty()) {
16902                                        None => CandidateSource::Unavailable(
16903                                            NO_PATCH_REASON.to_string(),
16904                                        ),
16905                                        Some(_) if m.requires_human_approval() => {
16906                                            CandidateSource::Unavailable(
16907                                                SAFETY_SKIP_REASON.to_string(),
16908                                            )
16909                                        }
16910                                        Some(patch) => {
16911                                            match crate::evolution::measure_candidate(
16912                                                measurer.as_ref(),
16913                                                rq,
16914                                                base_harness_config,
16915                                                patch,
16916                                            )
16917                                            .await
16918                                            {
16919                                                Ok(metrics) => CandidateSource::Measured(metrics),
16920                                                Err(e) => CandidateSource::Failed(e),
16921                                            }
16922                                        }
16923                                    }
16924                                } else if measure_request_ref.is_some() {
16925                                    CandidateSource::Unavailable(DRY_RUN_REASON.to_string())
16926                                } else {
16927                                    CandidateSource::Unavailable(NO_CANDIDATE_REASON.to_string())
16928                                };
16929
16930                                // Audit the document the verdict was computed
16931                                // from — a decision nobody can re-derive is not
16932                                // an audited decision.
16933                                let measured_here = matches!(source, CandidateSource::Measured(_));
16934
16935                                match source {
16936                                    CandidateSource::Supplied(ref cand)
16937                                    | CandidateSource::Measured(ref cand) => {
16938                                        let decision = agent.evaluate(m, baseline_metrics, cand);
16939                                        let mut status = match &decision {
16940                                            PromotionDecision::Promote { reason } => {
16941                                                let reason = reason.clone();
16942                                                if dry_run {
16943                                                    serde_json::json!({ "status": "would_apply", "governance": "promoted", "reason": reason })
16944                                                } else {
16945                                                    match session
16946                                                        .runtime
16947                                                        .update_harness_config(|cfg| {
16948                                                            cfg.apply(
16949                                                                m,
16950                                                                Governance::Promoted(
16951                                                                    decision.clone(),
16952                                                                ),
16953                                                            )
16954                                                        })
16955                                                        .await
16956                                                    {
16957                                                        Ok(inverse) => {
16958                                                            applied += 1;
16959                                                            serde_json::json!({
16960                                                                "status": "applied",
16961                                                                "governance": "promoted",
16962                                                                "reason": reason,
16963                                                                "rollback_patch": inverse,
16964                                                            })
16965                                                        }
16966                                                        Err(e) => serde_json::json!({
16967                                                            "status": "apply_failed", "error": e,
16968                                                        }),
16969                                                    }
16970                                                }
16971                                            }
16972                                            PromotionDecision::NeedsApproval { reason } => {
16973                                                pending += 1;
16974                                                pending_ref.lock().unwrap().push(
16975                                                    serde_json::json!({
16976                                                        "fingerprint": fingerprint,
16977                                                        "mutation": m.id,
16978                                                        "component": m.contract.component,
16979                                                        "safety_affecting": true,
16980                                                        "rationale": m.rationale,
16981                                                        "reason": reason,
16982                                                    }),
16983                                                );
16984                                                serde_json::json!({ "status": "pending_approval", "reason": reason })
16985                                            }
16986                                            PromotionDecision::Reject { reason } => {
16987                                                serde_json::json!({ "status": "rejected_by_gate", "reason": reason })
16988                                            }
16989                                            // No verdict: the two telemetry
16990                                            // documents are not measurable against
16991                                            // each other (today, task pass rates
16992                                            // over different task sets). Reported
16993                                            // as its own status rather than folded
16994                                            // into `rejected_by_gate` — the caller
16995                                            // needs to fix the comparison and
16996                                            // re-run, which is different advice
16997                                            // from "this mutation is bad".
16998                                            // Nothing is applied either way.
16999                                            PromotionDecision::Incomparable { reason } => {
17000                                                serde_json::json!({ "status": "incomparable", "reason": reason })
17001                                            }
17002                                        };
17003                                        if measured_here {
17004                                            if let Some(obj) = status.as_object_mut() {
17005                                                obj.insert(
17006                                                    "candidate_metrics".into(),
17007                                                    serde_json::to_value(cand)
17008                                                        .unwrap_or(Value::Null),
17009                                                );
17010                                            }
17011                                        }
17012                                        status
17013                                    }
17014                                    // A measurement that errored is reported as
17015                                    // exactly that. Nothing is applied, nothing is
17016                                    // fabricated, and the cycle moves on to the
17017                                    // next mutation.
17018                                    CandidateSource::Failed(error) => serde_json::json!({
17019                                        "status": "measurement_failed",
17020                                        "error": error,
17021                                    }),
17022                                    // No candidate telemetry → the regression gate
17023                                    // cannot be run honestly in-cycle; every
17024                                    // activation routes to HITL. Patchless
17025                                    // mutations are proposals a human addresses
17026                                    // either way.
17027                                    CandidateSource::Unavailable(reason) => {
17028                                        pending += 1;
17029                                        pending_ref.lock().unwrap().push(serde_json::json!({
17030                                            "fingerprint": fingerprint,
17031                                            "mutation": m.id,
17032                                            "component": m.contract.component,
17033                                            "safety_affecting": m.requires_human_approval(),
17034                                            "rationale": m.rationale,
17035                                            "reason": reason,
17036                                        }));
17037                                        serde_json::json!({
17038                                            "status": "pending_approval",
17039                                            "reason": reason,
17040                                        })
17041                                    }
17042                                }
17043                            }
17044                        };
17045                        let mut d = serde_json::json!({
17046                            "mutation": m.id,
17047                            "component": m.contract.component,
17048                            "fingerprint": fingerprint,
17049                            "rationale": m.rationale,
17050                        });
17051                        if let (Some(obj), Some(s)) = (d.as_object_mut(), status.as_object()) {
17052                            for (k, v) in s {
17053                                obj.insert(k.clone(), v.clone());
17054                            }
17055                        }
17056                        details.push(d);
17057                    }
17058                    let mut summary_obj = serde_json::json!({
17059                        "mechanism": "harness_evolution",
17060                        "mutations": harness_mutations.len(),
17061                        "applied": applied,
17062                        "pending": pending,
17063                        "details": details,
17064                    });
17065                    if let (Some(obj), Some(measurement)) =
17066                        (summary_obj.as_object_mut(), measurement_summary.as_ref())
17067                    {
17068                        obj.insert("measurement".into(), measurement.clone());
17069                    }
17070                    let summary = serde_json::to_string(&summary_obj).map_err(|e| e.to_string())?;
17071                    // S2: "evolved" means a patch actually landed — an
17072                    // all-pending/all-rejected pass is a no-op.
17073                    Ok(if applied > 0 {
17074                        EvolutionOutcome::applied(summary)
17075                    } else {
17076                        EvolutionOutcome::no_op(summary)
17077                    })
17078                }
17079                EvolvableComponent::Context => {
17080                    // `None` backoff: a session-driven run is a person asking
17081                    // for the check now. The unattended cadence is the loop
17082                    // that needs a brake, and it passes `Some`.
17083                    //
17084                    // The measure pair is `Some` exactly when the caller
17085                    // supplied `context_measure` (the measurer resolution
17086                    // above already erred if this build cannot measure), which
17087                    // is what turns the Context pillar unattended: a graded
17088                    // mutation promotes or is rejected without ever reaching
17089                    // the ledger.
17090                    let context_measure = match (
17091                        context_measurer_ref.as_ref(),
17092                        context_measure_request_ref.as_ref(),
17093                    ) {
17094                        (Some(m), Some(rq)) => {
17095                            Some((m.as_ref() as &dyn crate::evolution::HarnessMeasurer, rq))
17096                        }
17097                        _ => None,
17098                    };
17099                    crate::evolution::run_context_evolution(
17100                        &engine,
17101                        state,
17102                        dry_run,
17103                        pending_ref,
17104                        None,
17105                        context_measure,
17106                    )
17107                    .await
17108                }
17109                // A deliberate scope decision, reported as one. Returning an
17110                // `Err` here recorded `ran: false` — the same shape a crashed
17111                // mechanism produces — so a boundary the project chose on
17112                // purpose read as a failing subsystem in every cycle report.
17113                EvolvableComponent::Tools => Ok(EvolutionOutcome::out_of_scope(
17114                    crate::evolution::TOOLS_OUT_OF_SCOPE_REASON,
17115                )),
17116            }
17117        }
17118    };
17119
17120    let report = car_memgine::self_evolution::run_evolution_cycle(&components, &policy, run).await;
17121
17122    if !dry_run {
17123        let mut data: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
17124        data.insert("source".into(), Value::from("evolution.run"));
17125        data.insert(
17126            "evolve_now".into(),
17127            serde_json::to_value(&report.plan.evolve_now).unwrap_or(Value::Null),
17128        );
17129        data.insert(
17130            "evolved".into(),
17131            serde_json::to_value(&report.evolved).unwrap_or(Value::Null),
17132        );
17133        session.runtime.log.lock().await.append(
17134            car_eventlog::EventKind::EvolutionTriggered,
17135            None,
17136            None,
17137            data,
17138        );
17139        // S1: a bound lifecycle agent's engine is daemon-persisted after a
17140        // mutating run, like memory.add/memory.consolidate.
17141        if !report.evolved.is_empty() {
17142            if let Some(id) = session.agent_id.lock().await.clone() {
17143                if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
17144                    tracing::warn!(agent_id = %id, error = %e,
17145                        "agent memgine persist after evolution.run failed; in-memory state is canonical");
17146                }
17147            }
17148        }
17149    }
17150
17151    let pending = pending_approvals.into_inner().unwrap();
17152    let mut resp = serde_json::json!({
17153        "plan": report.plan,
17154        "steps": report.steps,
17155        "evolved": report.evolved,
17156        // Components this cycle deliberately did not evolve. Always present
17157        // (empty when there were none) so a caller can distinguish "no boundary
17158        // was hit" from "this daemon predates the field".
17159        "out_of_scope": report.out_of_scope,
17160    });
17161    if !pending.is_empty() {
17162        resp.as_object_mut()
17163            .unwrap()
17164            .insert("pending_approvals".into(), Value::Array(pending));
17165    }
17166    // 7. The measurement is reported at the TOP LEVEL, not only inside the
17167    //    Harness step.
17168    //
17169    //    A benchmark replay is a paid side effect — real model calls, real
17170    //    money — and a side effect nobody can see in the response is one nobody
17171    //    can audit. The baseline replay runs BEFORE the plan is assembled (it
17172    //    is what supplies the Harness planning signal), but the plan may
17173    //    legitimately never reach the Harness arm: `harness_component_from_
17174    //    metrics` returns `None` at zero attempts, and an elected component can
17175    //    still `skip` (pressure below threshold) or `defer` (evidence below
17176    //    `min_evidence`). Reported only from the step, a caller who asked for
17177    //    `harness_measure` on a healthy harness would be billed for a replay
17178    //    the response never mentions — and a replay that FAILED would vanish
17179    //    the same way, leaving a normal-looking cycle with no hint that the
17180    //    measurement they explicitly requested never happened.
17181    //
17182    //    So this key is present whenever `harness_measure` was requested, in
17183    //    every shape the measurement can end in: `measured`,
17184    //    `skipped_dry_run`, or `measurement_failed` carrying the error. The
17185    //    Harness step keeps its own copy — the elected case loses nothing.
17186    if let Some(measurement) = measurement_summary.as_ref() {
17187        resp.as_object_mut()
17188            .unwrap()
17189            .insert("measurement".into(), measurement.clone());
17190    }
17191    Ok(resp)
17192}
17193
17194/// Repair a degraded skill on this client's session memgine.
17195/// Returns `{ code: "..." }` on success, `null` if the skill
17196/// isn't broken or repair failed.
17197async fn handle_skill_repair(
17198    msg: &JsonRpcMessage,
17199    session: &crate::session::ClientSession,
17200) -> Result<Value, String> {
17201    let name = msg
17202        .params
17203        .get("skill_name")
17204        .and_then(|v| v.as_str())
17205        .ok_or("missing 'skill_name' parameter")?;
17206    let mut engine = session.memgine.lock().await;
17207    let code = engine.repair_skill(name).await;
17208    Ok(match code {
17209        Some(c) => serde_json::json!({ "code": c }),
17210        None => Value::Null,
17211    })
17212}
17213
17214/// Ingest distilled skills into this client's session memgine.
17215/// Returns the number of nodes inserted.
17216async fn handle_skills_ingest_distilled(
17217    msg: &JsonRpcMessage,
17218    session: &crate::session::ClientSession,
17219) -> Result<Value, String> {
17220    let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17221        msg.params
17222            .get("skills")
17223            .cloned()
17224            .unwrap_or(msg.params.clone()),
17225    )
17226    .map_err(|e| format!("invalid skills: {}", e))?;
17227    let mut engine = session.memgine.lock().await;
17228    let nodes = engine.ingest_distilled_skills(&skills);
17229    Ok(serde_json::json!({ "ingested": nodes.len() }))
17230}
17231
17232/// Run skill evolution against this session's memgine for a
17233/// specified domain.  Returns the resulting `DistilledSkill` array.
17234async fn handle_skills_evolve(
17235    msg: &JsonRpcMessage,
17236    session: &crate::session::ClientSession,
17237) -> Result<Value, String> {
17238    let domain = msg
17239        .params
17240        .get("domain")
17241        .and_then(|v| v.as_str())
17242        .ok_or("missing 'domain' parameter")?
17243        .to_string();
17244    let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
17245        msg.params
17246            .get("events")
17247            .cloned()
17248            .unwrap_or(Value::Array(vec![])),
17249    )
17250    .map_err(|e| format!("invalid events: {}", e))?;
17251    let mut engine = session.memgine.lock().await;
17252    let skills = engine.evolve_skills(&events, &domain).await;
17253    serde_json::to_value(&skills).map_err(|e| e.to_string())
17254}
17255
17256/// List domains whose skills are underperforming on this session.
17257async fn handle_skills_domains_needing_evolution(
17258    msg: &JsonRpcMessage,
17259    session: &crate::session::ClientSession,
17260) -> Result<Value, String> {
17261    let threshold = msg
17262        .params
17263        .get("threshold")
17264        .and_then(|v| v.as_f64())
17265        .unwrap_or(0.6);
17266    let engine = session.memgine.lock().await;
17267    let domains = engine.domains_needing_evolution(threshold);
17268    serde_json::to_value(&domains).map_err(|e| e.to_string())
17269}
17270
17271/// Ingest distilled/evolved skills as PROVISIONAL candidates on trial
17272/// (validation-gated optimization — see docs/solutions/gated-skill-optimization.md).
17273/// Unlike `skills.ingest_distilled`, these must prove themselves before the
17274/// promotion gate makes them Active. Returns `{ ingested: n }` (drops
17275/// already-rejected or already-trialing candidates).
17276async fn handle_skills_ingest_provisional(
17277    msg: &JsonRpcMessage,
17278    session: &crate::session::ClientSession,
17279) -> Result<Value, String> {
17280    let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17281        msg.params
17282            .get("skills")
17283            .cloned()
17284            .unwrap_or(msg.params.clone()),
17285    )
17286    .map_err(|e| format!("invalid skills: {}", e))?;
17287    let tenant = msg.params.get("tenant").and_then(|v| v.as_str());
17288    let mut engine = session.memgine.lock().await;
17289    let ingested = engine.ingest_provisional_candidates(&skills, tenant);
17290    Ok(serde_json::json!({ "ingested": ingested }))
17291}
17292
17293/// Run the skill promotion gate against this session's memgine: provisional
17294/// candidates with enough trial outcomes are promoted (strictly-better Wilson
17295/// lower bound) or rejected. Normally fires automatically in `consolidate()`;
17296/// this exposes a manual trigger. Returns `{ promoted: [...], rejected: [...] }`.
17297async fn handle_skills_gate(
17298    _msg: &JsonRpcMessage,
17299    session: &crate::session::ClientSession,
17300) -> Result<Value, String> {
17301    let mut engine = session.memgine.lock().await;
17302    let (promoted, rejected) = engine.gate_skill_candidates();
17303    Ok(serde_json::json!({ "promoted": promoted, "rejected": rejected }))
17304}
17305
17306/// Fetch a skill's full `SkillMeta` by key — including lifecycle `status`
17307/// (active/provisional), `incumbent`, `version`, and `stats`. Returns the JSON
17308/// SkillMeta, or `null` if no active skill node holds the key.
17309async fn handle_skill_meta(
17310    msg: &JsonRpcMessage,
17311    session: &crate::session::ClientSession,
17312) -> Result<Value, String> {
17313    let key = msg
17314        .params
17315        .get("key")
17316        .and_then(|v| v.as_str())
17317        .ok_or("missing 'key' parameter")?;
17318    let engine = session.memgine.lock().await;
17319    match engine.skill_meta(key) {
17320        Some(meta) => serde_json::to_value(&meta).map_err(|e| e.to_string()),
17321        None => Ok(Value::Null),
17322    }
17323}
17324
17325/// Export a VALIDATED skill as a portable markdown document (the SkillOpt
17326/// best_skill.md analog). Only Active, healthy skills export; returns the
17327/// markdown string, or `null` if the key is absent / not exportable.
17328async fn handle_skill_export(
17329    msg: &JsonRpcMessage,
17330    session: &crate::session::ClientSession,
17331) -> Result<Value, String> {
17332    let key = msg
17333        .params
17334        .get("key")
17335        .and_then(|v| v.as_str())
17336        .ok_or("missing 'key' parameter")?;
17337    let engine = session.memgine.lock().await;
17338    Ok(engine
17339        .export_skill(key)
17340        .map(Value::String)
17341        .unwrap_or(Value::Null))
17342}
17343
17344/// Import a skill from a portable markdown document (digest-verified). Ingests
17345/// as a fresh Active skill. Returns `{ imported: true }`, or a JSON-RPC error if
17346/// the document is malformed or its content digest doesn't verify.
17347async fn handle_skill_import(
17348    msg: &JsonRpcMessage,
17349    session: &crate::session::ClientSession,
17350) -> Result<Value, String> {
17351    let md = msg
17352        .params
17353        .get("markdown")
17354        .and_then(|v| v.as_str())
17355        .ok_or("missing 'markdown' parameter")?;
17356    let mut engine = session.memgine.lock().await;
17357    engine.import_skill_markdown(md)?;
17358    Ok(serde_json::json!({ "imported": true }))
17359}
17360
17361/// Rerank documents against a query using a cross-encoder model.
17362async fn handle_rerank(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17363    let engine = get_inference_engine(state);
17364    let req: car_inference::RerankRequest = typed_params(&msg.params)?;
17365    let _permit = state.admission.acquire().await;
17366    let result = engine.rerank(req).await.map_err(|e| e.to_string())?;
17367    serde_json::to_value(&result).map_err(|e| e.to_string())
17368}
17369
17370/// Transcribe audio at the given path. The path is interpreted on
17371/// the daemon's filesystem, not the FFI caller's — Daemon-mode
17372/// callers must pass a path the daemon can read (typically a
17373/// shared `~/.car/...` location or stdin push via the streaming
17374/// API).
17375/// `search` — web search, fulfilled natively (Parslee-hosted when signed in,
17376/// else a bring-your-own Tavily key). Engine-independent; resolves the provider
17377/// from the environment. See `car_inference::search`.
17378async fn handle_search(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17379    let req: car_inference::search::SearchRequest =
17380        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17381    let _permit = state.admission.acquire().await;
17382    let result = car_inference::search::web_search(&req.query, req.max_results)
17383        .await
17384        .map_err(|e| e.to_string())?;
17385    serde_json::to_value(&result).map_err(|e| e.to_string())
17386}
17387
17388/// `web_fetch` — fetch a URL and extract readable text. Keyless; the companion
17389/// to `search`. See `car_inference::search::web_fetch`.
17390async fn handle_web_fetch(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17391    let req: car_inference::search::FetchRequest =
17392        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17393    let _permit = state.admission.acquire().await;
17394    let result = car_inference::search::web_fetch(&req.url)
17395        .await
17396        .map_err(|e| e.to_string())?;
17397    serde_json::to_value(&result).map_err(|e| e.to_string())
17398}
17399
17400async fn handle_transcribe(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17401    use base64::Engine as _;
17402    let engine = get_inference_engine(state);
17403
17404    // Sandbox-crossing escape hatch (Parslee-ai/car-releases#31): when
17405    // the caller can't share a filesystem view with the daemon (e.g.
17406    // unsandboxed Milo talking to a sandboxed car-host), they pass
17407    // `audio_b64` instead of `audio_path`. We decode to a tempfile,
17408    // run transcribe against the path the engine expects, and clean up
17409    // on drop. Accepts either form; `audio_b64` wins if both are set.
17410    let mut params = msg.params.clone();
17411    let audio_b64 = params
17412        .as_object_mut()
17413        .and_then(|m| m.remove("audio_b64"))
17414        .and_then(|v| v.as_str().map(str::to_string));
17415    let _tmp_audio = if let Some(b64) = audio_b64 {
17416        let bytes = base64::engine::general_purpose::STANDARD
17417            .decode(b64.as_bytes())
17418            .map_err(|e| format!("audio_b64 decode failed: {e}"))?;
17419        let tmp = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
17420        std::fs::write(tmp.path(), &bytes).map_err(|e| e.to_string())?;
17421        let path = tmp.path().to_string_lossy().into_owned();
17422        if let Some(obj) = params.as_object_mut() {
17423            obj.insert("audio_path".to_string(), Value::String(path));
17424        }
17425        Some(tmp)
17426    } else {
17427        None
17428    };
17429
17430    let req: car_inference::TranscribeRequest =
17431        serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17432    let _permit = state.admission.acquire().await;
17433    let result = engine.transcribe(req).await.map_err(|e| e.to_string())?;
17434    serde_json::to_value(&result).map_err(|e| e.to_string())
17435}
17436
17437/// Synthesize speech. By default writes to `output_path` on the
17438/// daemon's filesystem; when `return_b64: true` (or no `output_path`
17439/// was supplied) the result also includes an `audio_b64` field with
17440/// the rendered bytes inline so cross-sandbox callers can avoid
17441/// filesystem coordination. Closes Parslee-ai/car-releases#31.
17442async fn handle_synthesize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17443    use base64::Engine as _;
17444    let engine = get_inference_engine(state);
17445
17446    let mut params = msg.params.clone();
17447    let return_b64 = params
17448        .as_object_mut()
17449        .and_then(|m| m.remove("return_b64"))
17450        .and_then(|v| v.as_bool())
17451        .unwrap_or(false);
17452    let no_output_path = params
17453        .as_object()
17454        .map(|m| !m.contains_key("output_path"))
17455        .unwrap_or(true);
17456
17457    let req: car_inference::SynthesizeRequest =
17458        serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17459    let _permit = state.admission.acquire().await;
17460    let result = engine.synthesize(req).await.map_err(|e| e.to_string())?;
17461    let mut value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
17462
17463    // Inline the bytes when the caller asked for them OR when no
17464    // output_path was specified (typical sandbox-crossing case —
17465    // they didn't pick a path because they have no shared one).
17466    if return_b64 || no_output_path {
17467        let bytes = std::fs::read(&result.audio_path).map_err(|e| {
17468            format!(
17469                "synthesize: failed to read rendered audio at {}: {e}",
17470                result.audio_path
17471            )
17472        })?;
17473        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
17474        if let Some(obj) = value.as_object_mut() {
17475            obj.insert("audio_b64".to_string(), Value::String(encoded));
17476        }
17477    }
17478    Ok(value)
17479}
17480
17481/// Provision the managed speech runtime. Returns its root path as a JSON
17482/// string, mirroring the embedded `prepare_speech_runtime` shape — the same
17483/// root `speech.health` reports. Can take minutes on a fresh machine (venv +
17484/// pip). Not a success signal on Apple Silicon, where the runtime is a
17485/// fallback behind the native MLX backends and a failed bootstrap degrades
17486/// rather than errors; read `speech.health.runtime.installed` (car#649).
17487async fn handle_speech_prepare(state: &ServerState) -> Result<Value, String> {
17488    let engine = get_inference_engine(state);
17489    let status = engine
17490        .prepare_speech_runtime()
17491        .await
17492        .map_err(|e| e.to_string())?;
17493    serde_json::to_value(&status).map_err(|e| e.to_string())
17494}
17495
17496/// Adaptive route decision for a prompt — returns the routing
17497/// JSON the FFI's `route_model` returns.
17498async fn handle_models_route(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17499    let prompt = msg
17500        .params
17501        .get("prompt")
17502        .and_then(|v| v.as_str())
17503        .ok_or("missing 'prompt' parameter")?;
17504    // Optional routing intent — notably `exclude_models` for
17505    // adversarial-reviewer separation (car#358). Absent = no intent.
17506    let intent: Option<car_inference::IntentHint> = match msg.params.get("intent") {
17507        Some(v) if !v.is_null() => {
17508            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid 'intent': {e}"))?)
17509        }
17510        _ => None,
17511    };
17512    let engine = get_inference_engine(state);
17513    let decision = engine.route_adaptive_with_intent(prompt, intent).await;
17514    serde_json::to_value(&decision).map_err(|e| e.to_string())
17515}
17516
17517/// Model performance profiles snapshot.
17518async fn handle_models_stats(state: &ServerState) -> Result<Value, String> {
17519    let engine = get_inference_engine(state);
17520    let profiles = engine.export_profiles().await;
17521    Ok(serde_json::json!({ "profiles": models_stats_view(&profiles) }))
17522}
17523
17524/// `outcomes.scoreboard` — the persistent, OUTCOME-DENOMINATED scoreboard
17525/// folded from the durable outcome ledger: per-model cost-per-success,
17526/// tokens-per-success, and success-rate, plus the deployment headline
17527/// `overall_usd_per_success`. Unlike `models.stats` (the live in-memory
17528/// profiles), this reads the cross-session ledger so it survives restart and is
17529/// de-biased by the pending-sweep. The dollars-per-correct-outcome view the
17530/// "results, not KPIs" thesis is legible in.
17531async fn handle_outcomes_scoreboard(state: &ServerState) -> Result<Value, String> {
17532    let engine = get_inference_engine(state);
17533    let scoreboard = engine.outcome_scoreboard();
17534    serde_json::to_value(&scoreboard).map_err(|e| e.to_string())
17535}
17536
17537/// Build the `models.stats` per-profile view. The raw `ModelProfile`
17538/// serializes counts (`success_count`/`fail_count`) but not the
17539/// `success_rate` / `avg_latency_ms` the tool schema promised — so the schema
17540/// was a contract lie (#335). Rather than drop fields (the CLI's cost join
17541/// needs `total_output_tokens` etc.), serialize the full profile and *add* the
17542/// two derived values. `success_rate` is `number | null` (`null` until
17543/// something resolves — never the router's 0.5 prior), consistent with the
17544/// `concierge.status` health contract.
17545fn models_stats_view(profiles: &[car_inference::ModelProfile]) -> Vec<Value> {
17546    profiles
17547        .iter()
17548        .map(|p| {
17549            let mut v = serde_json::to_value(p).expect("ModelProfile is infallibly serializable");
17550            if let Some(obj) = v.as_object_mut() {
17551                obj.insert(
17552                    "success_rate".into(),
17553                    serde_json::json!(p.success_rate_resolved()),
17554                );
17555                obj.insert(
17556                    "avg_latency_ms".into(),
17557                    serde_json::json!(p.avg_latency_ms()),
17558                );
17559            }
17560            v
17561        })
17562        .collect()
17563}
17564
17565#[cfg(test)]
17566mod models_stats_view_tests {
17567    use super::models_stats_view;
17568
17569    #[test]
17570    fn success_rate_is_null_until_resolved_then_the_real_ratio() {
17571        let mut unmeasured = car_inference::ModelProfile::new("m-new".into());
17572        unmeasured.total_calls = 5; // calls logged, nothing resolved yet
17573
17574        let mut measured = car_inference::ModelProfile::new("m-seen".into());
17575        measured.total_calls = 4;
17576        measured.success_count = 3;
17577        measured.fail_count = 1;
17578
17579        let view = models_stats_view(&[unmeasured, measured]);
17580
17581        // Never-measured: explicit null, NOT a fabricated 0.5.
17582        assert!(view[0]["success_rate"].is_null());
17583        assert_eq!(view[0]["success_count"], 0);
17584        assert_eq!(view[0]["total_calls"], 5);
17585
17586        // Measured: the real resolved ratio, and the declared field names.
17587        assert_eq!(view[1]["success_rate"], 0.75);
17588        assert_eq!(view[1]["fail_count"], 1);
17589        assert_eq!(view[1]["model_id"], "m-seen");
17590    }
17591
17592    #[test]
17593    fn view_is_non_lossy_and_round_trips_into_model_profile() {
17594        // Regression: the CLI (`car models stats`) deserializes the daemon
17595        // payload back into `Vec<ModelProfile>` for its cost join. The derived
17596        // view must therefore stay a *superset* of ModelProfile — adding
17597        // success_rate/avg_latency_ms, never dropping fields like
17598        // total_output_tokens. Guards the wire shape against silent breakage.
17599        let mut p = car_inference::ModelProfile::new("m".into());
17600        p.total_calls = 2;
17601        p.success_count = 1;
17602        p.total_output_tokens = 4096;
17603
17604        let view = models_stats_view(&[p]);
17605        assert_eq!(view[0]["avg_latency_ms"], 0.0); // derived field present
17606        let round: Vec<car_inference::ModelProfile> =
17607            serde_json::from_value(serde_json::Value::Array(view)).expect("round-trips");
17608        assert_eq!(round[0].total_output_tokens, 4096, "field not dropped");
17609        assert_eq!(round[0].model_id, "m");
17610    }
17611}
17612
17613#[derive(Deserialize)]
17614#[serde(rename_all = "camelCase")]
17615struct OutcomesResolvePendingParams {
17616    /// Flat `(trace_id, success, confidence, output)` tuples from the
17617    /// caller. Same shape `car-reason`'s session produces from its
17618    /// `ActionOutcome` vector. Daemon side runs the inference rules
17619    /// and writes resolved outcomes back to the shared tracker.
17620    action_results: Vec<(String, bool, f64, String)>,
17621}
17622
17623/// `outcomes.resolve_pending` — write inferred outcomes back to the
17624/// shared engine's `OutcomeTracker` (Parslee-ai/car#189 follow-up).
17625///
17626/// Symmetric to the in-process path
17627/// `ReasoningInferenceHandle::record_inferred_outcomes` on
17628/// `InferenceEngine`: takes the per-action result tuples the
17629/// reasoning session produces, runs
17630/// `OutcomeTracker::infer_outcomes_from_action_sequence` to convert
17631/// them into `InferredOutcome` records, and calls
17632/// `resolve_pending_from_signals` under the tracker write lock. The
17633/// learning loop that adjusts routing decisions therefore survives
17634/// daemon-routed reasoning runs (previously a best-effort no-op on
17635/// the daemon side).
17636///
17637/// Returns `{ recorded: N }` where N is the number of action results
17638/// the caller passed. The tracker doesn't surface how many of those
17639/// actually had pending entries to resolve; that count would require
17640/// expanding the tracker API and isn't load-bearing for any caller
17641/// yet.
17642async fn handle_outcomes_resolve_pending(
17643    req: &JsonRpcMessage,
17644    state: &ServerState,
17645) -> Result<Value, String> {
17646    let params: OutcomesResolvePendingParams =
17647        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17648    let engine = get_inference_engine(state);
17649    let mut tracker = engine.outcome_tracker.write().await;
17650    let inferred = tracker.infer_outcomes_from_action_sequence(&params.action_results);
17651    tracker.resolve_pending_from_signals(inferred);
17652    Ok(serde_json::json!({ "recorded": params.action_results.len() }))
17653}
17654
17655/// Per-session event log size.
17656async fn handle_events_count(session: &crate::session::ClientSession) -> Result<Value, String> {
17657    let n = session.runtime.log.lock().await.len();
17658    Ok(Value::from(n as u64))
17659}
17660
17661/// `events.query` — structured audit query over the session event log
17662/// (EPIC G / G2). Params are a `car_eventlog::EventQuery`
17663/// (`kinds`/`action_id`/`proposal_id`/`since`/`until`/`data_matches`/`limit`);
17664/// returns the matching events, most-recent-first. `ActionFailed.data` exposes
17665/// `params_digest`, `expected_effects`, and normalized `error_class`
17666/// (`timeout|rejected_by_policy|tool_error|validation|unknown`) without raw
17667/// parameters; `ActionSucceeded.data` carries the first two. Answers "who ran
17668/// what tool when, and which approvals applied" against the SessionScope /
17669/// PermissionDecision / ApprovalRecorded / action trail.
17670async fn handle_events_query(
17671    msg: &JsonRpcMessage,
17672    session: &crate::session::ClientSession,
17673) -> Result<Value, String> {
17674    let query: car_eventlog::EventQuery = if msg.params.is_null() {
17675        Default::default()
17676    } else {
17677        serde_json::from_value(msg.params.clone())
17678            .map_err(|e| format!("invalid events.query params: {e}"))?
17679    };
17680    let log = session.runtime.log.lock().await;
17681    let events = log.query(&query);
17682    Ok(serde_json::json!({
17683        "count": events.len(),
17684        "events": events,
17685    }))
17686}
17687
17688/// Which NLP primitive a `nlp.*` request selects (EPIC F / F4).
17689enum NlpOp {
17690    IdentifyLanguage,
17691    Tokenize,
17692    ExtractEntities,
17693}
17694
17695/// `nlp.identify_language` / `nlp.tokenize` / `nlp.extract_entities` — run a
17696/// stateless NLP primitive over `params.text` (EPIC F / F4). Backed by Apple's
17697/// NaturalLanguage framework on macOS and a pure-Rust fallback elsewhere, so
17698/// non-macOS agents get a real (if lighter) result instead of an error. Every
17699/// result carries `backend` (`"apple"`|`"fallback"`).
17700fn handle_nlp(req: &JsonRpcMessage, op: NlpOp) -> Result<Value, String> {
17701    let text = req
17702        .params
17703        .get("text")
17704        .and_then(|v| v.as_str())
17705        .ok_or("missing 'text'")?;
17706    let json = match op {
17707        NlpOp::IdentifyLanguage => car_ffi_common::nlp::identify_language(text),
17708        NlpOp::Tokenize => car_ffi_common::nlp::tokenize_words(text),
17709        NlpOp::ExtractEntities => car_ffi_common::nlp::extract_named_entities(text),
17710    }?;
17711    serde_json::from_str(&json).map_err(|e| format!("nlp result parse: {e}"))
17712}
17713
17714/// `metrics.summary` — live operational rollup of the session event stream
17715/// (EPIC G / G1): success/error rate, cost, tokens, avg latency, approvals,
17716/// gate rejections, policy violations, and the per-agent cost breakdown. One
17717/// call renders the whole live picture for a host dashboard.
17718async fn handle_metrics_summary(session: &crate::session::ClientSession) -> Result<Value, String> {
17719    let log = session.runtime.log.lock().await;
17720    // summarize_log (not a bare fold over events()) so `cumulative_cost_usd`
17721    // carries the monotonic counter that survives retention trims (G1).
17722    let summary = car_eventlog::summarize_log(&log);
17723    serde_json::to_value(&summary).map_err(|e| format!("serialize metrics.summary: {e}"))
17724}
17725
17726/// `metrics.alerts` — evaluate the live summary against operational thresholds
17727/// (EPIC G / G1). Params are `AlertThresholds`
17728/// (`max_cost_usd`/`max_error_rate`/`max_avg_latency_ms`/
17729/// `max_goals_ungrounded`/`min_actions`).
17730/// Returns `{ summary, alerts }`; every fired alert is also written to the
17731/// operational log (`tracing::warn`) so an operator sees it out-of-band. A
17732/// synthetic cost overage fires the `cost_overage` alert; an ungrounded goal
17733/// verifier pass can fire `goal_ungrounded`.
17734async fn handle_metrics_alerts(
17735    req: &JsonRpcMessage,
17736    session: &crate::session::ClientSession,
17737) -> Result<Value, String> {
17738    let thresholds: car_eventlog::AlertThresholds = if req.params.is_null() {
17739        Default::default()
17740    } else {
17741        serde_json::from_value(req.params.clone())
17742            .map_err(|e| format!("invalid metrics.alerts thresholds: {e}"))?
17743    };
17744    let log = session.runtime.log.lock().await;
17745    // The budget alert reads the monotonic cumulative cost, not a fold over
17746    // the retention-trimmed window — a trim must never un-fire it (G1).
17747    let summary = car_eventlog::summarize_log(&log);
17748    let alerts = car_eventlog::evaluate_alerts(&summary, &thresholds);
17749    for a in &alerts {
17750        tracing::warn!(target: "car::alerts", kind = ?a.kind, "operational alert: {}", a.message);
17751    }
17752    Ok(serde_json::json!({ "summary": summary, "alerts": alerts }))
17753}
17754
17755/// `events.cost_by_agent` — per-agent token/cost report (EPIC G / G3),
17756/// folded from the session log's `InferenceMetered` events by their `agent`
17757/// field. Answers "how much did each agent cost this run" (e.g. Researcher
17758/// $2, Coordinator $0.5). Provenance (which tool/workflow) is queryable via
17759/// `events.query {data_matches:{agent}}`.
17760async fn handle_events_cost_by_agent(
17761    session: &crate::session::ClientSession,
17762) -> Result<Value, String> {
17763    let log = session.runtime.log.lock().await;
17764    let report = log.cost_by_agent();
17765    serde_json::to_value(&report).map_err(|e| format!("serialize cost_by_agent: {e}"))
17766}
17767
17768/// `events.retention` — get or set the session event log's auto-retention
17769/// policy (EPIC G / G2). With a `policy` object (`{max_events, max_age_secs}`)
17770/// installs it and immediately enforces the age bound, returning how many
17771/// events were reaped; with no `policy` returns the current policy. Bounds the
17772/// log by size (auto on append) and age (on this call).
17773async fn handle_events_retention(
17774    msg: &JsonRpcMessage,
17775    session: &crate::session::ClientSession,
17776) -> Result<Value, String> {
17777    let mut log = session.runtime.log.lock().await;
17778    if let Some(p) = msg.params.get("policy") {
17779        let policy: car_eventlog::RetentionPolicy = serde_json::from_value(p.clone())
17780            .map_err(|e| format!("invalid retention policy: {e}"))?;
17781        let removed = log.enforce_retention(&policy, chrono::Utc::now());
17782        log.set_retention(Some(policy.clone()));
17783        Ok(serde_json::json!({ "policy": policy, "removed": removed }))
17784    } else {
17785        Ok(serde_json::json!({ "policy": log.retention() }))
17786    }
17787}
17788
17789/// `events.chain.enable` — turn on tamper-evident hash chaining for the
17790/// session event log (EPIC A / A9). Every event appended from now on is
17791/// linked to its predecessor by a content hash. Opt-in and idempotent;
17792/// existing events stay byte-identical.
17793async fn handle_events_chain_enable(
17794    session: &crate::session::ClientSession,
17795) -> Result<Value, String> {
17796    session.runtime.enable_event_log_hash_chaining().await;
17797    Ok(serde_json::json!({ "ok": true }))
17798}
17799
17800/// `events.chain.verify` — verify the session event log's tamper-evidence
17801/// chain (EPIC A / A9). Returns `{verified: n}` (chained events verified) or
17802/// `{tampered_at: i}` naming the first event whose hash/linkage doesn't
17803/// match — an interior edit, deletion, or reordering. Head/tail truncation
17804/// is not detectable (no anchored head hash).
17805async fn handle_events_chain_verify(
17806    session: &crate::session::ClientSession,
17807) -> Result<Value, String> {
17808    match session.runtime.verify_event_log_chain().await {
17809        Ok(n) => Ok(serde_json::json!({ "verified": n })),
17810        Err(i) => Ok(serde_json::json!({ "tampered_at": i })),
17811    }
17812}
17813
17814async fn handle_events_stats(session: &crate::session::ClientSession) -> Result<Value, String> {
17815    let stats = session.runtime.log.lock().await.stats();
17816    serde_json::to_value(stats).map_err(|e| e.to_string())
17817}
17818
17819#[derive(Deserialize)]
17820#[serde(rename_all = "camelCase")]
17821struct EventsTruncateParams {
17822    #[serde(default)]
17823    max_events: Option<usize>,
17824    #[serde(default)]
17825    max_spans: Option<usize>,
17826}
17827
17828async fn handle_events_truncate(
17829    msg: &JsonRpcMessage,
17830    session: &crate::session::ClientSession,
17831) -> Result<Value, String> {
17832    let params: EventsTruncateParams =
17833        serde_json::from_value(msg.params.clone()).unwrap_or(EventsTruncateParams {
17834            max_events: None,
17835            max_spans: None,
17836        });
17837    let mut log = session.runtime.log.lock().await;
17838    let removed_events = params
17839        .max_events
17840        .map(|max| log.truncate_events_keep_last(max))
17841        .unwrap_or(0);
17842    let removed_spans = params
17843        .max_spans
17844        .map(|max| log.truncate_spans_keep_last(max))
17845        .unwrap_or(0);
17846    let stats = log.stats();
17847    Ok(serde_json::json!({
17848        "removedEvents": removed_events,
17849        "removedSpans": removed_spans,
17850        "stats": stats,
17851    }))
17852}
17853
17854async fn handle_events_clear(session: &crate::session::ClientSession) -> Result<Value, String> {
17855    let mut log = session.runtime.log.lock().await;
17856    let removed = log.clear();
17857    Ok(serde_json::json!({ "removed": removed, "stats": log.stats() }))
17858}
17859
17860// ---------------------------------------------------------------------------
17861// Agent run tracing — run lifecycle (U1).
17862//
17863// `runs.start` brackets the beginning of an agent run: it mints a durable
17864// `run_id`, resolves the owning `agent_id`, tags it as the session's
17865// current run BEFORE responding (so the U2 per-turn recorder always reads
17866// the run_id the bracket set — KTD3), records `RunStarted`, and returns
17867// `{ run_id, agent_id }`. `runs.complete` records the terminal
17868// `AgentOutcome` and acks. On a mid-run disconnect with no
17869// `runs.complete`, `remove_session` sweeps the run to `Incomplete` after
17870// a short grace window (R5) so a healthy in-flight complete is not raced.
17871// ---------------------------------------------------------------------------
17872
17873/// Resolve the owning `agent_id` for a `runs.start` call, in priority
17874/// order: the connection's bound agent (`session.auth {agent_id}`), the
17875/// `CAR_AGENT_ID` env (supervised one-shot), then a deterministic id
17876/// synthesized from the supplied `agent_name` (unsupervised one-shot, so
17877/// `run_scenarios.py` runs still record). Returns `Err` only when none of
17878/// these resolve — a run with no identity has no durable key and is
17879/// rejected rather than recorded under an ambiguous id.
17880async fn resolve_run_agent_id(
17881    session: &crate::session::ClientSession,
17882    req: &car_proto::RunStartRequest,
17883) -> Result<String, String> {
17884    // 1. The connection's bound agent from `session.auth {agent_id}` is
17885    //    AUTHORITATIVE and wins over any caller-supplied `agent_id` (FIX 5).
17886    //    A bound session must not be able to record a run under a DIFFERENT
17887    //    agent — that's trace forgery (writing into another agent's run
17888    //    history). A matching explicit param is fine (redundant); a
17889    //    mismatching one is rejected so the forgery attempt is loud rather
17890    //    than silently misattributed.
17891    if let Some(bound) = session.agent_id.lock().await.clone() {
17892        if let Some(id) = req.agent_id.as_deref() {
17893            let id = id.trim();
17894            if !id.is_empty() && id != bound {
17895                return Err(format!(
17896                    "runs.start `agent_id` (`{id}`) does not match this session's bound \
17897                     agent: a bound connection can only record runs under its own agent"
17898                ));
17899            }
17900        }
17901        return Ok(bound);
17902    }
17903    // 2. UNBOUND session only: an explicit `agent_id` param wins. This is
17904    //    the one-shot path that legitimately names its agent (no binding to
17905    //    derive from).
17906    if let Some(id) = req.agent_id.as_deref() {
17907        let id = id.trim();
17908        if !id.is_empty() {
17909            return Ok(id.to_string());
17910        }
17911    }
17912    // 3. The supervisor-injected env for supervised one-shot runs.
17913    if let Ok(env_id) = std::env::var("CAR_AGENT_ID") {
17914        let env_id = env_id.trim().to_string();
17915        if !env_id.is_empty() {
17916            return Ok(env_id);
17917        }
17918    }
17919    // 4. Unsupervised one-shot fallback: synthesize a deterministic id
17920    //    from the agent's name so the run still has a stable key.
17921    if let Some(name) = req.agent_name.as_deref() {
17922        if let Some(synth) = synthesize_agent_id(name) {
17923            return Ok(synth);
17924        }
17925    }
17926    Err(
17927        "runs.start could not resolve an agent_id: no `agent_id` param, no bound \
17928         session.auth {agent_id}, no CAR_AGENT_ID env, and no usable `agent_name` \
17929         to synthesize one from"
17930            .to_string(),
17931    )
17932}
17933
17934/// Deterministically derive a stable agent id from a display name for
17935/// the unsupervised one-shot path. Lowercases, collapses any run of
17936/// non-alphanumeric characters to a single `-`, trims leading/trailing
17937/// `-`, and prefixes `name:` so a synthesized id is recognizable as a
17938/// name-derived fallback (and never collides with a real supervised
17939/// agent id, which has no `name:` prefix). Returns `None` when the name
17940/// has no alphanumeric content to key on.
17941fn synthesize_agent_id(name: &str) -> Option<String> {
17942    let mut slug = String::new();
17943    let mut prev_dash = false;
17944    for ch in name.chars() {
17945        if ch.is_ascii_alphanumeric() {
17946            slug.push(ch.to_ascii_lowercase());
17947            prev_dash = false;
17948        } else if !prev_dash {
17949            slug.push('-');
17950            prev_dash = true;
17951        }
17952    }
17953    let slug = slug.trim_matches('-');
17954    if slug.is_empty() {
17955        return None;
17956    }
17957    Some(format!("name:{slug}"))
17958}
17959
17960async fn release_new_run_reservation_after_error(
17961    state: &ServerState,
17962    session: &crate::session::ClientSession,
17963    requested: &crate::session::RunMeta,
17964    error: String,
17965) -> String {
17966    match state
17967        .release_unpersisted_run_reservation(session, requested)
17968        .await
17969    {
17970        Ok(()) => error,
17971        Err(cleanup) => format!("{error}; failed to release new run reservation: {cleanup}"),
17972    }
17973}
17974
17975async fn handle_runs_start(
17976    req: &JsonRpcMessage,
17977    session: &crate::session::ClientSession,
17978    state: &Arc<ServerState>,
17979) -> Result<Value, String> {
17980    let params: car_proto::RunStartRequest = serde_json::from_value(req.params.clone())
17981        .map_err(|e| format!("runs.start requires {{ intent, agent_id?, agent_name?, outcome_description?, idempotency_key? }}: {e}"))?;
17982    if params.intent.trim().is_empty() {
17983        return Err("runs.start requires a non-empty `intent`".to_string());
17984    }
17985
17986    let agent_id = resolve_run_agent_id(session, &params).await?;
17987    let _run_guard = session.run_lifecycle_guard.lock().await;
17988    let idempotency_key = params
17989        .idempotency_key
17990        .as_deref()
17991        .map(str::trim)
17992        .filter(|k| !k.is_empty())
17993        .map(str::to_string);
17994    let run_id = idempotency_key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
17995    let current_run = session.current_run_id.lock().await.clone();
17996    let requested_meta = crate::session::RunMeta {
17997        run_id: run_id.clone(),
17998        agent_id: agent_id.clone(),
17999        client_id: session.client_id.clone(),
18000        active_client_id: session.client_id.clone(),
18001        resume_predecessor_client_id: None,
18002        resume_lease: None,
18003        intent: params.intent.clone(),
18004        outcome_description: params.outcome_description.clone(),
18005        started_at: chrono::Utc::now(),
18006        termination: None,
18007        ended_at: None,
18008        turns: Vec::new(),
18009        start_committed: false,
18010        pending_terminal: None,
18011        cancellation_pending: None,
18012        cancellation_receipt: None,
18013        trace_corruption: None,
18014        durability_generation: 0,
18015    };
18016    if let Some(current) = current_run.as_deref() {
18017        if let Some(pending) = state
18018            .run_store
18019            .pending_proposal(current)
18020            .map_err(|error| proposal_durability_quarantine(current, "finalization", &error))?
18021        {
18022            return Err(format!(
18023                "proposal finalization pending for run_id `{current}` / original submission `{}`",
18024                pending.original_proposal_id
18025            ));
18026        }
18027        if let Some(marker) = state
18028            .run_store
18029            .execution_marker(current)
18030            .map_err(|error| proposal_durability_quarantine(current, "execution", &error))?
18031        {
18032            return Err(format!(
18033                "proposal execution outcome unknown for run_id `{current}` / original submission `{}`; CAR will not replace or terminalize the quarantined run",
18034                marker.original_proposal_id
18035            ));
18036        }
18037    }
18038    if let Some(pending) = state
18039        .run_store
18040        .pending_proposal(&run_id)
18041        .map_err(|error| proposal_durability_quarantine(&run_id, "finalization", &error))?
18042    {
18043        return Err(format!(
18044            "proposal finalization pending for run_id `{run_id}` / original submission `{}`",
18045            pending.original_proposal_id
18046        ));
18047    }
18048    if let Some(marker) = state
18049        .run_store
18050        .execution_marker(&run_id)
18051        .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))?
18052    {
18053        return Err(format!(
18054            "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not reopen or redispatch automatically",
18055            marker.original_proposal_id
18056        ));
18057    }
18058    // Reserve first so a stale/conflicting target is rejected without
18059    // terminalizing the current run. Every later error before the candidate
18060    // owns a journal/RunStore boundary releases a New reservation exactly.
18061    let reservation = state.reserve_run(requested_meta.clone()).await?;
18062    let reservation_is_new = matches!(&reservation, crate::session::RunReservation::New);
18063
18064    if let crate::session::RunReservation::Existing(existing) = &reservation {
18065        if let Some(error) = &existing.trace_corruption {
18066            return Err(error.clone());
18067        }
18068        if existing.is_terminal() {
18069            return Err(format!(
18070                "{} run `{run_id}` is already terminal",
18071                car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18072            ));
18073        }
18074    }
18075
18076    let current_resolution: Result<Option<Value>, String> = async {
18077        if let Some(current) = current_run.as_deref() {
18078            if current == run_id {
18079                let (owner, terminal, committed, pending) = state
18080                    .run_lifecycle_state(current)
18081                    .await
18082                    .ok_or_else(|| {
18083                        format!("reserved run `{current}` is absent from CAR registry")
18084                    })?;
18085                if owner != session.client_id || terminal {
18086                    return Err(format!(
18087                        "{} run `{current}` is not resumable by this client",
18088                        car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18089                    ));
18090                }
18091                session.require_run_journal_binding(current).await?;
18092                if pending {
18093                    let ended = state
18094                        .prepare_run_incomplete(current)
18095                        .await
18096                        .ok_or_else(|| {
18097                            format!("active run `{current}` could not resume its pending terminal")
18098                        })?;
18099                    session.append_run_terminal_event(&ended).await?;
18100                    state.commit_run_completion(&ended).await?;
18101                    session.clear_run_journal_binding(current).await?;
18102                    *session.current_run_id.lock().await = None;
18103                    return Err(format!(
18104                        "{} run `{current}` is already terminal",
18105                        car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18106                    ));
18107                }
18108                if committed {
18109                    return serde_json::to_value(car_proto::RunStartResponse {
18110                        run_id: current.to_string(),
18111                        agent_id: agent_id.clone(),
18112                        client_id: session.client_id.clone(),
18113                    })
18114                    .map(Some)
18115                    .map_err(|e| e.to_string());
18116                }
18117            } else {
18118                let (owner, terminal, committed, _pending) = state
18119                    .run_lifecycle_state(current)
18120                    .await
18121                    .ok_or_else(|| {
18122                        format!("active run `{current}` is absent from CAR registry")
18123                    })?;
18124                if owner != session.client_id {
18125                    return Err(format!(
18126                        "{} active run `{current}` belongs to another client",
18127                        car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18128                    ));
18129                }
18130                session.require_run_journal_binding(current).await?;
18131                if !terminal {
18132                    if !committed {
18133                        return Err(format!(
18134                            "{} active run `{current}` has an unacknowledged durable start; retry its exact idempotency key and occurrence-defining payload before starting `{run_id}`",
18135                            car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18136                        ));
18137                    }
18138                    // Reuse an already prepared terminal so a retry finishes
18139                    // its exact outcome rather than overwriting it.
18140                    let ended = state
18141                        .prepare_run_incomplete(current)
18142                        .await
18143                        .ok_or_else(|| {
18144                            format!("active run `{current}` could not be terminalized")
18145                        })?;
18146                    session.append_run_terminal_event(&ended).await?;
18147                    state.commit_run_completion(&ended).await?;
18148                }
18149                session.clear_run_journal_binding(current).await?;
18150                *session.current_run_id.lock().await = None;
18151            }
18152        }
18153        Ok(None)
18154    }
18155    .await;
18156    match current_resolution {
18157        Ok(Some(response)) => return Ok(response),
18158        Ok(None) => {}
18159        Err(error) => {
18160            if reservation_is_new {
18161                return Err(release_new_run_reservation_after_error(
18162                    state,
18163                    session,
18164                    &requested_meta,
18165                    error,
18166                )
18167                .await);
18168            }
18169            return Err(error);
18170        }
18171    }
18172
18173    let binding_preexisting = current_run.as_deref() == Some(run_id.as_str());
18174    if !binding_preexisting {
18175        if let Err(error) = session.bind_run_journal(&run_id).await {
18176            if reservation_is_new {
18177                return Err(release_new_run_reservation_after_error(
18178                    state,
18179                    session,
18180                    &requested_meta,
18181                    error,
18182                )
18183                .await);
18184            }
18185            return Err(error);
18186        }
18187    }
18188
18189    *session.current_run_id.lock().await = Some(run_id.clone());
18190    let start_result = async {
18191        let started = state.persist_run_start(&run_id).await?;
18192        session.append_run_started_event(&started).await?;
18193        state.commit_run_start(&run_id).await
18194    }
18195    .await;
18196    if let Err(error) = start_result {
18197        match state.run_store.run_started(&run_id) {
18198            Ok(None) => {
18199                match state
18200                    .reconcile_or_release_unacknowledged_start(session, &run_id)
18201                    .await
18202                {
18203                    Ok(true) => return Err(error),
18204                    Ok(false) => {
18205                        return Err(format!(
18206                            "{error}; run `{run_id}` became durable during rollback; retry the exact idempotency key and occurrence-defining payload"
18207                        ));
18208                    }
18209                    Err(cleanup) => {
18210                        return Err(format!(
18211                            "{error}; failed to release rejected start reservation: {cleanup}"
18212                        ));
18213                    }
18214                }
18215            }
18216            Ok(Some(_)) => {
18217                return Err(format!(
18218                    "{error}; {} run `{run_id}` has an unacknowledged durable start; retry the exact idempotency key and occurrence-defining payload",
18219                    car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18220                ));
18221            }
18222            Err(read_error) => {
18223                return Err(format!(
18224                    "{error}; durable start state for run `{run_id}` is unreadable ({read_error}); retry only the exact idempotency key and occurrence-defining payload"
18225                ));
18226            }
18227        }
18228    }
18229
18230    serde_json::to_value(car_proto::RunStartResponse {
18231        run_id,
18232        agent_id,
18233        client_id: session.client_id.clone(),
18234    })
18235    .map_err(|e| e.to_string())
18236}
18237
18238async fn handle_runs_resume(
18239    req: &JsonRpcMessage,
18240    session: &crate::session::ClientSession,
18241    state: &Arc<ServerState>,
18242) -> Result<Value, String> {
18243    if !session_has_capability(session, car_proto::RUNS_RESUME_CAPABILITY) {
18244        return Err(format!(
18245            "runs.resume requires negotiated capability `{}`",
18246            car_proto::RUNS_RESUME_CAPABILITY
18247        ));
18248    }
18249    let params: car_proto::RunResumeRequest = serde_json::from_value(req.params.clone())
18250        .map_err(|error| format!("runs.resume requires exactly {{ run_id }}: {error}"))?;
18251    if params.run_id.trim().is_empty() {
18252        return Err("runs.resume run_id must be non-empty".into());
18253    }
18254    if !session.authenticated.load(Ordering::Acquire) {
18255        return Err("runs.resume requires an authenticated agent session".into());
18256    }
18257    let agent_id = session
18258        .agent_id
18259        .lock()
18260        .await
18261        .clone()
18262        .ok_or_else(|| "runs.resume requires session.auth with an agent_id".to_string())?;
18263
18264    let _run_guard = session.run_lifecycle_guard.lock().await;
18265    let current = session.current_run_id.lock().await.clone();
18266    if current
18267        .as_deref()
18268        .is_some_and(|current| current != params.run_id)
18269    {
18270        return Err(format!(
18271            "runs.resume cannot replace this socket's active run `{}`",
18272            current.expect("checked as present")
18273        ));
18274    }
18275
18276    let binding_preexisting = current.as_deref() == Some(params.run_id.as_str());
18277    if !binding_preexisting {
18278        session.bind_run_journal(&params.run_id).await?;
18279    }
18280    let resumed = match state
18281        .resume_run(&params.run_id, &agent_id, &session.client_id)
18282        .await
18283    {
18284        Ok(binding) => binding,
18285        Err(error) => {
18286            if !binding_preexisting {
18287                let _ = session.clear_run_journal_binding(&params.run_id).await;
18288            }
18289            return Err(error);
18290        }
18291    };
18292    *session.current_run_id.lock().await = Some(params.run_id.clone());
18293
18294    serde_json::to_value(car_proto::RunResumeResponse {
18295        run_id: resumed.run_id,
18296        agent_id: resumed.agent_id,
18297        client_id: resumed.active_client_id,
18298        resumed_from_client_id: resumed.resumed_from_client_id,
18299    })
18300    .map_err(|error| error.to_string())
18301}
18302
18303async fn handle_runs_complete(
18304    req: &JsonRpcMessage,
18305    session: &crate::session::ClientSession,
18306    state: &Arc<ServerState>,
18307) -> Result<Value, String> {
18308    let params: car_proto::RunCompleteRequest = serde_json::from_value(req.params.clone())
18309        .map_err(|e| format!("runs.complete requires {{ run_id, outcome }}: {e}"))?;
18310
18311    let _run_guard = session.run_lifecycle_guard.lock().await;
18312    let current = session.current_run_id.lock().await.clone();
18313    if current.as_deref() != Some(params.run_id.as_str()) {
18314        return Err(format!(
18315            "runs.complete run_id `{}` is not this client's active run",
18316            params.run_id
18317        ));
18318    }
18319    let (owner_client, _terminal, start_committed, _completion_pending) = state
18320        .run_lifecycle_state(&params.run_id)
18321        .await
18322        .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18323    let (durable_client, active_client) = state
18324        .run_owner_binding(&params.run_id)
18325        .await
18326        .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18327    if owner_client != session.client_id || !start_committed {
18328        return Err("runs.complete client/run binding mismatch".into());
18329    }
18330    if active_client != session.client_id {
18331        return Err("runs.complete active owner mismatch".into());
18332    }
18333    session.require_run_journal_binding(&params.run_id).await?;
18334    if let Some(pending) = state
18335        .run_store
18336        .pending_proposal(&params.run_id)
18337        .map_err(|error| proposal_durability_quarantine(&params.run_id, "finalization", &error))?
18338    {
18339        return Err(format!(
18340            "proposal finalization pending for run_id `{}` / original submission `{}`",
18341            params.run_id, pending.original_proposal_id
18342        ));
18343    }
18344    if let Some(marker) = state
18345        .run_store
18346        .execution_marker(&params.run_id)
18347        .map_err(|error| proposal_durability_quarantine(&params.run_id, "execution", &error))?
18348    {
18349        return Err(format!(
18350            "proposal execution outcome unknown for run_id `{}` / original submission `{}`; runs.complete is quarantined",
18351            params.run_id, marker.original_proposal_id
18352        ));
18353    }
18354
18355    let termination = car_proto::RunTermination::Outcome {
18356        status: params.outcome.status,
18357        outcome: params.outcome.clone(),
18358    };
18359    let ended = state
18360        .prepare_run_completion_for_active_owner(
18361            &params.run_id,
18362            termination,
18363            Some(&session.client_id),
18364        )
18365        .await?;
18366    if durable_client == session.client_id {
18367        session.append_run_terminal_event(&ended).await?;
18368    } else {
18369        session
18370            .append_resumed_run_terminal_event(&ended, &durable_client)
18371            .await?;
18372    }
18373    state.commit_run_completion(&ended).await?;
18374    let completion_digest = ended
18375        .completion_digest
18376        .clone()
18377        .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18378
18379    // Both durable surfaces are now acknowledged. Only at this point may the
18380    // authenticated journal/session binding be released; every earlier error
18381    // leaves it intact so the owner can retry the same prepared terminal.
18382    session.clear_run_journal_binding(&params.run_id).await?;
18383    *session.current_run_id.lock().await = None;
18384
18385    serde_json::to_value(car_proto::RunCompleteResponse {
18386        run_id: params.run_id,
18387        ok: true,
18388        completion_digest,
18389    })
18390    .map_err(|e| e.to_string())
18391}
18392
18393#[derive(Serialize)]
18394struct RunCancelReceiptPreimage<'a> {
18395    receipt_version: u32,
18396    run_id: &'a str,
18397    idempotency_key: &'a str,
18398    reason_digest: &'a str,
18399    principal: &'a str,
18400    status: car_proto::RunCancellationStatus,
18401    terminal_digest: &'a Option<String>,
18402    action_id: &'a Option<String>,
18403    request_id: &'a Option<String>,
18404}
18405
18406fn run_cancel_receipt(
18407    requested: &car_proto::RunCancellationRequested,
18408    status: car_proto::RunCancellationStatus,
18409    terminal_digest: Option<String>,
18410) -> Result<car_proto::RunCancelResponse, String> {
18411    let preimage = RunCancelReceiptPreimage {
18412        receipt_version: requested.receipt_version,
18413        run_id: &requested.run_id,
18414        idempotency_key: &requested.idempotency_key,
18415        reason_digest: &requested.reason_digest,
18416        principal: &requested.principal,
18417        status,
18418        terminal_digest: &terminal_digest,
18419        action_id: &requested.action_id,
18420        request_id: &requested.request_id,
18421    };
18422    let receipt_digest = car_proto::canonical_sha256(&preimage)?;
18423    Ok(car_proto::RunCancelResponse {
18424        receipt_version: requested.receipt_version,
18425        run_id: requested.run_id.clone(),
18426        idempotency_key: requested.idempotency_key.clone(),
18427        reason_digest: requested.reason_digest.clone(),
18428        principal: requested.principal.clone(),
18429        status,
18430        terminal_digest,
18431        action_id: requested.action_id.clone(),
18432        request_id: requested.request_id.clone(),
18433        receipt_digest,
18434    })
18435}
18436
18437fn run_cancel_terminal_receipt(
18438    params: &car_proto::RunCancelRequest,
18439    reason_digest: &str,
18440    principal: &str,
18441    ended: &car_proto::RunEnded,
18442) -> Result<car_proto::RunCancelResponse, String> {
18443    let terminal_digest = ended
18444        .completion_digest
18445        .clone()
18446        .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18447    let (requested, status) = match &ended.termination {
18448        car_proto::RunTermination::Cancelled { cancellation }
18449            if cancellation.idempotency_key == params.idempotency_key
18450                && cancellation.reason_digest == reason_digest
18451                && cancellation.principal == principal =>
18452        {
18453            (
18454                car_proto::RunCancellationRequested {
18455                    receipt_version: cancellation.receipt_version,
18456                    run_id: cancellation.run_id.clone(),
18457                    idempotency_key: cancellation.idempotency_key.clone(),
18458                    reason_digest: cancellation.reason_digest.clone(),
18459                    principal: cancellation.principal.clone(),
18460                    action_id: cancellation.action_id.clone(),
18461                    request_id: cancellation.request_id.clone(),
18462                },
18463                car_proto::RunCancellationStatus::CancelledConfirmed,
18464            )
18465        }
18466        car_proto::RunTermination::Cancelled { cancellation } => (
18467            car_proto::RunCancellationRequested {
18468                receipt_version: 1,
18469                run_id: params.run_id.clone(),
18470                idempotency_key: params.idempotency_key.clone(),
18471                reason_digest: reason_digest.to_string(),
18472                principal: principal.to_string(),
18473                action_id: cancellation.action_id.clone(),
18474                request_id: cancellation.request_id.clone(),
18475            },
18476            car_proto::RunCancellationStatus::AlreadyTerminal,
18477        ),
18478        _ => (
18479            car_proto::RunCancellationRequested {
18480                receipt_version: 1,
18481                run_id: params.run_id.clone(),
18482                idempotency_key: params.idempotency_key.clone(),
18483                reason_digest: reason_digest.to_string(),
18484                principal: principal.to_string(),
18485                action_id: None,
18486                request_id: None,
18487            },
18488            car_proto::RunCancellationStatus::AlreadyTerminal,
18489        ),
18490    };
18491    run_cancel_receipt(&requested, status, Some(terminal_digest))
18492}
18493
18494async fn handle_runs_cancel(
18495    req: &JsonRpcMessage,
18496    session: &crate::session::ClientSession,
18497    state: &Arc<ServerState>,
18498) -> Result<Value, String> {
18499    if !session_has_capability(session, car_proto::RUNS_CANCEL_CAPABILITY) {
18500        return Err(format!(
18501            "runs.cancel requires negotiated capability `{}`",
18502            car_proto::RUNS_CANCEL_CAPABILITY
18503        ));
18504    }
18505    let params: car_proto::RunCancelRequest =
18506        serde_json::from_value(req.params.clone()).map_err(|error| {
18507            format!("runs.cancel requires exactly {{ run_id, idempotency_key, reason }}: {error}")
18508        })?;
18509    if params.run_id.trim().is_empty()
18510        || params.idempotency_key.trim().is_empty()
18511        || params.reason.trim().is_empty()
18512    {
18513        return Err("runs.cancel fields must be non-empty".into());
18514    }
18515    if params.idempotency_key.len() > 128 {
18516        return Err("runs.cancel idempotency_key exceeds 128 bytes".into());
18517    }
18518    if params.reason.len() > 1024 {
18519        return Err("runs.cancel reason exceeds 1024 bytes".into());
18520    }
18521
18522    let meta = state.run_meta(&params.run_id).await;
18523    let owning_agent = meta
18524        .as_ref()
18525        .map(|meta| meta.agent_id.clone())
18526        .or_else(|| state.run_store.agent_for_run(&params.run_id));
18527    let Some(owning_agent) = owning_agent else {
18528        return Err("run not found or not authorized".into());
18529    };
18530    if authorize_run_access(session, state, &owning_agent)
18531        .await
18532        .is_err()
18533    {
18534        return Err("run not found or not authorized".into());
18535    }
18536    let principal = if session.is_host.load(Ordering::Acquire) {
18537        "host".to_string()
18538    } else {
18539        format!("agent:{owning_agent}")
18540    };
18541    let reason_digest = format!("{:x}", Sha256::digest(params.reason.as_bytes()));
18542
18543    let records = state
18544        .run_store
18545        .get_run_trace_for_checked(&owning_agent, &params.run_id)
18546        .map_err(|error| format!("runs.cancel trace read failed: {error}"))?
18547        .ok_or_else(|| "run not found or not authorized".to_string())?;
18548    let existing_result = records.iter().find_map(|record| match record {
18549        car_proto::RunRecord::CancellationResult(result) => Some(result),
18550        _ => None,
18551    });
18552    if let Some(existing) = existing_result {
18553        if existing.idempotency_key != params.idempotency_key
18554            || existing.reason_digest != reason_digest
18555            || existing.principal != principal
18556        {
18557            return Err("run already has a different cancellation request".into());
18558        }
18559    }
18560    if let Some(ended) = records.iter().find_map(|record| match record {
18561        car_proto::RunRecord::Ended(ended) => Some(ended),
18562        _ => None,
18563    }) {
18564        return serde_json::to_value(run_cancel_terminal_receipt(
18565            &params,
18566            &reason_digest,
18567            &principal,
18568            ended,
18569        )?)
18570        .map_err(|error| error.to_string());
18571    }
18572
18573    let existing_requested = records.iter().find_map(|record| match record {
18574        car_proto::RunRecord::CancellationRequested(requested) => Some(requested),
18575        _ => None,
18576    });
18577    if let Some(existing) = existing_result {
18578        let owner_session = match meta.as_ref() {
18579            Some(meta) => state.sessions.lock().await.get(&meta.client_id).cloned(),
18580            None => None,
18581        };
18582        match owner_session {
18583            Some(owner_session) => {
18584                state
18585                    .persist_run_cancellation_result(&owner_session, existing)
18586                    .await?;
18587            }
18588            None => {
18589                state
18590                    .persist_recovered_run_cancellation_result(&owning_agent, existing)
18591                    .await?;
18592            }
18593        }
18594        return serde_json::to_value(existing).map_err(|error| error.to_string());
18595    }
18596    let Some(meta) = meta else {
18597        let Some(requested) = existing_requested else {
18598            return Err("run cancellation control is unavailable".into());
18599        };
18600        if requested.idempotency_key != params.idempotency_key
18601            || requested.reason_digest != reason_digest
18602            || requested.principal != principal
18603        {
18604            return Err("run already has a different cancellation request".into());
18605        }
18606        let response = run_cancel_receipt(
18607            requested,
18608            car_proto::RunCancellationStatus::TerminationUnconfirmed,
18609            None,
18610        )?;
18611        state
18612            .persist_recovered_run_cancellation_result(&owning_agent, &response)
18613            .await?;
18614        return serde_json::to_value(response).map_err(|error| error.to_string());
18615    };
18616    let owner_session = state.sessions.lock().await.get(&meta.client_id).cloned();
18617    let Some(owner_session) = owner_session else {
18618        let Some(requested) = existing_requested else {
18619            return Err("run cancellation control is unavailable".into());
18620        };
18621        if requested.idempotency_key != params.idempotency_key
18622            || requested.reason_digest != reason_digest
18623            || requested.principal != principal
18624        {
18625            return Err("run already has a different cancellation request".into());
18626        }
18627        let response = run_cancel_receipt(
18628            requested,
18629            car_proto::RunCancellationStatus::TerminationUnconfirmed,
18630            None,
18631        )?;
18632        state
18633            .persist_recovered_run_cancellation_result(&owning_agent, &response)
18634            .await?;
18635        return serde_json::to_value(response).map_err(|error| error.to_string());
18636    };
18637
18638    // Serialize against runs.complete for the opening session. Re-read the
18639    // durable trace after acquiring the guard: a completion that won before
18640    // cancellation must return the closed already_terminal vocabulary, while
18641    // a cancellation that wins keeps completion blocked until its durable
18642    // terminal or quarantine receipt is committed.
18643    let _run_guard = owner_session.run_lifecycle_guard.lock().await;
18644    let guarded_records = state
18645        .run_store
18646        .get_run_trace_for_checked(&owning_agent, &params.run_id)
18647        .map_err(|error| format!("runs.cancel guarded trace read failed: {error}"))?
18648        .ok_or_else(|| "run not found or not authorized".to_string())?;
18649    if let Some(ended) = guarded_records.iter().find_map(|record| match record {
18650        car_proto::RunRecord::Ended(ended) => Some(ended),
18651        _ => None,
18652    }) {
18653        return serde_json::to_value(run_cancel_terminal_receipt(
18654            &params,
18655            &reason_digest,
18656            &principal,
18657            ended,
18658        )?)
18659        .map_err(|error| error.to_string());
18660    }
18661
18662    let mut active: Vec<(String, String)> = owner_session
18663        .channel
18664        .active_actions
18665        .lock()
18666        .await
18667        .iter()
18668        .map(|(request_id, action_id)| (request_id.clone(), action_id.clone()))
18669        .collect();
18670    active.sort();
18671    let active_inferences = owner_session
18672        .inference_control
18673        .active_for_run(&params.run_id);
18674    let (request_id, action_id) = active
18675        .first()
18676        .map(|(request_id, action_id)| (Some(request_id.clone()), Some(action_id.clone())))
18677        .or_else(|| {
18678            active_inferences.first().map(|(inference_id, request_id)| {
18679                (
18680                    request_id.clone().or_else(|| Some(inference_id.clone())),
18681                    None,
18682                )
18683            })
18684        })
18685        .unwrap_or((None, None));
18686    let requested = if let Some(existing) = existing_requested {
18687        if existing.idempotency_key != params.idempotency_key
18688            || existing.reason_digest != reason_digest
18689            || existing.principal != principal
18690        {
18691            return Err("run already has a different cancellation request".into());
18692        }
18693        existing.clone()
18694    } else {
18695        car_proto::RunCancellationRequested {
18696            receipt_version: 1,
18697            run_id: params.run_id.clone(),
18698            idempotency_key: params.idempotency_key.clone(),
18699            reason_digest,
18700            principal,
18701            action_id,
18702            request_id,
18703        }
18704    };
18705    state
18706        .persist_run_cancellation_requested(&owner_session, &requested)
18707        .await?;
18708
18709    for (request_id, action_id) in &active {
18710        crate::session::write_tool_cancel(
18711            &owner_session.channel,
18712            request_id.clone(),
18713            action_id.clone(),
18714            params.reason.clone(),
18715        )
18716        .await;
18717    }
18718    let mut inference_stopped = true;
18719    for (inference_id, _) in &active_inferences {
18720        let _pending = owner_session
18721            .inference_control
18722            .try_acquire_control()
18723            .map_err(|error| format!("runs.cancel inference control rejected: {error:?}"))?;
18724        let status = owner_session
18725            .inference_control
18726            .control(
18727                inference_id,
18728                crate::inference_control::ControlCause::Cancel,
18729                exact_backend_termination_ack(inference_id),
18730            )
18731            .await;
18732        if !matches!(
18733            status,
18734            car_proto::InferenceControlStatus::CancelledConfirmed
18735                | car_proto::InferenceControlStatus::AlreadyTerminal
18736        ) {
18737            inference_stopped = false;
18738        }
18739    }
18740    let callbacks_stopped = if active.is_empty() {
18741        true
18742    } else {
18743        tokio::time::timeout(std::time::Duration::from_secs(5), async {
18744            loop {
18745                let pending = owner_session.channel.pending.lock().await;
18746                let any_active = active
18747                    .iter()
18748                    .any(|(request_id, _)| pending.contains_key(request_id));
18749                drop(pending);
18750                if !any_active {
18751                    break;
18752                }
18753                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
18754            }
18755        })
18756        .await
18757        .is_ok()
18758    };
18759    let stopped = callbacks_stopped && inference_stopped;
18760
18761    if !stopped {
18762        let response = run_cancel_receipt(
18763            &requested,
18764            car_proto::RunCancellationStatus::TerminationUnconfirmed,
18765            None,
18766        )?;
18767        state
18768            .persist_run_cancellation_result(&owner_session, &response)
18769            .await?;
18770        return serde_json::to_value(response).map_err(|error| error.to_string());
18771    }
18772
18773    let cancellation = car_proto::RunCancellationIdentity {
18774        receipt_version: requested.receipt_version,
18775        run_id: requested.run_id.clone(),
18776        idempotency_key: requested.idempotency_key.clone(),
18777        reason_digest: requested.reason_digest.clone(),
18778        principal: requested.principal.clone(),
18779        action_id: requested.action_id.clone(),
18780        request_id: requested.request_id.clone(),
18781    };
18782    let ended = state
18783        .prepare_run_completion(
18784            &params.run_id,
18785            car_proto::RunTermination::Cancelled { cancellation },
18786        )
18787        .await?;
18788    owner_session.append_run_terminal_event(&ended).await?;
18789    state.commit_run_completion(&ended).await?;
18790    let terminal_digest = ended
18791        .completion_digest
18792        .clone()
18793        .ok_or_else(|| "cancelled terminal is missing completion_digest".to_string())?;
18794    let response = run_cancel_receipt(
18795        &requested,
18796        car_proto::RunCancellationStatus::CancelledConfirmed,
18797        Some(terminal_digest),
18798    )?;
18799    owner_session
18800        .append_run_cancellation_result_event(&response)
18801        .await?;
18802    owner_session
18803        .clear_run_journal_binding(&params.run_id)
18804        .await?;
18805    let mut current = owner_session.current_run_id.lock().await;
18806    if current.as_deref() == Some(params.run_id.as_str()) {
18807        *current = None;
18808    }
18809    serde_json::to_value(response).map_err(|error| error.to_string())
18810}
18811
18812/// Daemon-owned size invariants for `runs.record_turns` (R8a). The daemon
18813/// owns turn size the way it owns the turn index (`record_run_turns`,
18814/// session.rs FIX 6) and the way `A2uiLimits` owns surface size — it never
18815/// trusts the client's truncation for data that lands on disk.
18816
18817/// Per-string-field byte cap. Each turn's `prompt` and `output` text and
18818/// any oversized `parameters` string is truncated to this with a marker
18819/// before the turn is appended. Sized to match U3's client-side cap with
18820/// headroom (the client caps at ~8 KB; the daemon's 16 KB ceiling catches
18821/// a misbehaving or older client without trimming healthy turns).
18822const RECORD_TURN_FIELD_CAP_BYTES: usize = 16 * 1024;
18823
18824/// Marker appended to a daemon-truncated field so a reader can tell the
18825/// value was cut, not merely short.
18826const RECORD_TURN_TRUNC_MARKER: &str = "…[truncated]";
18827
18828/// Aggregate per-turn byte cap. The per-field 16 KiB cap bounds each leaf
18829/// string, but a turn can carry MANY leaves (e.g. a `parameters` array of
18830/// thousands of sub-cap strings), so the per-field cap alone does not
18831/// bound the persisted JSONL line. After the typed decode each candidate
18832/// turn is serialized; one whose encoded form exceeds this cap gets its
18833/// heavy free-form fields (`parameters`, `output`, `prompt`) replaced
18834/// whole with [`RECORD_TURN_OVERSIZE_MARKER`] — see
18835/// [`enforce_turn_byte_cap`].
18836const RECORD_TURN_MAX_BYTES: usize = 256 * 1024;
18837
18838/// Replacement value for a free-form field dropped whole by the aggregate
18839/// per-turn cap ([`RECORD_TURN_MAX_BYTES`]) — distinct from the per-field
18840/// marker so a reader can tell WHICH invariant fired.
18841const RECORD_TURN_OVERSIZE_MARKER: &str = "…[truncated: turn exceeded 256 KiB]";
18842
18843/// Max turns in a single `runs.record_turns` batch. A larger batch is a
18844/// client bug (the agent flushes a bounded queue per cycle); reject it
18845/// loudly rather than admit an unbounded append under one lock.
18846const RECORD_TURNS_MAX_BATCH: usize = 256;
18847
18848/// Per-run turn ceiling — a runaway-loop backstop sized well above any
18849/// healthy main-agent-only cycle (tens of turns), never a trimmer. This is
18850/// a true hard cap: a batch that would take the run PAST this many
18851/// recorded turns is refused whole with `dropped: "run_turn_limit"`, which
18852/// the agent treats as stop-sending. (Losing the straddling batch is
18853/// correct — at this depth the run is a runaway, not a healthy cycle.)
18854///
18855/// The AUTHORITATIVE enforcement lives in
18856/// [`crate::session::ServerState::record_run_turns`], under the `runs` lock,
18857/// because the dispatcher spawns a task per frame and the handler's
18858/// pre-check below reads a lock-free snapshot that pipelined batches all
18859/// pass before any append lands (ADV-1). The handler pre-check is kept only
18860/// as a fast path that avoids a doomed decode+append for the obvious
18861/// already-over case. We alias the single source of truth here.
18862const RECORD_TURNS_RUN_CEILING: usize = crate::session::RECORD_TURNS_RUN_CEILING;
18863
18864/// Truncate one string in place to the daemon field-byte cap, appending
18865/// [`RECORD_TURN_TRUNC_MARKER`] when it was cut. Cuts on a UTF-8 char
18866/// boundary at or below the cap so the result is always valid.
18867fn truncate_turn_string(s: &mut String) {
18868    if s.len() > RECORD_TURN_FIELD_CAP_BYTES {
18869        // Find the largest char boundary ≤ the cap.
18870        let mut end = RECORD_TURN_FIELD_CAP_BYTES;
18871        while end > 0 && !s.is_char_boundary(end) {
18872            end -= 1;
18873        }
18874        s.truncate(end);
18875        s.push_str(RECORD_TURN_TRUNC_MARKER);
18876    }
18877}
18878
18879/// Truncate a JSON string value in place to the daemon field-byte cap,
18880/// appending [`RECORD_TURN_TRUNC_MARKER`] when it was cut. Non-string
18881/// values are recursively walked (arrays/objects) so a large blob nested
18882/// inside `parameters`/`output` is still bounded; numbers/bools are left
18883/// as-is.
18884fn truncate_turn_value(value: &mut Value) {
18885    match value {
18886        Value::String(s) => truncate_turn_string(s),
18887        Value::Array(items) => {
18888            for item in items.iter_mut() {
18889                truncate_turn_value(item);
18890            }
18891        }
18892        Value::Object(map) => {
18893            for (_k, v) in map.iter_mut() {
18894                truncate_turn_value(v);
18895            }
18896        }
18897        _ => {}
18898    }
18899}
18900
18901/// Headroom (bytes) subtracted from [`RECORD_TURN_MAX_BYTES`] when MEASURING
18902/// a turn, to cover the re-stamp slack (ADV-4). The cap is enforced here with
18903/// the turn's placeholder `index: 0`, but `record_run_turns` re-stamps the
18904/// index to the live append position under the `runs` lock — up to a
18905/// 4-digit-plus number near the run ceiling. So `"index":0` (9 bytes) can
18906/// grow to e.g. `"index":1999` (12 bytes): a turn measured at exactly the cap
18907/// would persist a few bytes OVER it. 16 bytes is comfortably above the
18908/// worst-case index-digit growth (the ceiling is 2000 → 4 digits → +3 bytes),
18909/// with margin to spare.
18910const RECORD_TURN_REINDEX_HEADROOM: usize = 16;
18911
18912/// Effective measurement cap: a turn is treated as "within the persisted
18913/// cap" only when its encoded length (with the placeholder index) is at or
18914/// below this, leaving [`RECORD_TURN_REINDEX_HEADROOM`] for the live re-stamp.
18915const RECORD_TURN_MEASURE_CAP: usize = RECORD_TURN_MAX_BYTES - RECORD_TURN_REINDEX_HEADROOM;
18916
18917/// Encoded size of a turn as it would persist (one JSONL line, minus the
18918/// trailing newline). A turn that fails to serialize reports `usize::MAX`
18919/// so the cap path treats it as oversized rather than waving it through.
18920fn turn_encoded_len(turn: &car_proto::RunTurn) -> usize {
18921    serde_json::to_vec(turn).map_or(usize::MAX, |v| v.len())
18922}
18923
18924/// Enforce the aggregate per-turn byte cap ([`RECORD_TURN_MAX_BYTES`]) on
18925/// a decoded turn — the invariant the per-field pass cannot give: the
18926/// per-field cap bounds each leaf string, but not how many leaves a turn
18927/// carries, so a `parameters` array of thousands of sub-cap strings (or a
18928/// multi-MB `tool` scalar the per-field pass never visits) would still
18929/// produce an unbounded JSONL line.
18930///
18931/// When the encoded turn exceeds the cap, the heavy free-form fields
18932/// (`parameters`, `output`, `prompt`) are replaced WHOLE with
18933/// [`RECORD_TURN_OVERSIZE_MARKER`] (no partial salvage — at this size the
18934/// content is a misbehaving client, not signal). If the turn is somehow
18935/// STILL over cap, the remaining string scalars the per-field pass never
18936/// truncates (`tool`, `policy_rejected.rule`/`param`) are field-capped too.
18937///
18938/// Returns `true` when the turn is within the persisted cap (keep it) and
18939/// `false` when it is STILL over cap after both passes (ADV-5: reject/drop
18940/// it — a serialize failure or a future free-form field this pass doesn't
18941/// bound). The caller drops a rejected turn with a logged reason and a
18942/// structured drop rather than appending an over-cap line. The measurement
18943/// uses [`RECORD_TURN_MEASURE_CAP`] (ADV-4 re-stamp headroom).
18944pub(crate) fn enforce_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
18945    // ADV-5: exhaustively destructure so a future new `RunTurn` field is a
18946    // COMPILE error here, not a silently-unbounded persisted line. Every
18947    // field below is either a free-form string/JSON we bound, or a
18948    // number/C-like enum that is bounded by construction.
18949    let car_proto::RunTurn {
18950        index: _,               // usize — bounded; daemon re-stamps it anyway
18951        proposal_id: _,         // semantic identity — never truncate; reject if oversized
18952        action_id: _,           // semantic identity — never truncate; reject if oversized
18953        action_status: _,       // closed enum — bounded by construction
18954        action_duration_ms: _,  // f64 — bounded by construction
18955        action_completed_at: _, // timestamp — bounded by construction
18956        depends_on: _,          // semantic graph — never truncate; reject if oversized
18957        state_dependencies: _,  // semantic graph — never truncate; reject if oversized
18958        prompt: _,              // bounded below (replaced whole when oversize)
18959        tool: _,                // bounded below (field-capped when oversize)
18960        parameters: _,          // bounded below (replaced whole when oversize)
18961        output: _,              // bounded below (replaced whole when oversize)
18962        cli_outcome: _,         // C-like enum + i64 — bounded
18963        verifier_verdict: _,    // C-like enum — bounded
18964        policy_rejected: _,     // bounded below (field-capped when oversize)
18965    } = turn;
18966
18967    if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
18968        return true;
18969    }
18970    // Replace the big three free-form fields whole.
18971    if !turn.parameters.is_null() {
18972        turn.parameters = Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string());
18973    }
18974    if turn.output.is_some() {
18975        turn.output = Some(Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string()));
18976    }
18977    if turn.prompt.is_some() {
18978        turn.prompt = Some(RECORD_TURN_OVERSIZE_MARKER.to_string());
18979    }
18980    if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
18981        return true;
18982    }
18983    // Still over cap: the bytes live in the scalars the per-field pass
18984    // never visits. Field-cap them.
18985    if let Some(tool) = turn.tool.as_mut() {
18986        truncate_turn_string(tool);
18987    }
18988    if let Some(pr) = turn.policy_rejected.as_mut() {
18989        truncate_turn_string(&mut pr.rule);
18990        if let Some(param) = pr.param.as_mut() {
18991            truncate_turn_string(param);
18992        }
18993    }
18994    // ADV-5: if the turn is STILL over cap, every free-form field has already
18995    // been replaced/capped, so the only ways to land here are a serialize
18996    // failure (`turn_encoded_len` → usize::MAX) or a future free-form field
18997    // the exhaustive destructure above will have forced us to handle. Reject
18998    // the turn (hard, not a release-compiled-out `debug_assert`) so an
18999    // unbounded line never reaches disk.
19000    turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19001}
19002
19003/// Bound a proposal-produced turn without changing its authenticated action
19004/// parameters. Output/prompt may be compacted, but if the immutable proposal
19005/// metadata itself cannot fit the durable line cap, finalization fails closed.
19006pub(crate) fn enforce_proposal_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
19007    let parameters = turn.parameters.clone();
19008    if !enforce_turn_byte_cap(turn) {
19009        return false;
19010    }
19011    turn.parameters = parameters;
19012    turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19013}
19014
19015/// Build a non-fatal `runs.record_turns` rejection: `ok: false`, nothing
19016/// appended, with the machine-readable `dropped` reason the agent treats
19017/// as "stop sending for this run".
19018fn record_turns_dropped(run_id: &str, reason: &str) -> Result<Value, String> {
19019    serde_json::to_value(car_proto::RunRecordTurnsResponse {
19020        run_id: run_id.to_string(),
19021        base_index: 0,
19022        count: 0,
19023        ok: false,
19024        dropped: Some(reason.to_string()),
19025    })
19026    .map_err(|e| e.to_string())
19027}
19028
19029/// `runs.record_turns {run_id, turns}` — WS-only batch append of
19030/// client-narrated turns (feedback-agent A2UI/runs plan, U1).
19031///
19032/// The turn source is an out-of-pipeline agent whose work happens inside
19033/// its own subprocess (e.g. a `claude -p` resolver), invisible to the
19034/// proposal-path recorder. It builds full `RunTurn`s itself and pushes
19035/// them here in batches; the daemon appends them through the SAME
19036/// [`ServerState::record_run_turns`] the proposal recorder uses, so the
19037/// index re-stamp (FIX 6), JSONL persist, and `runs.trace.event` fanout
19038/// are byte-identical — this handler never re-implements locking, append,
19039/// or fanout.
19040///
19041/// Authorization (KTD): write access is the OWNING-agent binding only. The
19042/// read path's host-token gate must NOT apply here — a host-token or
19043/// unbound session writing another agent's turns is trace forgery. An
19044/// unknown run AND an unauthorized run collapse to the same uniform
19045/// `dropped: "run_not_found"` (mirroring `handle_runs_subscribe`'s FIX 3
19046/// not-found), so the response is never an existence/owner oracle.
19047///
19048/// Daemon-owned size invariants (R8a): per-field truncation, an aggregate
19049/// per-turn byte cap ([`enforce_turn_byte_cap`]), a batch-size cap (hard
19050/// error above), and a per-run turn ceiling (`run_turn_limit` — a hard
19051/// cap: no batch is accepted that would take the run past the ceiling).
19052/// Run existence/terminality is pre-checked to return a distinguishable
19053/// `ok: false` + reason rather than `record_run_turns`'s silent
19054/// zero-count drop; the benign TOCTOU (a run going terminal between the
19055/// check and the append) still drops silently inside `record_run_turns`,
19056/// matching existing recorder semantics.
19057async fn handle_runs_record_turns(
19058    req: &JsonRpcMessage,
19059    session: &crate::session::ClientSession,
19060    state: &Arc<ServerState>,
19061) -> Result<Value, String> {
19062    // Parse leniently: the wire `RunTurn` may omit `verifier_verdict`
19063    // (client-narrated turns default it to `not_run`) and carries a
19064    // client-supplied `index` we ignore. We deserialize each turn from a
19065    // generic object so we can inject the verdict default before the typed
19066    // `RunTurn` deserialize, without adding a serde default to the shared
19067    // proto type (the proposal recorder always sets the field).
19068    let raw_run_id = req
19069        .params
19070        .get("run_id")
19071        .and_then(Value::as_str)
19072        .map(str::to_string);
19073    let run_id = match raw_run_id {
19074        Some(id) if !id.trim().is_empty() => id,
19075        _ => return Err("runs.record_turns requires { run_id, turns: [RunTurn] }".to_string()),
19076    };
19077
19078    let raw_turns =
19079        match req.params.get("turns").and_then(Value::as_array) {
19080            Some(arr) => arr,
19081            None => return Err(
19082                "runs.record_turns requires { run_id, turns: [RunTurn] }: `turns` must be an array"
19083                    .to_string(),
19084            ),
19085        };
19086    if raw_turns.is_empty() {
19087        return Err("runs.record_turns requires a non-empty `turns` array".to_string());
19088    }
19089    if raw_turns.len() > RECORD_TURNS_MAX_BATCH {
19090        return Err(format!(
19091            "runs.record_turns batch too large: {} turns exceeds the per-call cap of {}",
19092            raw_turns.len(),
19093            RECORD_TURNS_MAX_BATCH
19094        ));
19095    }
19096
19097    // Decode each wire turn into a typed RunTurn, defaulting an absent
19098    // `verifier_verdict` to `not_run` and truncating its oversized string
19099    // fields to the daemon cap (R8a) — the daemon does not trust client
19100    // truncation for data that lands on disk.
19101    let mut turns: Vec<car_proto::RunTurn> = Vec::with_capacity(raw_turns.len());
19102    for (i, raw) in raw_turns.iter().enumerate() {
19103        let mut obj = raw.clone();
19104        match obj.as_object_mut() {
19105            Some(map) => {
19106                // The daemon owns the index (re-stamped under the `runs`
19107                // lock in `record_run_turns`), so a client-narrated turn's
19108                // `index` is genuinely IGNORED — the protocol doc says so.
19109                // ADV-6: overwrite it UNCONDITIONALLY with a placeholder `0`
19110                // (not `entry().or_insert`), so a present-but-invalid value
19111                // (`-1`, `"5"`, `1.5`) can't fail the typed `usize` decode and
19112                // reject the whole batch. The placeholder is re-stamped to the
19113                // live append position on append regardless.
19114                map.insert("index".to_string(), Value::Number(0.into()));
19115                map.entry("verifier_verdict")
19116                    .or_insert_with(|| Value::String("not_run".to_string()));
19117                // Truncate the heavy free-text / blob fields before the
19118                // typed decode so the bounded values are what gets stored.
19119                if let Some(p) = map.get_mut("prompt") {
19120                    truncate_turn_value(p);
19121                }
19122                if let Some(o) = map.get_mut("output") {
19123                    truncate_turn_value(o);
19124                }
19125                if let Some(params) = map.get_mut("parameters") {
19126                    truncate_turn_value(params);
19127                }
19128            }
19129            None => {
19130                return Err(format!(
19131                    "runs.record_turns requires {{ run_id, turns: [RunTurn] }}: \
19132                     turn {i} is not an object"
19133                ));
19134            }
19135        }
19136        let mut turn: car_proto::RunTurn = serde_json::from_value(obj).map_err(|e| {
19137            format!("runs.record_turns requires {{ run_id, turns: [RunTurn] }}: turn {i}: {e}")
19138        })?;
19139        // Aggregate cap (R8a): the per-field pass bounds each leaf, not the
19140        // sum of leaves — bound the whole encoded turn before it is stored.
19141        // ADV-5: a turn that is STILL over cap after the replace+field-cap
19142        // pass (a serialize failure, or a future free-form field) is REJECTED,
19143        // not waved through. Drop the whole batch with a structured reason and
19144        // log it server-side — an unbounded line must never reach disk.
19145        if !enforce_turn_byte_cap(&mut turn) {
19146            tracing::warn!(
19147                run_id = %run_id,
19148                turn = i,
19149                "runs.record_turns: turn could not be bounded under the per-turn byte cap; dropping batch"
19150            );
19151            return record_turns_dropped(&run_id, "turn_too_large");
19152        }
19153        turns.push(turn);
19154    }
19155
19156    // Resolve the run's owning agent: in-memory registry (live run) first,
19157    // disk fallback for a run whose RunMeta isn't in this process — the
19158    // `handle_runs_subscribe` pattern. Unknown run → uniform not-found.
19159    //
19160    // ADV-2: read the run HEADER only — agent, terminal, count, corruption —
19161    // not the whole `RunMeta`. The previous `run_meta` clone copied the
19162    // entire `turns` buffer (full prompts + CLI output) under the global
19163    // `runs` lock on every batch RPC; the handler needs only those three
19164    // facts.
19165    let header = state.run_header(&run_id).await;
19166    let owning_agent = match &header {
19167        Some((agent_id, _terminal, _len, _corruption)) => agent_id.clone(),
19168        None => match state.run_store.agent_for_run(&run_id) {
19169            Some(a) => a,
19170            // FIX 3 uniform not-found: an unknown run and an unauthorized
19171            // run are indistinguishable to the caller.
19172            None => return record_turns_dropped(&run_id, "run_not_found"),
19173        },
19174    };
19175
19176    // WRITE authz (KTD): owning-agent binding ONLY. A host-token or unbound
19177    // session must NOT be able to write another agent's turns — that is
19178    // forgery, the inverse of the read path's host-token allowance. An
19179    // unauthorized write collapses to the SAME `run_not_found` as an
19180    // unknown run (no existence/owner oracle); logged server-side only.
19181    let bound = session.agent_id.lock().await.clone();
19182    if bound.as_deref() != Some(owning_agent.as_str()) {
19183        tracing::debug!(
19184            run_id = %run_id,
19185            owning_agent = %owning_agent,
19186            client_id = %session.client_id,
19187            "runs.record_turns denied (not the owning agent); returning uniform not-found"
19188        );
19189        return record_turns_dropped(&run_id, "run_not_found");
19190    }
19191
19192    if let Some((_agent_id, _terminal, _len, Some(error))) = &header {
19193        return Err(error.clone());
19194    }
19195
19196    // Pre-check terminality and the ceiling as a FAST PATH only. These read
19197    // the lock-free `run_header` snapshot, so a write against an obviously
19198    // closed / already-over-ceiling run skips the decode+append. They are
19199    // NOT authoritative: the dispatcher spawns a task per frame, so pipelined
19200    // batches can each pass this snapshot before any of them appends (ADV-1).
19201    // The authoritative terminal+ceiling enforcement is under the `runs` lock
19202    // inside `record_run_turns`, whose `RecordRunTurnsOutcome` we map below.
19203    if let Some((_agent_id, terminal, len, _corruption)) = &header {
19204        if *terminal {
19205            return record_turns_dropped(&run_id, "run_terminal");
19206        }
19207        // Per-run turn ceiling (R8a) fast path: a runaway-loop backstop and a
19208        // TRUE hard cap — refuse any batch that would take the run PAST the
19209        // ceiling, whole, and tell the agent to stop sending. (Healthy
19210        // cycles are tens of turns; losing the straddling batch of a
19211        // 2000-turn runaway is correct, not data loss.) The under-lock check
19212        // in `record_run_turns` is the one that actually bounds the run; this
19213        // only short-circuits the common already-over case.
19214        if *len + turns.len() > RECORD_TURNS_RUN_CEILING {
19215            return record_turns_dropped(&run_id, "run_turn_limit");
19216        }
19217    }
19218
19219    // Append through the shared path — index re-stamp, JSONL persist,
19220    // `runs.trace.event` fanout, AND the authoritative under-lock ceiling all
19221    // happen inside `record_run_turns` under its own lock discipline. It
19222    // returns a `RecordRunTurnsOutcome` that distinguishes a healthy append
19223    // from a ceiling refusal and from an unknown/terminal run (ADV-1) — we
19224    // must NOT let an under-lock ceiling refusal masquerade as `run_terminal`.
19225    let count = turns.len();
19226    let records: Vec<car_proto::RunRecord> =
19227        turns.into_iter().map(car_proto::RunRecord::Turn).collect();
19228    let new_total = match state
19229        .record_run_turns_for_active_owner(&run_id, &session.client_id, records)
19230        .await
19231    {
19232        crate::session::RecordRunTurnsOutcome::Appended { new_total } => new_total,
19233        crate::session::RecordRunTurnsOutcome::RefusedCeiling => {
19234            // A pipelined batch crossed the ceiling under the lock even
19235            // though our fast-path snapshot was sub-ceiling — the runaway
19236            // backstop fired authoritatively. Tell the agent to stop.
19237            return record_turns_dropped(&run_id, "run_turn_limit");
19238        }
19239        crate::session::RecordRunTurnsOutcome::UnknownOrTerminal => {
19240            // `record_run_turns` appended nothing because the run is unknown
19241            // or went terminal. We resolved the run above, so the benign
19242            // cause is the TOCTOU: the run went terminal between our
19243            // fast-path check and the append. Surface a non-fatal drop.
19244            return record_turns_dropped(&run_id, "run_terminal");
19245        }
19246        crate::session::RecordRunTurnsOutcome::PersistenceFailed(error) => {
19247            if error.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
19248                return Err(error);
19249            }
19250            return Err(format!(
19251                "runs.record_turns persistence failed; retry exact batch safely: {error}"
19252            ));
19253        }
19254    };
19255
19256    serde_json::to_value(car_proto::RunRecordTurnsResponse {
19257        run_id,
19258        base_index: new_total - count,
19259        count,
19260        ok: true,
19261        dropped: None,
19262    })
19263    .map_err(|e| e.to_string())
19264}
19265
19266/// Authorize the calling connection to read/subscribe a run owned by
19267/// `agent_id` (R16 / KTD10 / Parslee-ai/car#254). A connection is
19268/// entitled when:
19269///
19270/// 1. It **owns** the agent — its `session.auth {agent_id}` binding
19271///    matches the run's owning `agent_id` (the supervised agent reading
19272///    its own trace), or
19273/// 2. It holds the **host-management role** — it authenticated via
19274///    `session.auth { host_token }` with the per-launch host token
19275///    (`ClientSession::is_host`).
19276///
19277/// What this gate buys, and its bound (be honest):
19278///
19279/// - It distinguishes agent-bound sessions, so one supervised agent's
19280///   connection cannot read a *different* agent's runs by guessing ids.
19281/// - Host-role is gated by the **host token**, NOT by `host.subscribe`
19282///   membership. This is the #254 fix: `host.subscribe` has no authz, so
19283///   keying on `is_subscribed` let ANY authenticated connection
19284///   self-elevate and read every agent's traces. The host token is read
19285///   only from the `0600` `host-token` file (never served over
19286///   `GET /auth-token`), so a different local user — or a client that
19287///   scraped the auth token off the HTTP endpoint — cannot obtain it.
19288/// - It is therefore a real confidentiality boundary against *another
19289///   local user* (multi-user / CI / remote daemon). It is NOT an
19290///   isolation boundary against a *malicious same-user process*: that
19291///   process can read the `0600` host-token (and the `0600` run files)
19292///   directly. Closing same-user isolation would need DPAPI/Keychain or
19293///   process-cred (SO_PEERCRED) auth — tracked separately, out of scope.
19294///
19295/// NOTE: lower-sensitivity host *metadata* — the agent roster, approvals,
19296/// and host events delivered by `host.subscribe` / `host.agents` /
19297/// `host.approvals` — is intentionally still available to any
19298/// authenticated connection (the local UI consumes it). Only run-trace
19299/// *content* (prompts, CLI output) requires host-role. Tightening that
19300/// metadata surface is a separate decision from #254.
19301async fn authorize_run_access(
19302    session: &crate::session::ClientSession,
19303    _state: &Arc<ServerState>,
19304    owning_agent_id: &str,
19305) -> Result<(), String> {
19306    // 1. The connection owns the agent (its own run).
19307    if let Some(bound) = session.agent_id.lock().await.clone() {
19308        if bound == owning_agent_id {
19309            return Ok(());
19310        }
19311    }
19312    // 2. The connection authenticated as the host-management client by
19313    //    presenting the per-launch host token (Parslee-ai/car#254).
19314    //    NOTE: this is deliberately NOT `host.is_subscribed(...)` — that
19315    //    check let any authenticated connection self-elevate just by
19316    //    calling `host.subscribe` (which has no authz), reading every
19317    //    agent's run traces. host.subscribe still works for host *events*;
19318    //    it just no longer grants the cross-agent run-trace read.
19319    if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
19320        return Ok(());
19321    }
19322    // Do NOT name the owning `agent_id` in the error (FIX 3): a caller-
19323    // supplied or resolved id is not a transparent key, and echoing it back
19324    // turns a rejection into an existence/owner oracle. Log it server-side
19325    // for diagnosis; keep the wire message agent-agnostic.
19326    tracing::debug!(
19327        owning_agent = %owning_agent_id,
19328        client_id = %session.client_id,
19329        "run access denied: connection neither owns the agent nor is the host client"
19330    );
19331    Err(
19332        "not authorized to access this run: this connection neither owns the \
19333         owning agent (via session.auth) nor is the host management client"
19334            .to_string(),
19335    )
19336}
19337
19338/// `runs.subscribe {run_id,cursor,limit}` — return one bounded catch-up page
19339/// and register for `runs.trace.event` only when the page reaches live (U4).
19340///
19341/// Authorizes the caller against the run's owning `agent_id` (R16), then
19342/// atomically pages the run's turns and, on the final page, registers the
19343/// subscriber under the `runs` lock (invariant #1, in
19344/// [`ServerState::subscribe_run_page`]).
19345///
19346/// FIX 3 (existence/owner oracle): an unknown `run_id` and an
19347/// exists-but-unauthorized `run_id` must be INDISTINGUISHABLE. Both return
19348/// the same uniform not-found marker (`{ run_id, not_found: true }`,
19349/// mirroring [`handle_runs_get_trace`]) — never an error frame that varies
19350/// by case, and never the owning `agent_id`. Otherwise an unentitled caller
19351/// could probe which `run_id`s exist (and learn the owning agent) by
19352/// telling "unknown" apart from "not authorized".
19353async fn handle_runs_subscribe(
19354    req: &JsonRpcMessage,
19355    session: &crate::session::ClientSession,
19356    state: &Arc<ServerState>,
19357) -> Result<Value, String> {
19358    const MAX_LIMIT: usize = 500;
19359    #[derive(Deserialize)]
19360    struct LegacyRequest {
19361        run_id: String,
19362    }
19363    let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19364    let (run_id, cursor, limit) = if paginated {
19365        let params: car_proto::RunSubscribeRequest = serde_json::from_value(req.params.clone())
19366            .map_err(|e| format!("runs.subscribe requires {{ run_id, cursor, limit }}: {e}"))?;
19367        (params.run_id, params.cursor, params.limit)
19368    } else {
19369        let params: LegacyRequest = serde_json::from_value(req.params.clone())
19370            .map_err(|e| format!("runs.subscribe requires {{ run_id }}: {e}"))?;
19371        (params.run_id, 0, crate::session::RECORD_TURNS_RUN_CEILING)
19372    };
19373    if !(1..=MAX_LIMIT).contains(&limit) && paginated {
19374        return Err(format!(
19375            "runs.subscribe limit must be between 1 and {MAX_LIMIT}"
19376        ));
19377    }
19378
19379    // Uniform not-found marker shared by the unknown-run and unauthorized
19380    // cases so the two are indistinguishable to the caller (FIX 3).
19381    let not_found = || {
19382        serde_json::to_value(serde_json::json!({
19383            "run_id": run_id,
19384            "not_found": true,
19385        }))
19386        .map_err(|e| e.to_string())
19387    };
19388
19389    // Resolve the run's owning agent_id. Prefer the in-memory registry
19390    // (live run); fall back to the disk store (a run that exists but whose
19391    // RunMeta isn't in this process). Unknown run → uniform not-found.
19392    let owning_agent = match state.run_header(&run_id).await {
19393        Some((agent_id, _, _, _)) => agent_id,
19394        None => {
19395            let run_store = state.run_store.clone();
19396            let durable_run_id = run_id.clone();
19397            match run_store_blocking("runs.subscribe owner lookup", move || {
19398                Ok(run_store.agent_for_run(&durable_run_id))
19399            })
19400            .await?
19401            {
19402                Some(agent_id) => agent_id,
19403                None => return not_found(),
19404            }
19405        }
19406    };
19407
19408    // R16/KTD10: gate before snapshotting. An unauthorized caller gets the
19409    // SAME uniform not-found as an unknown run — no distinguishable outcome,
19410    // no leaked owning `agent_id` (logged server-side only).
19411    if authorize_run_access(session, state, &owning_agent)
19412        .await
19413        .is_err()
19414    {
19415        tracing::debug!(
19416            run_id = %run_id,
19417            owning_agent = %owning_agent,
19418            client_id = %session.client_id,
19419            "runs.subscribe denied (unauthorized); returning uniform not-found"
19420        );
19421        return not_found();
19422    }
19423
19424    let page = match state
19425        .subscribe_run_page(
19426            &run_id,
19427            &session.client_id,
19428            session.channel.clone(),
19429            cursor,
19430            limit,
19431        )
19432        .await?
19433    {
19434        Some(crate::session::RunSubscribePageResult::Ready(page)) => page,
19435        Some(crate::session::RunSubscribePageResult::Durable { agent_id, status }) => {
19436            let run_store = state.run_store.clone();
19437            let durable_run_id = run_id.clone();
19438            let page = strict_run_trace_read_blocking(
19439                "runs.subscribe durable page",
19440                state,
19441                run_id.clone(),
19442                move || run_store.get_run_turn_page_for(&agent_id, &durable_run_id, cursor, limit),
19443            )
19444            .await?;
19445            let Some((turns, next_cursor, live_cursor, durable_status)) = page else {
19446                return not_found();
19447            };
19448            let status = match durable_status {
19449                crate::run_store::RunStatus::InProgress => status,
19450                crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19451                crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19452                crate::run_store::RunStatus::CancellationPending => {
19453                    car_proto::RunLiveStatus::CancellationPending
19454                }
19455                crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19456            };
19457            car_proto::RunSubscribeResponse {
19458                run_id: run_id.clone(),
19459                agent_id: owning_agent.clone(),
19460                turns,
19461                cursor,
19462                limit,
19463                next_cursor,
19464                live_cursor,
19465                subscribed: next_cursor.is_none(),
19466                status,
19467            }
19468        }
19469        // A restart has no in-memory RunMeta for retained terminal history.
19470        // The owner lookup above already proved the durable file exists, so
19471        // page it directly instead of turning a valid replay subscription into
19472        // a false not-found (and, critically, run the strict corruption scan).
19473        None => {
19474            let run_store = state.run_store.clone();
19475            let durable_run_id = run_id.clone();
19476            let durable_agent = owning_agent.clone();
19477            let page = strict_run_trace_read_blocking(
19478                "runs.subscribe retained durable page",
19479                state,
19480                run_id.clone(),
19481                move || {
19482                    run_store.get_run_turn_page_for(&durable_agent, &durable_run_id, cursor, limit)
19483                },
19484            )
19485            .await?;
19486            if let Some((turns, next_cursor, live_cursor, durable_status)) = page {
19487                let status = match durable_status {
19488                    crate::run_store::RunStatus::InProgress => car_proto::RunLiveStatus::InProgress,
19489                    crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19490                    crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19491                    crate::run_store::RunStatus::CancellationPending => {
19492                        car_proto::RunLiveStatus::CancellationPending
19493                    }
19494                    crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19495                };
19496                car_proto::RunSubscribeResponse {
19497                    run_id: run_id.clone(),
19498                    agent_id: owning_agent.clone(),
19499                    turns,
19500                    cursor,
19501                    limit,
19502                    next_cursor,
19503                    live_cursor,
19504                    subscribed: next_cursor.is_none(),
19505                    status,
19506                }
19507            } else if !paginated {
19508                // Pre-v3 durable traces have no summary sidecar. The legacy
19509                // no-capability lane intentionally preserves v2's complete,
19510                // unbounded snapshot semantics, so recover the turns and
19511                // status from the strict JSONL read instead of hiding valid
19512                // persisted user history behind a false not-found.
19513                let run_store = state.run_store.clone();
19514                let durable_run_id = run_id.clone();
19515                let durable_agent = owning_agent.clone();
19516                let records = strict_run_trace_read_blocking(
19517                    "runs.subscribe pre-v3 durable trace",
19518                    state,
19519                    run_id.clone(),
19520                    move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
19521                )
19522                .await?;
19523                let Some(records) = records else {
19524                    return not_found();
19525                };
19526                let mut status = car_proto::RunLiveStatus::InProgress;
19527                let mut turns = Vec::new();
19528                for record in records {
19529                    match record {
19530                        turn @ car_proto::RunRecord::Turn(_) => turns.push(turn),
19531                        car_proto::RunRecord::CancellationRequested(_)
19532                        | car_proto::RunRecord::CancellationResult(_) => {
19533                            if status == car_proto::RunLiveStatus::InProgress {
19534                                status = car_proto::RunLiveStatus::CancellationPending;
19535                            }
19536                        }
19537                        car_proto::RunRecord::Ended(ended) => {
19538                            status = match ended.termination {
19539                                car_proto::RunTermination::Outcome { .. } => {
19540                                    car_proto::RunLiveStatus::Completed
19541                                }
19542                                car_proto::RunTermination::Incomplete => {
19543                                    car_proto::RunLiveStatus::Incomplete
19544                                }
19545                                car_proto::RunTermination::Cancelled { .. } => {
19546                                    car_proto::RunLiveStatus::Cancelled
19547                                }
19548                            };
19549                        }
19550                        car_proto::RunRecord::Started(_) => {}
19551                    }
19552                }
19553                let live_cursor = turns.len();
19554                car_proto::RunSubscribeResponse {
19555                    run_id: run_id.clone(),
19556                    agent_id: owning_agent.clone(),
19557                    turns,
19558                    cursor: 0,
19559                    limit,
19560                    next_cursor: None,
19561                    live_cursor,
19562                    subscribed: false,
19563                    status,
19564                }
19565            } else {
19566                return not_found();
19567            }
19568        }
19569    };
19570    if paginated {
19571        serde_json::to_value(page).map_err(|e| e.to_string())
19572    } else {
19573        serde_json::to_value(serde_json::json!({
19574            "run_id": page.run_id,
19575            "agent_id": page.agent_id,
19576            "turns_so_far": page.turns,
19577            "cursor": page.live_cursor,
19578            "status": page.status,
19579        }))
19580        .map_err(|e| e.to_string())
19581    }
19582}
19583
19584/// `runs.unsubscribe {run_id}` — drop this connection's live run-trace
19585/// subscription for `run_id` (U4). Idempotent: returns `removed: false`
19586/// if there was nothing to remove. No authorization gate is needed —
19587/// removing your own subscription leaks nothing.
19588async fn handle_runs_unsubscribe(
19589    req: &JsonRpcMessage,
19590    session: &crate::session::ClientSession,
19591    state: &Arc<ServerState>,
19592) -> Result<Value, String> {
19593    let params: car_proto::RunUnsubscribeRequest = serde_json::from_value(req.params.clone())
19594        .map_err(|e| format!("runs.unsubscribe requires {{ run_id }}: {e}"))?;
19595    let removed = state
19596        .unsubscribe_run(&params.run_id, &session.client_id)
19597        .await;
19598    serde_json::to_value(car_proto::RunUnsubscribeResponse {
19599        run_id: params.run_id,
19600        removed,
19601    })
19602    .map_err(|e| e.to_string())
19603}
19604
19605/// `runs.list {agent_id,cursor,limit}` — list one bounded page of an agent's
19606/// runs newest-first for replay (U5). Reads the disk store's durable summary
19607/// index ([`RunStore::list_runs_page`]), so it works
19608/// across daemon restart / `client_id` churn — `agent_id` is the durable
19609/// key, not the connection.
19610///
19611/// R16/KTD10: the `agent_id` is **not** a transparent key. We authorize
19612/// the caller for it FIRST — it must own the agent (`session.auth
19613/// {agent_id}`) or be the CarHost host-client — so an unentitled caller
19614/// can't enumerate other agents' run lists. An authorized caller for an
19615/// agent with no runs gets an empty list (the empty state, R13).
19616async fn handle_runs_list(
19617    req: &JsonRpcMessage,
19618    session: &crate::session::ClientSession,
19619    state: &Arc<ServerState>,
19620) -> Result<Value, String> {
19621    #[derive(Deserialize)]
19622    struct LegacyRequest {
19623        agent_id: String,
19624    }
19625    let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19626    let (agent_id, page) = if paginated {
19627        let params: car_proto::RunListRequest = serde_json::from_value(req.params.clone())
19628            .map_err(|e| format!("runs.list requires {{ agent_id, cursor, limit }}: {e}"))?;
19629        (params.agent_id, Some((params.cursor, params.limit)))
19630    } else {
19631        let params: LegacyRequest = serde_json::from_value(req.params.clone())
19632            .map_err(|e| format!("runs.list requires {{ agent_id }}: {e}"))?;
19633        (params.agent_id, None)
19634    };
19635
19636    // Gate before reading — the param is an authorization subject, not a
19637    // lookup key. An unentitled caller is rejected, never served a list.
19638    authorize_run_access(session, state, &agent_id).await?;
19639
19640    const MAX_LIMIT: usize = 200;
19641    if let Some((_, limit)) = page {
19642        if !(1..=MAX_LIMIT).contains(&limit) {
19643            return Err(format!("runs.list limit must be between 1 and {MAX_LIMIT}"));
19644        }
19645    }
19646    let run_store = state.run_store.clone();
19647    let durable_agent_id = agent_id.clone();
19648    let Some((cursor, limit)) = page else {
19649        let runs = run_store_blocking("runs.list legacy complete read", move || {
19650            Ok(run_store.list_runs(&durable_agent_id))
19651        })
19652        .await?;
19653        return Ok(serde_json::json!({"agent_id": agent_id, "runs": runs}));
19654    };
19655    let (runs, next_cursor) = run_store_blocking("runs.list page", move || {
19656        run_store
19657            .list_runs_page(&durable_agent_id, cursor, limit)
19658            .map_err(|error| format!("runs.list durable index read failed: {error}"))
19659    })
19660    .await?;
19661    let mut response = serde_json::json!({
19662        "agent_id": agent_id,
19663        "runs": runs,
19664        "cursor": cursor,
19665        "limit": limit,
19666    });
19667    if let Some(next_cursor) = next_cursor {
19668        response["next_cursor"] = serde_json::json!(next_cursor);
19669    }
19670    Ok(response)
19671}
19672
19673/// `runs.get_trace {run_id,cursor,limit}` — fetch one bounded page of a
19674/// run's ordered trace from the disk store for replay (U5). Sequential pages
19675/// replay identically to what the live stream delivered, including after a
19676/// fresh daemon restart with empty memory.
19677///
19678/// R16/KTD10: resolve the run's owning `agent_id` from disk and authorize
19679/// the caller against it before serving any record — a `run_id` is not a
19680/// transparent key. An unknown `run_id` (no file on disk) returns a clear
19681/// not-found marker (`{ run_id, not_found: true }`), not an error frame,
19682/// so a UI can distinguish "no such run" from a transport failure.
19683///
19684/// A `timeout`/`Incomplete` run is **not** an error: its persisted trail
19685/// (the turns recorded so far plus the terminal `Ended`/`Incomplete`
19686/// marker) is returned, so the dashboard renders the partial run.
19687async fn handle_runs_get_trace(
19688    req: &JsonRpcMessage,
19689    session: &crate::session::ClientSession,
19690    state: &Arc<ServerState>,
19691) -> Result<Value, String> {
19692    #[derive(Deserialize)]
19693    struct LegacyRequest {
19694        run_id: String,
19695        #[serde(default)]
19696        cursor: Option<usize>,
19697    }
19698    let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19699    let (run_id, cursor, limit) = if paginated {
19700        let params: car_proto::RunGetTraceRequest = serde_json::from_value(req.params.clone())
19701            .map_err(|e| format!("runs.get_trace requires {{ run_id, cursor, limit }}: {e}"))?;
19702        (params.run_id, params.cursor, Some(params.limit))
19703    } else {
19704        let params: LegacyRequest = serde_json::from_value(req.params.clone())
19705            .map_err(|e| format!("runs.get_trace requires {{ run_id }}: {e}"))?;
19706        (params.run_id, params.cursor.unwrap_or(0), None)
19707    };
19708    const MAX_LIMIT: usize = 500;
19709    if let Some(limit) = limit {
19710        if !(1..=MAX_LIMIT).contains(&limit) {
19711            return Err(format!(
19712                "runs.get_trace limit must be between 1 and {MAX_LIMIT}"
19713            ));
19714        }
19715    }
19716
19717    // Resolve the owning agent from disk (the durable key path — works
19718    // after a restart). An unknown run_id has no owner: not-found, and we
19719    // never reach the store read. We don't reveal whether the id exists to
19720    // an unauthorized caller — authorization is checked against the
19721    // resolved owner, and resolution failure is the same not-found either
19722    // way.
19723    let run_store = state.run_store.clone();
19724    let durable_run_id = run_id.clone();
19725    let owning_agent = run_store_blocking("runs.get_trace owner lookup", move || {
19726        Ok(run_store.agent_for_run(&durable_run_id))
19727    })
19728    .await?;
19729    let Some(owning_agent) = owning_agent else {
19730        return serde_json::to_value(serde_json::json!({
19731            "run_id": run_id,
19732            "not_found": true,
19733        }))
19734        .map_err(|e| e.to_string());
19735    };
19736
19737    // R16/KTD10: gate before serving any record.
19738    authorize_run_access(session, state, &owning_agent).await?;
19739
19740    // Stream one bounded page from disk. `agent_for_run` just resolved the
19741    // file, so this is the cheaper keyed read.
19742    let run_store = state.run_store.clone();
19743    let durable_agent = owning_agent.clone();
19744    let durable_run_id = run_id.clone();
19745    if limit.is_none() {
19746        let records = strict_run_trace_read_blocking(
19747            "runs.get_trace legacy complete read",
19748            state,
19749            run_id.clone(),
19750            move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
19751        )
19752        .await?;
19753        let Some(records) = records else {
19754            return Ok(serde_json::json!({"run_id": run_id, "not_found": true}));
19755        };
19756        let records = records.into_iter().skip(cursor).collect::<Vec<_>>();
19757        return Ok(serde_json::json!({
19758            "run_id": run_id,
19759            "agent_id": owning_agent,
19760            "records": records,
19761            "cursor": cursor,
19762        }));
19763    }
19764    let limit = limit.expect("paginated path has a limit");
19765    let page =
19766        strict_run_trace_read_blocking("runs.get_trace page", state, run_id.clone(), move || {
19767            run_store.get_run_trace_page_for(&durable_agent, &durable_run_id, cursor, limit)
19768        })
19769        .await?;
19770    let Some((paged, next_cursor)) = page else {
19771        return serde_json::to_value(serde_json::json!({
19772            "run_id": run_id,
19773            "not_found": true,
19774        }))
19775        .map_err(|e| e.to_string());
19776    };
19777
19778    let mut response = serde_json::json!({
19779        "run_id": run_id,
19780        "agent_id": owning_agent,
19781        "records": paged,
19782        "cursor": cursor,
19783        "limit": limit,
19784    });
19785    if let Some(next_cursor) = next_cursor {
19786        response["next_cursor"] = serde_json::json!(next_cursor);
19787    }
19788    Ok(response)
19789}
19790
19791fn run_trace_read_error(run_id: &str, error: std::io::Error) -> String {
19792    if crate::run_store::is_trace_corruption_error(&error) {
19793        format!(
19794            "{} run `{run_id}`: {error}",
19795            car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX
19796        )
19797    } else {
19798        format!("run trace read failed for `{run_id}`: {error}")
19799    }
19800}
19801
19802/// Update the per-session replan config. Wire shape mirrors the
19803/// FFI's positional `set_replan_config` arguments — the engine
19804/// crate's `ReplanConfig` struct doesn't derive Serialize, so we
19805/// reconstruct it from a flat object here.
19806async fn handle_replan_set_config(
19807    msg: &JsonRpcMessage,
19808    session: &crate::session::ClientSession,
19809) -> Result<Value, String> {
19810    let max_replans = msg
19811        .params
19812        .get("max_replans")
19813        .and_then(|v| v.as_u64())
19814        .unwrap_or(0) as u32;
19815    let delay_ms = msg
19816        .params
19817        .get("delay_ms")
19818        .and_then(|v| v.as_u64())
19819        .unwrap_or(0);
19820    let verify_before_execute = msg
19821        .params
19822        .get("verify_before_execute")
19823        .and_then(|v| v.as_bool())
19824        .unwrap_or(true);
19825    let replan_on_rejected = msg
19826        .params
19827        .get("replan_on_rejected")
19828        .and_then(|v| v.as_bool())
19829        .unwrap_or(false);
19830    let cfg = car_engine::ReplanConfig {
19831        max_replans,
19832        delay_ms,
19833        verify_before_execute,
19834        replan_on_rejected,
19835    };
19836    session.runtime.set_replan_config(cfg).await;
19837    Ok(Value::Null)
19838}
19839
19840async fn handle_skills_list(
19841    msg: &JsonRpcMessage,
19842    session: &crate::session::ClientSession,
19843) -> Result<Value, String> {
19844    let domain = msg.params.get("domain").and_then(|v| v.as_str());
19845    let engine = session.memgine.lock().await;
19846    let skills: Vec<serde_json::Value> = engine
19847        .graph
19848        .inner
19849        .node_indices()
19850        .filter_map(|nix| {
19851            let node = engine.graph.inner.node_weight(nix)?;
19852            if node.kind != car_memgine::MemKind::Skill {
19853                return None;
19854            }
19855            let meta = car_memgine::SkillMeta::from_node(node)?;
19856            if let Some(d) = domain {
19857                match &meta.scope {
19858                    car_memgine::SkillScope::Global => {}
19859                    car_memgine::SkillScope::Domain(sd) if sd == d => {}
19860                    _ => return None,
19861                }
19862            }
19863            Some(serde_json::to_value(&meta).unwrap_or_default())
19864        })
19865        .collect();
19866    serde_json::to_value(&skills).map_err(|e| e.to_string())
19867}
19868
19869#[derive(serde::Deserialize)]
19870struct SecretParams {
19871    #[serde(default)]
19872    service: Option<String>,
19873    key: String,
19874    #[serde(default)]
19875    value: Option<String>,
19876    /// Set only by the local CLI reader after it loaded the owner-only host
19877    /// token. This is authorization intent, not identity; the authenticated
19878    /// session remains the proof.
19879    #[serde(default)]
19880    operator_broker: bool,
19881}
19882
19883fn canonical_secret_key(key: &str) -> &str {
19884    if key.eq_ignore_ascii_case("openrouter") {
19885        car_inference::openrouter::API_KEY_ENV
19886    } else {
19887        key
19888    }
19889}
19890
19891fn reject_reserved_oauth_secret(service: Option<&str>, key: &str) -> Result<(), String> {
19892    if car_inference::openrouter::is_reserved_oauth_secret(service, key) {
19893        Err("reserved_private_secret: the OpenRouter OAuth credential is managed only by openrouter.auth_start/openrouter.disconnect".to_string())
19894    } else {
19895        Ok(())
19896    }
19897}
19898
19899fn is_openrouter_pasted_authority_slot(service: Option<&str>, key: &str) -> bool {
19900    let is_default_service = service.is_none() || service == Some(car_secrets::DEFAULT_SERVICE);
19901    is_default_service && key == car_inference::openrouter::API_KEY_ENV
19902}
19903
19904async fn handle_secret_put(req: &JsonRpcMessage) -> Result<Value, String> {
19905    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
19906    let value = p.value.ok_or_else(|| "missing 'value'".to_string())?;
19907    let key = canonical_secret_key(&p.key);
19908    reject_reserved_oauth_secret(p.service.as_deref(), key)?;
19909    if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
19910        return car_ffi_common::secrets::put(p.service.as_deref(), key, &value);
19911    }
19912    let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(true, || {
19913        car_ffi_common::secrets::put(p.service.as_deref(), key, &value)
19914    })
19915    .await;
19916    result.map(|value| with_openrouter_authority_generation(value, &final_status))
19917}
19918
19919async fn handle_secret_get(
19920    req: &JsonRpcMessage,
19921    session: &crate::session::ClientSession,
19922    state: &crate::session::ServerState,
19923) -> Result<Value, String> {
19924    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
19925    let key = canonical_secret_key(&p.key);
19926    let agent_id = session.agent_id.lock().await.clone();
19927
19928    if p.operator_broker {
19929        // The auth gate has already validated this session. Host authority is
19930        // backed by the owner-only local token file (the ordinary token is
19931        // exposed to dashboard clients), which is the same-UID proof. Requiring
19932        // auth-enabled state closes `--no-auth`; rejecting a bound agent keeps
19933        // it from self-promoting past policy.
19934        if state.auth_token.get().is_none()
19935            || !session
19936                .authenticated
19937                .load(std::sync::atomic::Ordering::Acquire)
19938            || !session.is_host.load(std::sync::atomic::Ordering::Acquire)
19939            || agent_id.is_some()
19940        {
19941            return Err(
19942                "operator_broker_denied: secret.get operator broker requires an authenticated local host session"
19943                    .to_string(),
19944            );
19945        }
19946        let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
19947        let action = car_ir::Action::tool_call("secret.get")
19948            .with_param("service", Value::String(service.to_string()))
19949            .with_param("key", Value::String(key.to_string()))
19950            .with_param("caller_role", Value::String("operator".to_string()));
19951        let result =
19952            car_ffi_common::secrets::get_for_daemon_operator_broker(p.service.as_deref(), key);
19953        let (kind, outcome) = if result.is_ok() {
19954            (car_eventlog::EventKind::ActionSucceeded, "read")
19955        } else {
19956            (car_eventlog::EventKind::ActionFailed, "secret_store_error")
19957        };
19958        session.runtime.log.lock().await.append(
19959            kind,
19960            Some(&action.id),
19961            None,
19962            operator_secret_read_audit_data(service, key, outcome),
19963        );
19964        return result;
19965    }
19966
19967    reject_reserved_oauth_secret(p.service.as_deref(), key)?;
19968
19969    let Some(agent_id) = agent_id else {
19970        // Hosts and user-run standalone clients keep the existing generic
19971        // secret.get contract. The broker boundary applies to authenticated
19972        // supervised-agent sessions, whose identity the daemon can enforce and
19973        // audit rather than trusting a caller-supplied label.
19974        return car_ffi_common::secrets::get(p.service.as_deref(), key);
19975    };
19976    let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
19977    let action = car_ir::Action::tool_call("secret.get")
19978        .with_param("service", Value::String(service.to_string()))
19979        .with_param("key", Value::String(key.to_string()))
19980        .with_param("agent_id", Value::String(agent_id.clone()));
19981    let violations = session
19982        .runtime
19983        .policies
19984        .read()
19985        .await
19986        .check(&action, &session.runtime.state);
19987    if !violations.is_empty() {
19988        let policies: Vec<Value> = violations
19989            .iter()
19990            .map(|violation| Value::String(violation.policy_name.clone()))
19991            .collect();
19992        let mut data = secret_read_audit_data(service, key, &agent_id, "policy_denied");
19993        data.insert("policies".to_string(), Value::Array(policies.clone()));
19994        session.runtime.log.lock().await.append(
19995            car_eventlog::EventKind::PolicyViolation,
19996            Some(&action.id),
19997            None,
19998            data,
19999        );
20000        return Err(serde_json::json!({
20001            "code": "policy_denied",
20002            "message": "secret.get was denied by the daemon policy engine",
20003            "context": {"service": service, "key": key, "agent_id": agent_id},
20004            "policies": policies,
20005        })
20006        .to_string());
20007    }
20008
20009    let result = car_ffi_common::secrets::get(p.service.as_deref(), key);
20010    let (kind, outcome) = if result.is_ok() {
20011        (car_eventlog::EventKind::ActionSucceeded, "read")
20012    } else {
20013        (car_eventlog::EventKind::ActionFailed, "secret_store_error")
20014    };
20015    session.runtime.log.lock().await.append(
20016        kind,
20017        Some(&action.id),
20018        None,
20019        secret_read_audit_data(service, key, &agent_id, outcome),
20020    );
20021    result
20022}
20023
20024fn secret_read_audit_data(
20025    service: &str,
20026    key: &str,
20027    agent_id: &str,
20028    outcome: &str,
20029) -> HashMap<String, Value> {
20030    HashMap::from([
20031        ("tool".to_string(), Value::String("secret.get".to_string())),
20032        ("service".to_string(), Value::String(service.to_string())),
20033        ("key".to_string(), Value::String(key.to_string())),
20034        ("agent_id".to_string(), Value::String(agent_id.to_string())),
20035        ("outcome".to_string(), Value::String(outcome.to_string())),
20036    ])
20037}
20038
20039fn operator_secret_read_audit_data(
20040    service: &str,
20041    key: &str,
20042    outcome: &str,
20043) -> HashMap<String, Value> {
20044    HashMap::from([
20045        ("tool".to_string(), Value::String("secret.get".to_string())),
20046        ("service".to_string(), Value::String(service.to_string())),
20047        ("key".to_string(), Value::String(key.to_string())),
20048        (
20049            "caller_role".to_string(),
20050            Value::String("operator".to_string()),
20051        ),
20052        ("outcome".to_string(), Value::String(outcome.to_string())),
20053    ])
20054}
20055
20056async fn handle_secret_delete(req: &JsonRpcMessage) -> Result<Value, String> {
20057    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20058    let key = canonical_secret_key(&p.key);
20059    reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20060    if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
20061        return car_ffi_common::secrets::delete(p.service.as_deref(), key);
20062    }
20063    let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(false, || {
20064        car_ffi_common::secrets::delete(p.service.as_deref(), key)
20065    })
20066    .await;
20067    result.map(|value| with_openrouter_authority_generation(value, &final_status))
20068}
20069
20070fn with_openrouter_authority_generation(
20071    mut value: Value,
20072    status: &crate::openrouter_auth::Status,
20073) -> Value {
20074    if let Value::Object(fields) = &mut value {
20075        fields.insert(
20076            "authority_generation".to_string(),
20077            Value::from(status.authority_generation),
20078        );
20079        return value;
20080    }
20081    serde_json::json!({
20082        "ok": true,
20083        "authority_generation": status.authority_generation,
20084        "result": value,
20085    })
20086}
20087
20088fn handle_secret_status(req: &JsonRpcMessage) -> Result<Value, String> {
20089    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20090    car_ffi_common::secrets::status(p.service.as_deref(), canonical_secret_key(&p.key))
20091}
20092
20093#[cfg(test)]
20094mod openrouter_secret_service_authority_tests {
20095    use super::{handle_secret_delete, handle_secret_put, handle_secret_status, JsonRpcMessage};
20096    use serde_json::{json, Value};
20097
20098    static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
20099
20100    struct FileSecretEnvironment(Option<std::ffi::OsString>);
20101
20102    impl FileSecretEnvironment {
20103        fn install(path: &std::path::Path) -> Self {
20104            let previous = std::env::var_os("CAR_SECRETS_FILE_DIR");
20105            unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", path) };
20106            Self(previous)
20107        }
20108    }
20109
20110    impl Drop for FileSecretEnvironment {
20111        fn drop(&mut self) {
20112            match self.0.take() {
20113                Some(previous) => unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", previous) },
20114                None => unsafe { std::env::remove_var("CAR_SECRETS_FILE_DIR") },
20115            }
20116        }
20117    }
20118
20119    fn req(params: Value) -> JsonRpcMessage {
20120        JsonRpcMessage {
20121            jsonrpc: "2.0".into(),
20122            method: None,
20123            params,
20124            id: json!(1),
20125            result: None,
20126            error: None,
20127        }
20128    }
20129
20130    #[tokio::test(flavor = "current_thread")]
20131    async fn non_default_service_put_is_generic_and_does_not_publish_pasted_authority() {
20132        let _env_lock = ENV_LOCK.lock().await;
20133        let temp = tempfile::TempDir::new().unwrap();
20134        let _environment = FileSecretEnvironment::install(temp.path());
20135        let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20136
20137        let result = handle_secret_put(&req(json!({
20138            "service": "integration-test-vault",
20139            "key": "openrouter",
20140            "value": "custom-service-only"
20141        })))
20142        .await
20143        .unwrap();
20144        assert_eq!(result["service"], "integration-test-vault");
20145        assert_eq!(result["key"], car_inference::openrouter::API_KEY_ENV);
20146        assert!(
20147            result.get("authority_generation").is_none(),
20148            "a generic service write must not claim it changed OpenRouter authority: {result}"
20149        );
20150        let generic_status = handle_secret_status(&req(json!({
20151            "service": "integration-test-vault",
20152            "key": "openrouter"
20153        })))
20154        .unwrap();
20155        assert_eq!(generic_status["exists"], true);
20156        assert_eq!(
20157            serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20158            authority_before,
20159            "a non-default service cannot supersede OAuth/default-service resolution"
20160        );
20161    }
20162
20163    #[tokio::test(flavor = "current_thread")]
20164    async fn non_default_service_delete_is_generic_and_does_not_publish_false_absence() {
20165        let _env_lock = ENV_LOCK.lock().await;
20166        let temp = tempfile::TempDir::new().unwrap();
20167        let _environment = FileSecretEnvironment::install(temp.path());
20168        car_ffi_common::secrets::put(
20169            Some("integration-test-vault-delete"),
20170            car_inference::openrouter::API_KEY_ENV,
20171            "custom-service-only",
20172        )
20173        .unwrap();
20174        let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20175
20176        let result = handle_secret_delete(&req(json!({
20177            "service": "integration-test-vault-delete",
20178            "key": "openrouter"
20179        })))
20180        .await
20181        .unwrap();
20182        assert_eq!(result["service"], "integration-test-vault-delete");
20183        assert!(
20184            result.get("authority_generation").is_none(),
20185            "a generic service delete must not claim it changed OpenRouter authority: {result}"
20186        );
20187        let generic_status = handle_secret_status(&req(json!({
20188            "service": "integration-test-vault-delete",
20189            "key": "openrouter"
20190        })))
20191        .unwrap();
20192        assert_eq!(generic_status["exists"], false);
20193        assert_eq!(
20194            serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20195            authority_before,
20196            "deleting another service's key cannot publish false default-service absence"
20197        );
20198    }
20199}
20200
20201#[derive(serde::Deserialize)]
20202struct PermParams {
20203    domain: String,
20204    #[serde(default)]
20205    target_bundle_id: Option<String>,
20206}
20207
20208fn handle_perm_status(req: &JsonRpcMessage) -> Result<Value, String> {
20209    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20210    car_ffi_common::permissions::status(&p.domain, p.target_bundle_id.as_deref())
20211}
20212
20213fn handle_perm_request(req: &JsonRpcMessage) -> Result<Value, String> {
20214    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20215    car_ffi_common::permissions::request(&p.domain, p.target_bundle_id.as_deref())
20216}
20217
20218fn handle_perm_explain(req: &JsonRpcMessage) -> Result<Value, String> {
20219    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20220    car_ffi_common::permissions::explain(&p.domain, p.target_bundle_id.as_deref())
20221}
20222
20223fn handle_calendar_events(req: &JsonRpcMessage) -> Result<Value, String> {
20224    #[derive(serde::Deserialize)]
20225    struct P {
20226        start: String,
20227        end: String,
20228        #[serde(default)]
20229        calendar_ids: Vec<String>,
20230    }
20231    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20232    let start = chrono::DateTime::parse_from_rfc3339(&p.start)
20233        .map_err(|e| format!("parse start: {}", e))?
20234        .with_timezone(&chrono::Utc);
20235    let end = chrono::DateTime::parse_from_rfc3339(&p.end)
20236        .map_err(|e| format!("parse end: {}", e))?
20237        .with_timezone(&chrono::Utc);
20238    car_ffi_common::integrations::calendar_events(start, end, &p.calendar_ids)
20239}
20240
20241fn handle_calendar_create_event(req: &JsonRpcMessage) -> Result<Value, String> {
20242    let raw = req.params.to_string();
20243    car_ffi_common::integrations::calendar_create_event(&raw)
20244}
20245
20246fn handle_calendar_update_event(req: &JsonRpcMessage) -> Result<Value, String> {
20247    let raw = req.params.to_string();
20248    car_ffi_common::integrations::calendar_update_event(&raw)
20249}
20250
20251fn handle_calendar_delete_event(req: &JsonRpcMessage) -> Result<Value, String> {
20252    #[derive(serde::Deserialize)]
20253    struct P {
20254        event_id: String,
20255    }
20256    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20257    car_ffi_common::integrations::calendar_delete_event(&p.event_id)
20258}
20259
20260fn handle_contacts_find(req: &JsonRpcMessage) -> Result<Value, String> {
20261    #[derive(serde::Deserialize)]
20262    struct P {
20263        query: String,
20264        #[serde(default = "default_limit")]
20265        limit: usize,
20266        #[serde(default)]
20267        container_ids: Vec<String>,
20268    }
20269    fn default_limit() -> usize {
20270        50
20271    }
20272    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20273    car_ffi_common::integrations::contacts_list(&p.query, &p.container_ids, p.limit)
20274}
20275
20276fn handle_mail_inbox(req: &JsonRpcMessage) -> Result<Value, String> {
20277    #[derive(serde::Deserialize, Default)]
20278    struct P {
20279        #[serde(default)]
20280        account_ids: Vec<String>,
20281    }
20282    let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20283    car_ffi_common::integrations::mail_inbox(&p.account_ids)
20284}
20285
20286fn handle_mail_mailboxes(req: &JsonRpcMessage) -> Result<Value, String> {
20287    #[derive(serde::Deserialize, Default)]
20288    struct P {
20289        #[serde(default)]
20290        account_ids: Vec<String>,
20291    }
20292    let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20293    car_ffi_common::integrations::mail_mailboxes(&p.account_ids)
20294}
20295
20296/// The params ARE the `MessageQuery`, so they go through verbatim — every
20297/// field defaults, and `{}` reads the INBOX exactly as `mail.inbox` does.
20298/// Omitted params deserialize as `Value::Null`, which is not a `MessageQuery`;
20299/// treat that as the all-defaults query rather than a parse error.
20300fn handle_mail_messages(req: &JsonRpcMessage) -> Result<Value, String> {
20301    let raw = if req.params.is_null() {
20302        "{}".to_string()
20303    } else {
20304        req.params.to_string()
20305    };
20306    car_ffi_common::integrations::mail_messages(&raw)
20307}
20308
20309fn handle_mail_message_body(req: &JsonRpcMessage) -> Result<Value, String> {
20310    #[derive(serde::Deserialize)]
20311    struct P {
20312        message_id: String,
20313    }
20314    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20315    car_ffi_common::integrations::mail_message_body(&p.message_id)
20316}
20317
20318fn handle_mail_send(req: &JsonRpcMessage) -> Result<Value, String> {
20319    let raw = req.params.to_string();
20320    car_ffi_common::integrations::mail_send(&raw)
20321}
20322
20323fn handle_messages_chats(req: &JsonRpcMessage) -> Result<Value, String> {
20324    #[derive(serde::Deserialize)]
20325    struct P {
20326        #[serde(default = "default_limit")]
20327        limit: usize,
20328    }
20329    fn default_limit() -> usize {
20330        50
20331    }
20332    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20333    car_ffi_common::integrations::messages_chats(p.limit)
20334}
20335
20336/// The params object is the all-optional MessagesReadQuery. As with
20337/// `mail.messages`, omitted JSON-RPC params mean the default `{}` query.
20338fn handle_messages_read(req: &JsonRpcMessage) -> Result<Value, String> {
20339    let raw = if req.params.is_null() {
20340        "{}".to_string()
20341    } else {
20342        req.params.to_string()
20343    };
20344    car_ffi_common::integrations::messages_read(&raw)
20345}
20346
20347fn handle_messages_send(req: &JsonRpcMessage) -> Result<Value, String> {
20348    let raw = req.params.to_string();
20349    car_ffi_common::integrations::messages_send(&raw)
20350}
20351
20352fn handle_notes_find(req: &JsonRpcMessage) -> Result<Value, String> {
20353    #[derive(serde::Deserialize)]
20354    struct P {
20355        query: String,
20356        #[serde(default = "default_limit")]
20357        limit: usize,
20358    }
20359    fn default_limit() -> usize {
20360        50
20361    }
20362    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20363    car_ffi_common::integrations::notes_find(&p.query, p.limit)
20364}
20365
20366fn handle_reminders_items(req: &JsonRpcMessage) -> Result<Value, String> {
20367    #[derive(serde::Deserialize)]
20368    struct P {
20369        #[serde(default = "default_limit")]
20370        limit: usize,
20371    }
20372    fn default_limit() -> usize {
20373        50
20374    }
20375    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20376    car_ffi_common::integrations::reminders_items(p.limit)
20377}
20378
20379fn handle_bookmarks_list(req: &JsonRpcMessage) -> Result<Value, String> {
20380    #[derive(serde::Deserialize)]
20381    struct P {
20382        #[serde(default = "default_limit")]
20383        limit: usize,
20384    }
20385    fn default_limit() -> usize {
20386        100
20387    }
20388    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 100 });
20389    car_ffi_common::integrations::bookmarks_list(p.limit)
20390}
20391
20392fn handle_health_sleep(req: &JsonRpcMessage) -> Result<Value, String> {
20393    #[derive(serde::Deserialize)]
20394    struct P {
20395        start: String,
20396        end: String,
20397    }
20398    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20399    let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20400        .map_err(|e| format!("parse start: {}", e))?
20401        .with_timezone(&chrono::Utc);
20402    let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20403        .map_err(|e| format!("parse end: {}", e))?
20404        .with_timezone(&chrono::Utc);
20405    car_ffi_common::health::sleep_windows(s, e)
20406}
20407
20408fn handle_health_workouts(req: &JsonRpcMessage) -> Result<Value, String> {
20409    #[derive(serde::Deserialize)]
20410    struct P {
20411        start: String,
20412        end: String,
20413    }
20414    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20415    let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20416        .map_err(|e| format!("parse start: {}", e))?
20417        .with_timezone(&chrono::Utc);
20418    let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20419        .map_err(|e| format!("parse end: {}", e))?
20420        .with_timezone(&chrono::Utc);
20421    car_ffi_common::health::workouts(s, e)
20422}
20423
20424fn handle_health_activity(req: &JsonRpcMessage) -> Result<Value, String> {
20425    #[derive(serde::Deserialize)]
20426    struct P {
20427        start: String,
20428        end: String,
20429    }
20430    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20431    let s = chrono::NaiveDate::parse_from_str(&p.start, "%Y-%m-%d")
20432        .map_err(|e| format!("parse start: {}", e))?;
20433    let e = chrono::NaiveDate::parse_from_str(&p.end, "%Y-%m-%d")
20434        .map_err(|e| format!("parse end: {}", e))?;
20435    car_ffi_common::health::activity(s, e)
20436}
20437
20438async fn handle_browser_close(session: &crate::session::ClientSession) -> Result<Value, String> {
20439    let closed = session.browser.close().await?;
20440    Ok(serde_json::json!({"closed": closed}))
20441}
20442
20443async fn handle_browser_run(
20444    req: &JsonRpcMessage,
20445    session: &crate::session::ClientSession,
20446) -> Result<Value, String> {
20447    #[derive(serde::Deserialize)]
20448    struct BrowserRunParams {
20449        /// Inline JSON string (CLI-compatible), OR the structured object.
20450        script: Value,
20451        #[serde(default)]
20452        width: Option<u32>,
20453        #[serde(default)]
20454        height: Option<u32>,
20455        /// When true, launches a visible Chromium window for interactive
20456        /// flows (first-time auth, 2FA, supervised runs). Only honored on
20457        /// the call that first launches the browser session — subsequent
20458        /// calls reuse the existing browser regardless.
20459        #[serde(default)]
20460        headed: Option<bool>,
20461        /// Extra Chromium command-line flags appended verbatim at
20462        /// launch (#112). Honoured only on the launch call.
20463        #[serde(default)]
20464        extra_args: Option<Vec<String>>,
20465    }
20466    let params: BrowserRunParams =
20467        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20468
20469    // Validate before launching Chromium. The wire contract accepts an inline
20470    // JSON string or a structured object, not arbitrary JSON scalars/arrays.
20471    // Launching first made malformed calls wait on browser startup before the
20472    // script parser could reject them.
20473    let script_json = browser_script_json(params.script)?;
20474
20475    let browser_session = session
20476        .browser
20477        .get_or_launch(car_ffi_common::browser::BrowserLaunchOptions {
20478            width: params.width.unwrap_or(1280),
20479            height: params.height.unwrap_or(720),
20480            headless: !params.headed.unwrap_or(false),
20481            extra_args: params.extra_args.unwrap_or_default(),
20482            // `browser.run` is a per-CONNECTION browser and stays fully
20483            // independent of the drawer's and the agents': no persistent
20484            // profile, so nothing it does can collide with theirs, and
20485            // nothing they do can repoint it.
20486            profile_dir: None,
20487        })
20488        .await?;
20489
20490    let trace_json = browser_session.run(&script_json).await?;
20491    serde_json::from_str(&trace_json).map_err(|e| e.to_string())
20492}
20493
20494fn browser_script_json(script: Value) -> Result<String, String> {
20495    match script {
20496        Value::String(script) => Ok(script),
20497        script @ Value::Object(_) => Ok(script.to_string()),
20498        _ => Err("browser.run script must be a string or object".to_string()),
20499    }
20500}
20501
20502#[cfg(test)]
20503mod browser_run_script_tests {
20504    use super::browser_script_json;
20505    use serde_json::{json, Value};
20506
20507    #[test]
20508    fn accepts_only_the_documented_script_shapes_before_browser_launch() {
20509        assert_eq!(
20510            browser_script_json(Value::String(r#"{"operations":[]}"#.to_string()))
20511                .expect("inline JSON string"),
20512            r#"{"operations":[]}"#
20513        );
20514        assert_eq!(
20515            serde_json::from_str::<Value>(
20516                &browser_script_json(json!({"operations": []})).expect("structured object")
20517            )
20518            .expect("serialized object"),
20519            json!({"operations": []})
20520        );
20521
20522        for unsupported in [Value::Null, json!(1), json!(true), json!([])] {
20523            assert_eq!(
20524                browser_script_json(unsupported).expect_err("unsupported JSON shape"),
20525                "browser.run script must be a string or object"
20526            );
20527        }
20528    }
20529}
20530
20531// ---------------------------------------------------------------------------
20532// Voice streaming JSON-RPC methods
20533//
20534// Events are pushed back to the originating client as JSON-RPC notifications:
20535//   { "jsonrpc": "2.0", "method": "voice.event",
20536//     "params": { "session_id": "...", "event": {...} } }
20537//
20538// The session registry is process-wide (ServerState.voice_sessions); per-call
20539// WsVoiceEventSink instances bind each session to its originating WS so a
20540// client only ever sees events for sessions it started.
20541// ---------------------------------------------------------------------------
20542
20543#[derive(Deserialize)]
20544struct VoiceStartParams {
20545    session_id: String,
20546    audio_source: Value,
20547    #[serde(default)]
20548    options: Option<Value>,
20549}
20550
20551async fn handle_voice_transcribe_stream_start(
20552    req: &JsonRpcMessage,
20553    state: &Arc<ServerState>,
20554    session: &Arc<crate::session::ClientSession>,
20555) -> Result<Value, String> {
20556    let params: VoiceStartParams =
20557        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20558    let audio_source_json =
20559        serde_json::to_string(&params.audio_source).map_err(|e| e.to_string())?;
20560    let options_json = params
20561        .options
20562        .as_ref()
20563        .map(|v| serde_json::to_string(v).map_err(|e| e.to_string()))
20564        .transpose()?;
20565    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20566        channel: session.channel.clone(),
20567    });
20568    let json = car_ffi_common::voice::transcribe_stream_start(
20569        &params.session_id,
20570        &audio_source_json,
20571        options_json.as_deref(),
20572        state.voice_sessions.clone(),
20573        sink,
20574    )
20575    .await?;
20576    serde_json::from_str(&json).map_err(|e| e.to_string())
20577}
20578
20579#[derive(Deserialize)]
20580struct VoiceStopParams {
20581    session_id: String,
20582}
20583
20584async fn handle_voice_transcribe_stream_stop(
20585    req: &JsonRpcMessage,
20586    state: &Arc<ServerState>,
20587) -> Result<Value, String> {
20588    let params: VoiceStopParams =
20589        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20590    let json = car_ffi_common::voice::transcribe_stream_stop(
20591        &params.session_id,
20592        state.voice_sessions.clone(),
20593    )
20594    .await?;
20595    serde_json::from_str(&json).map_err(|e| e.to_string())
20596}
20597
20598#[derive(Deserialize)]
20599struct VoicePushParams {
20600    session_id: String,
20601    /// Base64-encoded 16-bit signed PCM frame. JSON-RPC is text, so binary
20602    /// audio frames have to be encoded; clients in WS-binary contexts that
20603    /// want to skip the round trip can call the FFI directly.
20604    pcm_b64: String,
20605}
20606
20607async fn handle_voice_transcribe_stream_push(
20608    req: &JsonRpcMessage,
20609    state: &Arc<ServerState>,
20610) -> Result<Value, String> {
20611    use base64::Engine;
20612    let params: VoicePushParams =
20613        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20614    let pcm = base64::engine::general_purpose::STANDARD
20615        .decode(&params.pcm_b64)
20616        .map_err(|e| format!("invalid pcm_b64: {}", e))?;
20617    let json = car_ffi_common::voice::transcribe_stream_push(
20618        &params.session_id,
20619        &pcm,
20620        state.voice_sessions.clone(),
20621    )
20622    .await?;
20623    serde_json::from_str(&json).map_err(|e| e.to_string())
20624}
20625
20626fn handle_voice_sessions_list(state: &Arc<ServerState>) -> Value {
20627    let json = car_ffi_common::voice::list_voice_sessions(state.voice_sessions.clone());
20628    serde_json::from_str(&json).unwrap_or(Value::Null)
20629}
20630
20631#[derive(Deserialize)]
20632struct VoiceTtsStreamStartParams {
20633    /// Caller-chosen opaque id for this stream. Used both as the
20634    /// session_id wrapped onto each `voice.event` notification AND as
20635    /// the key for `voice.tts_stream.cancel`.
20636    stream_id: String,
20637    /// Text to synthesize. Splitting into multiple synth calls (for
20638    /// long-form narration) is the caller's responsibility.
20639    text: String,
20640    /// Optional [`car_ffi_common::voice::TtsStreamOptions`] as a raw
20641    /// JSON value (provider, voice_id, binary_frames).
20642    #[serde(default)]
20643    options: Option<Value>,
20644}
20645
20646async fn handle_voice_tts_stream_start(
20647    req: &JsonRpcMessage,
20648    session: &Arc<crate::session::ClientSession>,
20649) -> Result<Value, String> {
20650    let params: VoiceTtsStreamStartParams =
20651        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20652    let opts_str = params
20653        .options
20654        .as_ref()
20655        .map(|v| v.to_string())
20656        .filter(|s| !s.is_empty());
20657    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20658        channel: session.channel.clone(),
20659    });
20660    let json = car_ffi_common::voice::tts_stream_start(
20661        &params.stream_id,
20662        &params.text,
20663        opts_str.as_deref(),
20664        sink,
20665    )
20666    .await?;
20667    serde_json::from_str(&json).map_err(|e| e.to_string())
20668}
20669
20670#[derive(Deserialize)]
20671struct VoiceTtsStreamCancelParams {
20672    stream_id: String,
20673}
20674
20675async fn handle_voice_tts_stream_cancel(req: &JsonRpcMessage) -> Result<Value, String> {
20676    let params: VoiceTtsStreamCancelParams =
20677        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20678    let json = car_ffi_common::voice::tts_stream_cancel(&params.stream_id).await?;
20679    serde_json::from_str(&json).map_err(|e| e.to_string())
20680}
20681
20682fn handle_voice_tts_stream_list() -> Value {
20683    let json = car_ffi_common::voice::list_tts_streams();
20684    serde_json::from_str(&json).unwrap_or(Value::Null)
20685}
20686
20687async fn handle_voice_dispatch_turn(
20688    req: &JsonRpcMessage,
20689    state: &Arc<ServerState>,
20690    session: &Arc<crate::session::ClientSession>,
20691) -> Result<Value, String> {
20692    let req_value = req.params.clone();
20693    let request: crate::voice_turn::DispatchVoiceTurnRequest =
20694        serde_json::from_value(req_value).map_err(|e| e.to_string())?;
20695    let engine = get_inference_engine(state).clone();
20696    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20697        channel: session.channel.clone(),
20698    });
20699    let resp = crate::voice_turn::dispatch(engine, request, sink).await?;
20700    serde_json::to_value(resp).map_err(|e| e.to_string())
20701}
20702
20703async fn handle_voice_cancel_turn() -> Result<Value, String> {
20704    crate::voice_turn::cancel().await;
20705    Ok(serde_json::json!({"cancelled": true}))
20706}
20707
20708async fn handle_voice_prewarm_turn(state: &Arc<ServerState>) -> Result<Value, String> {
20709    let engine = get_inference_engine(state).clone();
20710    crate::voice_turn::prewarm(engine).await;
20711    Ok(serde_json::json!({"prewarmed": true}))
20712}
20713
20714// ---------------------------------------------------------------------------
20715// Inference runner over WebSocket — closes Parslee-ai/car-releases#24
20716//
20717// Bidirectional protocol shape:
20718//   1. Client → server: `inference.register_runner` (no params). The
20719//      session that calls this becomes the host for delegated models.
20720//   2. Server → client: `inference.runner.invoke` notification with
20721//      {call_id, request} when CAR needs to dispatch a delegated turn.
20722//   3. Client → server: `inference.runner.event` with {call_id, event}
20723//      for each chunk; `inference.runner.complete` with {call_id, result}
20724//      on success; `inference.runner.fail` with {call_id, error} on
20725//      failure.
20726//
20727// The server-side data is process-wide because only one inference
20728// runner can be registered at a time (matches the FFI bindings'
20729// constraint). The per-call mailboxes live in dedicated DashMaps.
20730// ---------------------------------------------------------------------------
20731
20732/// Runners keyed by the `client_id` of the session that registered them.
20733///
20734/// Replaces a single process-global slot that the last registrant overwrote —
20735/// so two hosts sharing one daemon dispatched each other's delegated calls, and
20736/// the loser saw `no bridge context for <uuid>` (Parslee-ai/car-releases#77).
20737///
20738/// This is ROUTING only. It is deliberately NOT a concurrency control: bounding
20739/// how many generations run at once is `crate::admission`'s job, which sizes
20740/// permits from host RAM. The old singleton conflated the two, and as a resource
20741/// guard it was both redundant and worse than admission — a hard global 1
20742/// regardless of how much memory the machine actually had.
20743fn ws_runner_sessions() -> &'static dashmap::DashMap<String, Arc<crate::session::WsChannel>> {
20744    static MAP: std::sync::OnceLock<dashmap::DashMap<String, Arc<crate::session::WsChannel>>> =
20745        std::sync::OnceLock::new();
20746    MAP.get_or_init(dashmap::DashMap::new)
20747}
20748
20749/// The runner that serves calls whose originator registered none of its own.
20750///
20751/// Preserves the pre-#77 use case: a dedicated runner process serving calls that
20752/// originate elsewhere (a CLI invocation, a scheduled agent). Last registrant
20753/// wins here, as before — but now only for unclaimed traffic, not for calls a
20754/// host made itself.
20755fn ws_runner_fallback() -> &'static std::sync::RwLock<Option<Arc<crate::session::WsChannel>>> {
20756    static SLOT: std::sync::OnceLock<std::sync::RwLock<Option<Arc<crate::session::WsChannel>>>> =
20757        std::sync::OnceLock::new();
20758    SLOT.get_or_init(|| std::sync::RwLock::new(None))
20759}
20760
20761/// Resolve the channel for a delegated call: the caller's own runner when it has
20762/// one, else the fallback.
20763fn resolve_runner_channel(caller: Option<&str>) -> Option<Arc<crate::session::WsChannel>> {
20764    if let Some(id) = caller {
20765        if let Some(entry) = ws_runner_sessions().get(id) {
20766            return Some(entry.value().clone());
20767        }
20768    }
20769    ws_runner_fallback().read().ok().and_then(|g| g.clone())
20770}
20771
20772fn ws_runner_calls() -> &'static dashmap::DashMap<String, car_inference::EventEmitter> {
20773    static MAP: std::sync::OnceLock<dashmap::DashMap<String, car_inference::EventEmitter>> =
20774        std::sync::OnceLock::new();
20775    MAP.get_or_init(dashmap::DashMap::new)
20776}
20777
20778fn ws_runner_completions() -> &'static dashmap::DashMap<
20779    String,
20780    tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
20781> {
20782    static MAP: std::sync::OnceLock<
20783        dashmap::DashMap<
20784            String,
20785            tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
20786        >,
20787    > = std::sync::OnceLock::new();
20788    MAP.get_or_init(dashmap::DashMap::new)
20789}
20790
20791struct WsInferenceRunner;
20792
20793#[async_trait::async_trait]
20794impl car_inference::InferenceRunner for WsInferenceRunner {
20795    async fn run(
20796        &self,
20797        request: car_inference::tasks::generate::GenerateRequest,
20798        emitter: car_inference::EventEmitter,
20799    ) -> std::result::Result<car_inference::RunnerResult, car_inference::RunnerError> {
20800        // Route to the runner the ORIGINATING session registered; fall back to
20801        // the global one only when it registered none (car-releases#77).
20802        let channel = resolve_runner_channel(request.caller.as_deref()).ok_or_else(|| {
20803            car_inference::RunnerError::Declined(
20804                "no WebSocket inference runner registered — call inference.register_runner first"
20805                    .into(),
20806            )
20807        })?;
20808
20809        let call_id = uuid::Uuid::new_v4().to_string();
20810        let request_json = serde_json::to_value(&request)
20811            .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
20812        let (tx, rx) = tokio::sync::oneshot::channel();
20813        ws_runner_calls().insert(call_id.clone(), emitter);
20814        ws_runner_completions().insert(call_id.clone(), tx);
20815
20816        // Fire the invoke notification.
20817        use futures::SinkExt;
20818        let notification = serde_json::json!({
20819            "jsonrpc": "2.0",
20820            "method": "inference.runner.invoke",
20821            "params": {
20822                "call_id": call_id,
20823                "request": request_json,
20824            },
20825        });
20826        let text = serde_json::to_string(&notification)
20827            .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
20828        let _ = channel
20829            .write
20830            .lock()
20831            .await
20832            .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
20833            .await;
20834
20835        let result = rx.await.map_err(|_| {
20836            car_inference::RunnerError::Failed("runner completion channel dropped".into())
20837        })?;
20838        ws_runner_calls().remove(&call_id);
20839        result.map_err(car_inference::RunnerError::Failed)
20840    }
20841}
20842
20843async fn handle_inference_register_runner(
20844    session: &Arc<crate::session::ClientSession>,
20845) -> Result<Value, String> {
20846    ws_runner_sessions().insert(session.client_id.clone(), session.channel.clone());
20847    // Also the fallback for calls whose originator registered no runner. Last
20848    // registrant wins here, as before — but that now only affects unclaimed
20849    // traffic, not another host's own calls.
20850    let mut guard = ws_runner_fallback()
20851        .write()
20852        .map_err(|e| format!("ws runner slot poisoned: {e}"))?;
20853    let displaced = guard.is_some();
20854    *guard = Some(session.channel.clone());
20855    drop(guard);
20856    car_inference::set_inference_runner(Some(Arc::new(WsInferenceRunner)));
20857    // Report what happened. A bare `{"registered": true}` to a second registrant
20858    // was indistinguishable from being the only one, which is how two hosts
20859    // silently stole each other's calls (car-releases#77).
20860    Ok(serde_json::json!({
20861        "registered": true,
20862        "scope": "session",
20863        "runners_registered": ws_runner_sessions().len(),
20864        "became_fallback": true,
20865        "displaced_fallback": displaced,
20866    }))
20867}
20868
20869#[derive(serde::Deserialize)]
20870struct InferenceRunnerEventParams {
20871    call_id: String,
20872    event: Value,
20873}
20874
20875async fn handle_inference_runner_event(req: &JsonRpcMessage) -> Result<Value, String> {
20876    let params: InferenceRunnerEventParams =
20877        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20878    let stream_event = match parse_runner_event_value(&params.event) {
20879        Some(e) => e,
20880        None => return Err("unrecognised runner event shape".into()),
20881    };
20882    if let Some(entry) = ws_runner_calls().get(&params.call_id) {
20883        let emitter = entry.value().clone();
20884        tokio::spawn(async move { emitter.emit(stream_event).await });
20885    }
20886    Ok(serde_json::json!({"emitted": true}))
20887}
20888
20889#[derive(serde::Deserialize)]
20890struct InferenceRunnerCompleteParams {
20891    call_id: String,
20892    result: Value,
20893}
20894
20895async fn handle_inference_runner_complete(req: &JsonRpcMessage) -> Result<Value, String> {
20896    let params: InferenceRunnerCompleteParams =
20897        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20898    let result: std::result::Result<car_inference::RunnerResult, String> =
20899        serde_json::from_value(params.result)
20900            .map_err(|e| format!("invalid RunnerResult JSON: {e}"));
20901    if let Some((_, tx)) = ws_runner_completions().remove(&params.call_id) {
20902        let _ = tx.send(result);
20903    }
20904    Ok(serde_json::json!({"completed": true}))
20905}
20906
20907#[derive(serde::Deserialize)]
20908struct InferenceRunnerFailParams {
20909    call_id: String,
20910    error: String,
20911}
20912
20913async fn handle_inference_runner_fail(req: &JsonRpcMessage) -> Result<Value, String> {
20914    let params: InferenceRunnerFailParams =
20915        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20916    if let Some((_, tx)) = ws_runner_completions().remove(&params.call_id) {
20917        let _ = tx.send(Err(params.error));
20918    }
20919    Ok(serde_json::json!({"failed": true}))
20920}
20921
20922fn parse_runner_event_value(v: &Value) -> Option<car_inference::StreamEvent> {
20923    let ty = v.get("type").and_then(|t| t.as_str())?;
20924    match ty {
20925        "text" => Some(car_inference::StreamEvent::TextDelta(
20926            v.get("data")?.as_str()?.to_string(),
20927        )),
20928        "tool_start" => Some(car_inference::StreamEvent::ToolCallStart {
20929            name: v.get("name")?.as_str()?.to_string(),
20930            index: v.get("index")?.as_u64()? as usize,
20931            id: v.get("id").and_then(|i| i.as_str()).map(str::to_string),
20932        }),
20933        "tool_delta" => Some(car_inference::StreamEvent::ToolCallDelta {
20934            index: v.get("index")?.as_u64()? as usize,
20935            arguments_delta: v.get("data")?.as_str()?.to_string(),
20936        }),
20937        "usage" => Some(car_inference::StreamEvent::Usage {
20938            input_tokens: v.get("input_tokens")?.as_u64()?,
20939            output_tokens: v.get("output_tokens")?.as_u64()?,
20940            cache_read_input_tokens: v
20941                .get("cache_read_input_tokens")
20942                .and_then(|x| x.as_u64())
20943                .unwrap_or(0),
20944            cache_creation_input_tokens: v
20945                .get("cache_creation_input_tokens")
20946                .and_then(|x| x.as_u64())
20947                .unwrap_or(0),
20948        }),
20949        "provider_output_item" => Some(car_inference::StreamEvent::ProviderOutputItem(
20950            v.get("item")?.clone(),
20951        )),
20952        "error" => Some(car_inference::StreamEvent::Error(
20953            v.get("message")?.as_str()?.to_string(),
20954        )),
20955        "done" => Some(car_inference::StreamEvent::Done {
20956            text: v.get("text")?.as_str()?.to_string(),
20957            tool_calls: v
20958                .get("tool_calls")
20959                .and_then(|tc| serde_json::from_value(tc.clone()).ok())
20960                .unwrap_or_default(),
20961        }),
20962        _ => None,
20963    }
20964}
20965
20966#[derive(Deserialize)]
20967struct EnrollSpeakerParams {
20968    label: String,
20969    audio: Value,
20970}
20971
20972async fn handle_enroll_speaker(req: &JsonRpcMessage) -> Result<Value, String> {
20973    let params: EnrollSpeakerParams =
20974        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20975    let audio_json = serde_json::to_string(&params.audio).map_err(|e| e.to_string())?;
20976    let json = car_ffi_common::voice::enroll_speaker(&params.label, &audio_json).await?;
20977    serde_json::from_str(&json).map_err(|e| e.to_string())
20978}
20979
20980#[derive(Deserialize)]
20981struct RemoveEnrollmentParams {
20982    label: String,
20983}
20984
20985fn handle_remove_enrollment(req: &JsonRpcMessage) -> Result<Value, String> {
20986    let params: RemoveEnrollmentParams =
20987        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20988    let json = car_ffi_common::voice::remove_enrollment(&params.label)?;
20989    serde_json::from_str(&json).map_err(|e| e.to_string())
20990}
20991
20992#[derive(Deserialize)]
20993struct WorkflowRunParams {
20994    workflow: Value,
20995    /// Optional state map seeded into workflow state before the run starts —
20996    /// the inter-workflow chaining hook (hand a prior run's `final_state` to
20997    /// the next workflow). Omitted = prior behavior.
20998    #[serde(default)]
20999    initial_state: Option<std::collections::HashMap<String, Value>>,
21000}
21001
21002async fn handle_workflow_run(
21003    req: &JsonRpcMessage,
21004    session: &Arc<crate::session::ClientSession>,
21005) -> Result<Value, String> {
21006    let params: WorkflowRunParams =
21007        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21008    let workflow_json = serde_json::to_string(&params.workflow).map_err(|e| e.to_string())?;
21009    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21010        channel: session.channel.clone(),
21011        host: session.host.clone(),
21012        client_id: session.client_id.clone(),
21013    });
21014    let json = car_ffi_common::workflow::run_workflow(&workflow_json, params.initial_state, runner)
21015        .await?;
21016    let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21017    // If the run parked at an approval gate, persist the checkpoint durably so
21018    // it survives a daemon restart and can be resumed by run_id.
21019    persist_if_paused(&result)?;
21020    Ok(result)
21021}
21022
21023#[derive(Deserialize)]
21024struct WorkflowChainParams {
21025    /// Workflow definitions run sequentially: each next workflow's initial
21026    /// state is the previous result's `final_state` merged over
21027    /// `initial_state`.
21028    workflows: Vec<Value>,
21029    /// Optional base state seeded into every workflow in the chain (the first
21030    /// workflow gets exactly this; later ones get it merged under the
21031    /// predecessor's `final_state`).
21032    #[serde(default)]
21033    initial_state: Option<std::collections::HashMap<String, Value>>,
21034}
21035
21036/// `workflow.chain` — run workflows sequentially, threading each result's
21037/// `final_state` into the next workflow's initial state. Every workflow is
21038/// statically pre-validated before any executes (structural garbage rejects
21039/// the chain up front, as an error). Stops at the first non-`completed`
21040/// result, returning the results so far plus the stopping status; a paused
21041/// intermediate persists its checkpoint durably exactly like `workflow.run`
21042/// (resume it via `workflow.resume` by `run_id`, then re-chain the remainder
21043/// if desired) and is named by `paused_at_index`. A mid-chain *runtime*
21044/// engine error (e.g. cycle limit) does not discard the chain: the response
21045/// still carries `results` so far (delivery evidence included) plus
21046/// top-level `error` and `failed_at_index`.
21047async fn handle_workflow_chain(
21048    req: &JsonRpcMessage,
21049    session: &Arc<crate::session::ClientSession>,
21050) -> Result<Value, String> {
21051    let params: WorkflowChainParams =
21052        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21053    let workflows_json = serde_json::to_string(&params.workflows).map_err(|e| e.to_string())?;
21054    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21055        channel: session.channel.clone(),
21056        host: session.host.clone(),
21057        client_id: session.client_id.clone(),
21058    });
21059    let json =
21060        car_ffi_common::workflow::chain_workflows(&workflows_json, params.initial_state, runner)
21061            .await?;
21062    let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21063    // Only the stopping workflow can be paused (the chain halts there);
21064    // persist its checkpoint durably so workflow.resume can pick it up.
21065    if let Some(last) = result
21066        .get("results")
21067        .and_then(|r| r.as_array())
21068        .and_then(|r| r.last())
21069    {
21070        persist_if_paused(last)?;
21071    }
21072    Ok(result)
21073}
21074
21075/// Re-arm workflow runs orphaned by a crash between an approval `claim` and its
21076/// `complete`. Call **once at daemon startup**, before serving connections, so a
21077/// restart mid-approval doesn't bury paused runs. Best-effort: logs and returns
21078/// on any error rather than failing boot.
21079pub fn recover_workflow_checkpoints() {
21080    let dir = match workflow_runs_dir() {
21081        Ok(d) => d,
21082        Err(e) => {
21083            tracing::debug!(error = %e, "no workflow checkpoint dir; skipping recovery");
21084            return;
21085        }
21086    };
21087    match car_workflow::CheckpointStore::open(&dir).and_then(|s| s.recover_orphaned()) {
21088        Ok(0) => {}
21089        Ok(n) => tracing::info!(rearmed = n, "recovered orphaned workflow checkpoints"),
21090        Err(e) => tracing::warn!(error = %e, "workflow checkpoint recovery failed"),
21091    }
21092}
21093
21094/// Directory holding durable workflow checkpoints: `workflow-runs` under the
21095/// CAR state root — `CAR_HOME` when set, otherwise `~/.car/workflow-runs`.
21096fn workflow_runs_dir() -> Result<std::path::PathBuf, String> {
21097    let root = car_home::root()
21098        .ok_or_else(|| "cannot resolve home directory for workflow checkpoints".to_string())?;
21099    Ok(root.join("workflow-runs"))
21100}
21101
21102/// If `result` is a paused WorkflowResult, save its checkpoint to the store.
21103fn persist_if_paused(result: &Value) -> Result<(), String> {
21104    if result.get("status").and_then(|s| s.as_str()) != Some("paused") {
21105        return Ok(());
21106    }
21107    let Some(paused_value) = result.get("paused") else {
21108        return Err("paused result missing checkpoint".to_string());
21109    };
21110    let paused: car_workflow::PausedWorkflow =
21111        serde_json::from_value(paused_value.clone()).map_err(|e| e.to_string())?;
21112    let store =
21113        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21114    store.save(&paused).map_err(|e| e.to_string())
21115}
21116
21117#[derive(Deserialize)]
21118struct WorkflowResumeParams {
21119    run_id: String,
21120    #[serde(default)]
21121    input: Value,
21122}
21123
21124/// `workflow.list_paused` — list resumable workflow runs with their pause
21125/// metadata (EPIC H / H1). The discovery half of durable resume: after a daemon
21126/// restart a client no longer holds the paused `run_id`s, so it enumerates them
21127/// here (each `{run_id, paused_stage_id, prompt, created_at}`) and resumes by
21128/// `run_id` via `workflow.resume`. In-flight and corrupt checkpoints are
21129/// omitted — only genuinely resumable runs are returned.
21130async fn handle_workflow_list_paused() -> Result<Value, String> {
21131    let store =
21132        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21133    let summaries = store.list_summaries().map_err(|e| e.to_string())?;
21134    serde_json::to_value(&summaries).map_err(|e| format!("serialize list_paused: {e}"))
21135}
21136
21137async fn handle_workflow_resume(
21138    req: &JsonRpcMessage,
21139    session: &Arc<crate::session::ClientSession>,
21140) -> Result<Value, String> {
21141    let params: WorkflowResumeParams =
21142        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21143
21144    let store =
21145        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21146    // Atomic claim: a duplicate/racing resume of the same run gets nothing,
21147    // so a side-effecting downstream stage never runs twice.
21148    let paused = store
21149        .claim(&params.run_id)
21150        .map_err(|e| e.to_string())?
21151        .ok_or_else(|| {
21152            format!(
21153                "no paused workflow run '{}' (already resumed or unknown)",
21154                params.run_id
21155            )
21156        })?;
21157    let paused_json = serde_json::to_string(&paused).map_err(|e| e.to_string())?;
21158    let input_json = if params.input.is_null() {
21159        "{}".to_string()
21160    } else {
21161        serde_json::to_string(&params.input).map_err(|e| e.to_string())?
21162    };
21163
21164    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21165        channel: session.channel.clone(),
21166        host: session.host.clone(),
21167        client_id: session.client_id.clone(),
21168    });
21169
21170    let json = car_ffi_common::workflow::resume_workflow(&paused_json, &input_json, runner).await;
21171    let result = match json {
21172        Ok(j) => serde_json::from_str::<Value>(&j).map_err(|e| e.to_string())?,
21173        Err(e) => {
21174            // Resume failed (e.g. invalid input) — release the in-flight marker
21175            // so the run can be resumed again with corrected input.
21176            let _ = store.save(&paused);
21177            let _ = store.complete(&params.run_id);
21178            return Err(e);
21179        }
21180    };
21181    // Re-paused at another gate → persist the fresh checkpoint; either way drop
21182    // the in-flight marker from the claim.
21183    persist_if_paused(&result)?;
21184    store.complete(&params.run_id).map_err(|e| e.to_string())?;
21185    Ok(result)
21186}
21187
21188#[derive(Deserialize)]
21189struct BuilderBuildParams {
21190    goal: String,
21191    #[serde(default)]
21192    existing: Value,
21193    #[serde(default = "default_builder_attempts")]
21194    max_attempts: u32,
21195}
21196
21197fn default_builder_attempts() -> u32 {
21198    3
21199}
21200
21201/// `builder.build` — natural language → validated workflow. Runs on the daemon so
21202/// the catalog is authoritative: tools come from this session's registered tool
21203/// schemas and models from the inference registry, making the builder's
21204/// tool-existence cross-check meaningful.
21205async fn handle_builder_build(
21206    req: &JsonRpcMessage,
21207    state: &Arc<ServerState>,
21208    session: &Arc<crate::session::ClientSession>,
21209) -> Result<Value, String> {
21210    let params: BuilderBuildParams =
21211        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21212    if params.goal.trim().is_empty() {
21213        return Err("missing 'goal'".to_string());
21214    }
21215
21216    let engine = get_inference_engine(state).clone();
21217
21218    let tools: Vec<car_builder::ToolInfo> = session
21219        .runtime
21220        .registry
21221        .schemas()
21222        .await
21223        .into_iter()
21224        .map(|s| car_builder::ToolInfo {
21225            name: s.name,
21226            description: s.description,
21227        })
21228        .collect();
21229    let models: Vec<String> = engine
21230        .list_models_unified()
21231        .into_iter()
21232        .map(|m| m.id)
21233        .collect();
21234    let catalog = car_builder::ToolCatalog {
21235        tools,
21236        models,
21237        agents: Vec::new(),
21238    };
21239
21240    let existing = if params.existing.is_null() {
21241        None
21242    } else {
21243        serde_json::from_value::<car_workflow::Workflow>(params.existing.clone()).ok()
21244    };
21245
21246    let build_req = car_builder::BuildRequest {
21247        goal: params.goal,
21248        catalog,
21249        existing,
21250        feedback: None,
21251        max_attempts: params.max_attempts,
21252    };
21253
21254    let result = car_builder::build_workflow(
21255        |prompt: String| {
21256            let engine = engine.clone();
21257            async move {
21258                let greq = car_inference::GenerateRequest {
21259                    prompt,
21260                    model: None,
21261                    expected_row_digest: None,
21262                    expected_catalog_revision: None,
21263                    params: car_inference::GenerateParams {
21264                        temperature: 0.2,
21265                        max_tokens: 4096,
21266                        ..Default::default()
21267                    },
21268                    context: None,
21269                    context_stable_prefix: None,
21270                    tools: None,
21271                    images: None,
21272                    messages: None,
21273                    cache_control: false,
21274                    response_format: None,
21275                    intent: None,
21276                    client_ref: None,
21277                    caller: None,
21278                };
21279                engine
21280                    .generate_tracked(greq)
21281                    .await
21282                    .map(|r| r.text)
21283                    .map_err(|e| e.to_string())
21284            }
21285        },
21286        &build_req,
21287    )
21288    .await;
21289
21290    Ok(serde_json::json!({
21291        "valid": result.valid,
21292        "workflow": result.workflow,
21293        "issues": result.issues,
21294        "warnings": result.warnings,
21295        "attempts": result.attempts,
21296    }))
21297}
21298
21299#[derive(Deserialize)]
21300struct WorkflowVerifyParams {
21301    workflow: Value,
21302}
21303
21304fn handle_workflow_verify(req: &JsonRpcMessage) -> Result<Value, String> {
21305    let params: WorkflowVerifyParams =
21306        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21307    let workflow_json = serde_json::to_string(&params.workflow).map_err(|e| e.to_string())?;
21308    let json = car_ffi_common::workflow::verify_workflow(&workflow_json)?;
21309    serde_json::from_str(&json).map_err(|e| e.to_string())
21310}
21311
21312#[derive(Deserialize)]
21313struct WorkflowBuildAutomationParams {
21314    spec: Value,
21315}
21316
21317/// Lower an external-item `AutomationSpec` (poll → dedup → per-item agent →
21318/// deliver) into a runnable workflow definition. Stateless; the caller hands the
21319/// returned workflow back to `workflow.run` (typically on a schedule).
21320fn handle_workflow_build_automation(req: &JsonRpcMessage) -> Result<Value, String> {
21321    let params: WorkflowBuildAutomationParams =
21322        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21323    let spec_json = serde_json::to_string(&params.spec).map_err(|e| e.to_string())?;
21324    let json = car_ffi_common::workflow::build_automation_workflow(&spec_json)?;
21325    serde_json::from_str(&json).map_err(|e| e.to_string())
21326}
21327
21328// ---------------------------------------------------------------------------
21329// Meeting JSON-RPC methods
21330// ---------------------------------------------------------------------------
21331
21332async fn handle_meeting_start(
21333    req: &JsonRpcMessage,
21334    state: &Arc<ServerState>,
21335    session: &Arc<crate::session::ClientSession>,
21336) -> Result<Value, String> {
21337    // We need the meeting id BEFORE handing the upstream sink to
21338    // start_meeting so the WsMemgineIngestSink stamps transcripts with
21339    // the correct `meeting/<id>/<source>` speaker. Parse the request
21340    // here, mint an id if none was provided, and pass the same id
21341    // through to start_meeting via the request JSON.
21342    let mut req_value = req.params.clone();
21343    let meeting_id = req_value
21344        .get("id")
21345        .and_then(|v| v.as_str())
21346        .map(str::to_string)
21347        .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
21348    if let Some(map) = req_value.as_object_mut() {
21349        map.insert("id".into(), Value::String(meeting_id.clone()));
21350    }
21351    let request_json = serde_json::to_string(&req_value).map_err(|e| e.to_string())?;
21352
21353    let ws_upstream: Arc<dyn car_voice::VoiceEventSink> =
21354        Arc::new(crate::session::WsVoiceEventSink {
21355            channel: session.channel.clone(),
21356        });
21357
21358    // Wrap the WS upstream with a memgine-ingest fanout that uses the
21359    // tokio::sync::Mutex-wrapped session memgine. We pass `None` for
21360    // the FFI-common `start_meeting` memgine arg to avoid the
21361    // sync-mutex contract there — ingest happens here instead.
21362    let upstream: Arc<dyn car_voice::VoiceEventSink> =
21363        Arc::new(crate::session::WsMemgineIngestSink {
21364            meeting_id,
21365            engine: session.memgine.clone(),
21366            upstream: ws_upstream,
21367        });
21368
21369    let cwd = std::env::current_dir().ok();
21370    let json = crate::meeting::start_meeting(
21371        &request_json,
21372        state.meetings.clone(),
21373        state.voice_sessions.clone(),
21374        upstream,
21375        None,
21376        cwd,
21377    )
21378    .await?;
21379    serde_json::from_str(&json).map_err(|e| e.to_string())
21380}
21381
21382#[derive(Deserialize)]
21383struct MeetingStopParams {
21384    meeting_id: String,
21385    #[serde(default = "default_summarize")]
21386    summarize: bool,
21387}
21388
21389fn default_summarize() -> bool {
21390    true
21391}
21392
21393async fn handle_meeting_stop(
21394    req: &JsonRpcMessage,
21395    state: &Arc<ServerState>,
21396    _session: &Arc<crate::session::ClientSession>,
21397) -> Result<Value, String> {
21398    let params: MeetingStopParams =
21399        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21400    let inference = if params.summarize {
21401        Some(state.inference.get().cloned()).flatten()
21402    } else {
21403        None
21404    };
21405    let json = crate::meeting::stop_meeting(
21406        &params.meeting_id,
21407        params.summarize,
21408        state.meetings.clone(),
21409        state.voice_sessions.clone(),
21410        inference,
21411    )
21412    .await?;
21413    serde_json::from_str(&json).map_err(|e| e.to_string())
21414}
21415
21416#[derive(Deserialize, Default)]
21417struct MeetingListParams {
21418    #[serde(default)]
21419    root: Option<std::path::PathBuf>,
21420}
21421
21422fn handle_meeting_list(req: &JsonRpcMessage) -> Result<Value, String> {
21423    let params: MeetingListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21424    let cwd = std::env::current_dir().ok();
21425    let json = crate::meeting::list_meetings(params.root, cwd)?;
21426    serde_json::from_str(&json).map_err(|e| e.to_string())
21427}
21428
21429#[derive(Deserialize)]
21430struct MeetingGetParams {
21431    meeting_id: String,
21432    #[serde(default)]
21433    root: Option<std::path::PathBuf>,
21434}
21435
21436fn handle_meeting_get(req: &JsonRpcMessage) -> Result<Value, String> {
21437    let params: MeetingGetParams =
21438        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21439    let cwd = std::env::current_dir().ok();
21440    let json = crate::meeting::get_meeting(&params.meeting_id, params.root, cwd)?;
21441    serde_json::from_str(&json).map_err(|e| e.to_string())
21442}
21443
21444// ---------------------------------------------------------------------------
21445// Agent registry — file-based cross-process discovery (#111)
21446// ---------------------------------------------------------------------------
21447
21448#[derive(Deserialize, Default)]
21449struct RegistryRegisterParams {
21450    /// Caller serializes their AgentEntry as a JSON value; we
21451    /// re-serialize it so the ffi-common helper can validate the
21452    /// shape with the same parser used by the bindings.
21453    entry: Value,
21454    #[serde(default)]
21455    registry_path: Option<std::path::PathBuf>,
21456}
21457
21458fn handle_registry_register(req: &JsonRpcMessage) -> Result<Value, String> {
21459    let params: RegistryRegisterParams =
21460        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21461    let entry_json = serde_json::to_string(&params.entry).map_err(|e| e.to_string())?;
21462    car_ffi_common::registry::register_agent(&entry_json, params.registry_path)?;
21463    Ok(Value::Null)
21464}
21465
21466#[derive(Deserialize, Default)]
21467struct RegistryNameParams {
21468    name: String,
21469    #[serde(default)]
21470    registry_path: Option<std::path::PathBuf>,
21471}
21472
21473fn handle_registry_heartbeat(req: &JsonRpcMessage) -> Result<Value, String> {
21474    let params: RegistryNameParams =
21475        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21476    let json = car_ffi_common::registry::agent_heartbeat(&params.name, params.registry_path)?;
21477    serde_json::from_str(&json).map_err(|e| e.to_string())
21478}
21479
21480fn handle_registry_unregister(req: &JsonRpcMessage) -> Result<Value, String> {
21481    let params: RegistryNameParams =
21482        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21483    car_ffi_common::registry::unregister_agent(&params.name, params.registry_path)?;
21484    Ok(Value::Null)
21485}
21486
21487#[derive(Deserialize, Default)]
21488struct RegistryListParams {
21489    #[serde(default)]
21490    registry_path: Option<std::path::PathBuf>,
21491}
21492
21493fn handle_registry_list(req: &JsonRpcMessage) -> Result<Value, String> {
21494    let params: RegistryListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21495    let json = car_ffi_common::registry::list_agents(params.registry_path)?;
21496    serde_json::from_str(&json).map_err(|e| e.to_string())
21497}
21498
21499#[derive(Deserialize, Default)]
21500struct RegistryReapParams {
21501    /// Heartbeats older than this many seconds are reaped. Default
21502    /// 60 — two missed 20s heartbeats trigger removal.
21503    #[serde(default = "default_reap_age")]
21504    max_age_secs: u64,
21505    #[serde(default)]
21506    registry_path: Option<std::path::PathBuf>,
21507}
21508
21509fn default_reap_age() -> u64 {
21510    60
21511}
21512
21513fn handle_registry_reap(req: &JsonRpcMessage) -> Result<Value, String> {
21514    let params: RegistryReapParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21515    let json =
21516        car_ffi_common::registry::reap_stale_agents(params.max_age_secs, params.registry_path)?;
21517    serde_json::from_str(&json).map_err(|e| e.to_string())
21518}
21519
21520// ---------------------------------------------------------------------------
21521// car-a2a server lifecycle (mirrors NAPI startA2AServer / stopA2AServer /
21522// a2AServerStatus and PyO3 start_a2a_server / stop_a2a_server /
21523// a2a_server_status — closes the binding gap noted in #126).
21524// ---------------------------------------------------------------------------
21525
21526async fn handle_a2a_start(
21527    req: &JsonRpcMessage,
21528    state: &Arc<ServerState>,
21529    session: &crate::session::ClientSession,
21530) -> Result<Value, String> {
21531    let params_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21532    // Always hand the session's runtime through. start_a2a uses it
21533    // only when `share_session_runtime: true` is set in params;
21534    // otherwise it falls back to the legacy fresh-Runtime + agent_basics
21535    // path. Passing it unconditionally keeps the FFI layer ignorant of
21536    // the flag's plumbing.
21537    //
21538    // Also hand through a chat responder bound to THIS session so a
21539    // conversational (text-only) `message/send` to the A2A listener routes back
21540    // to this host agent's `agent.chat` handler (car-releases#65). start_a2a
21541    // wires it only when `share_session_runtime: true` — without the shared
21542    // runtime there's no host loop to route to.
21543    let responder: Arc<dyn car_a2a::ChatResponder> = Arc::new(WsChatResponder {
21544        state: Arc::downgrade(state),
21545        host_client_id: session.client_id.clone(),
21546    });
21547    let json =
21548        crate::a2a::start_a2a(&params_json, Some(session.runtime.clone()), Some(responder)).await?;
21549    serde_json::from_str(&json).map_err(|e| e.to_string())
21550}
21551
21552fn handle_a2a_stop() -> Result<Value, String> {
21553    let json = crate::a2a::stop_a2a()?;
21554    serde_json::from_str(&json).map_err(|e| e.to_string())
21555}
21556
21557fn handle_a2a_status() -> Result<Value, String> {
21558    let json = crate::a2a::a2a_status()?;
21559    serde_json::from_str(&json).map_err(|e| e.to_string())
21560}
21561
21562// a2a.peers.* — registry of remote A2A peers CAR can discover/call. Reachable
21563// from the language bindings via the generic `a2a_dispatch` proxy.
21564
21565#[derive(serde::Deserialize)]
21566struct A2aPeerAddParams {
21567    url: String,
21568    #[serde(default)]
21569    label: Option<String>,
21570    /// Opt-in to register a non-loopback peer (see the gate below).
21571    #[serde(default)]
21572    allow_untrusted: bool,
21573}
21574
21575fn handle_a2a_peers_add(req: &JsonRpcMessage) -> Result<Value, String> {
21576    let params: A2aPeerAddParams =
21577        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
21578    // `discovery.resolve` will issue an outbound GET to this URL's agent card,
21579    // so registering a non-loopback peer is a deliberate trust decision — gate
21580    // it the same way `a2a.send` gates its endpoint (SSRF guard: otherwise any
21581    // registered URL, incl. internal/metadata endpoints, becomes a daemon GET).
21582    if !params.allow_untrusted && !is_loopback_http_endpoint(&params.url) {
21583        return Err(
21584            "a2a.peers.add endpoint must be loopback unless allowUntrusted is true".to_string(),
21585        );
21586    }
21587    let reg = car_a2a::peers::PeerRegistry::user_default()?;
21588    let entry = reg.add(&params.url, params.label)?;
21589    serde_json::to_value(entry).map_err(|e| e.to_string())
21590}
21591
21592fn handle_a2a_peers_list() -> Result<Value, String> {
21593    let reg = car_a2a::peers::PeerRegistry::user_default()?;
21594    Ok(serde_json::json!({ "peers": reg.list() }))
21595}
21596
21597#[derive(serde::Deserialize)]
21598struct A2aPeerRemoveParams {
21599    slug: String,
21600}
21601
21602fn handle_a2a_peers_remove(req: &JsonRpcMessage) -> Result<Value, String> {
21603    let params: A2aPeerRemoveParams =
21604        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
21605    let reg = car_a2a::peers::PeerRegistry::user_default()?;
21606    Ok(serde_json::json!({ "removed": reg.remove(&params.slug)? }))
21607}
21608
21609#[derive(Deserialize)]
21610#[serde(rename_all = "camelCase")]
21611struct A2aSendParams {
21612    endpoint: String,
21613    message: car_a2a::Message,
21614    #[serde(default)]
21615    blocking: bool,
21616    #[serde(default = "default_true")]
21617    ingest_a2ui: bool,
21618    #[serde(default)]
21619    route_auth: Option<A2aRouteAuth>,
21620    #[serde(default)]
21621    allow_untrusted_endpoint: bool,
21622}
21623
21624fn default_true() -> bool {
21625    true
21626}
21627
21628/// In-core A2A dispatcher entry point. Forwards the JSON-RPC method
21629/// + params to the lazy-initialized [`car_a2a::A2aDispatcher`] held
21630/// on `ServerState`. Closes Parslee-ai/car-releases#28.
21631///
21632/// Streaming methods (`message/stream`, `tasks/resubscribe` and their
21633/// PascalCase aliases) return `MethodNotFound` from the dispatcher's
21634/// transport-neutral surface — the standalone `start_a2a_listener`
21635/// HTTP path serves SSE for those, but the in-core WS surface is
21636/// JSON-RPC only. Same trade as the dispatcher itself.
21637async fn handle_a2a_dispatch(
21638    method: &str,
21639    req: &JsonRpcMessage,
21640    state: &Arc<ServerState>,
21641) -> Result<Value, String> {
21642    let dispatcher = state.a2a_dispatcher().await;
21643    dispatcher
21644        .dispatch(method, req.params.clone())
21645        .await
21646        .map_err(|e| e.to_string())
21647}
21648
21649async fn handle_a2a_send(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
21650    let params: A2aSendParams =
21651        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21652    let endpoint = trusted_route_endpoint(
21653        Some(params.endpoint.clone()),
21654        params.allow_untrusted_endpoint,
21655    )
21656    .ok_or_else(|| {
21657        "`a2a.send` endpoint must be loopback unless allowUntrustedEndpoint is true".to_string()
21658    })?;
21659    let client = match params.route_auth.clone() {
21660        Some(auth) => {
21661            car_a2a::A2aClient::new(endpoint.clone()).with_auth(client_auth_from_route_auth(auth))
21662        }
21663        None => car_a2a::A2aClient::new(endpoint.clone()),
21664    };
21665    let result = client
21666        .send_message(params.message, params.blocking)
21667        .await
21668        .map_err(|e| e.to_string())?;
21669    let result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
21670    let mut applied = Vec::new();
21671    if params.ingest_a2ui {
21672        state
21673            .a2ui
21674            .validate_payload(&result_value)
21675            .map_err(|e| e.to_string())?;
21676        let routed_endpoint = Some(endpoint.clone());
21677        for envelope in car_a2ui::envelopes_from_value(&result_value).map_err(|e| e.to_string())? {
21678            let owner = car_a2ui::owner_from_value(&result_value).map(|owner| {
21679                if owner.endpoint.is_none() {
21680                    owner.with_endpoint(routed_endpoint.clone())
21681                } else {
21682                    owner
21683                }
21684            });
21685            applied.push(
21686                apply_a2ui_envelope(state, envelope, owner, params.route_auth.clone()).await?,
21687            );
21688        }
21689    }
21690    Ok(serde_json::json!({
21691        "result": result,
21692        "a2ui": {
21693            "applied": applied,
21694        }
21695    }))
21696}
21697
21698// ---------------------------------------------------------------------------
21699// macOS automation — AppleScript + Shortcuts (car-automation), Vision OCR
21700// (car-vision). Mirrors NAPI runApplescript / listShortcuts / runShortcut /
21701// visionOcr and PyO3 run_applescript / list_shortcuts / run_shortcut /
21702// vision_ocr.
21703// ---------------------------------------------------------------------------
21704
21705async fn handle_run_applescript(req: &JsonRpcMessage) -> Result<Value, String> {
21706    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21707    let json = car_ffi_common::automation::run_applescript(&args_json).await?;
21708    serde_json::from_str(&json).map_err(|e| e.to_string())
21709}
21710
21711async fn handle_run_powershell(req: &JsonRpcMessage) -> Result<Value, String> {
21712    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21713    let json = car_ffi_common::automation::run_powershell(&args_json).await?;
21714    serde_json::from_str(&json).map_err(|e| e.to_string())
21715}
21716
21717async fn handle_list_shortcuts(req: &JsonRpcMessage) -> Result<Value, String> {
21718    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21719    let json = car_ffi_common::automation::list_shortcuts(&args_json).await?;
21720    serde_json::from_str(&json).map_err(|e| e.to_string())
21721}
21722
21723async fn handle_run_shortcut(req: &JsonRpcMessage) -> Result<Value, String> {
21724    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21725    let json = car_ffi_common::automation::run_shortcut(&args_json).await?;
21726    serde_json::from_str(&json).map_err(|e| e.to_string())
21727}
21728
21729async fn handle_local_notification(req: &JsonRpcMessage) -> Result<Value, String> {
21730    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21731    let json = car_ffi_common::notifications::local(&args_json).await?;
21732    serde_json::from_str(&json).map_err(|e| e.to_string())
21733}
21734
21735async fn handle_vision_ocr(req: &JsonRpcMessage) -> Result<Value, String> {
21736    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21737    let json = car_ffi_common::vision::ocr(&args_json).await?;
21738    serde_json::from_str(&json).map_err(|e| e.to_string())
21739}
21740
21741// ---------------------------------------------------------------------------
21742// Lifecycle-managed agents (car_registry::supervisor) — Parslee-ai/car-releases#27
21743// ---------------------------------------------------------------------------
21744
21745/// Serialize a `ManagedAgent` for the wire, WITHOUT its per-agent token.
21746///
21747/// `AgentSpec::token` is the credential `session.auth { agent_id, token }`
21748/// checks, and `ManagedAgent` flattens the spec — so serializing one verbatim
21749/// published every supervised agent's credential to any authenticated caller.
21750/// That made the bound-identity rules in this file decorative rather than wrong:
21751/// an agent could read another's token, authenticate AS it, and then every
21752/// identity check would pass because the bound identity genuinely was the
21753/// victim. A spoofing rule cannot hold while the impersonation credential is a
21754/// public read.
21755///
21756/// Redacted in the PROJECTION rather than with `skip_serializing` on the field:
21757/// `AgentSpec` is also the on-disk `agents.json` format, so skipping it there
21758/// would silently blank every token on the next manifest write.
21759fn agent_to_wire(agent: &car_registry::supervisor::ManagedAgent) -> Result<Value, String> {
21760    let mut v = serde_json::to_value(agent).map_err(|e| e.to_string())?;
21761    if let Some(map) = v.as_object_mut() {
21762        map.remove("token");
21763    }
21764    Ok(v)
21765}
21766
21767async fn handle_agents_list(state: &Arc<ServerState>) -> Result<Value, String> {
21768    // Observe-only mode (Parslee-ai/car-releases#44): a second
21769    // car-server on the host can't take the supervisor lock, so it
21770    // can't drive `Supervisor::list` — but it can still answer
21771    // `agents.list` by reading the on-disk manifest directly. The
21772    // `attached` decoration is local to whichever daemon the caller
21773    // is talking to, so observer-mode entries return `attached:
21774    // false` (this daemon hasn't received `session.auth` from those
21775    // children; the primary one has).
21776    let (agents, manifest_path, log_dir) = match state.observer_manifest_path() {
21777        Some(p) => {
21778            let agents = car_registry::supervisor::Supervisor::list_from_manifest(p)
21779                .map_err(|e| e.to_string())?;
21780            let log_dir = p
21781                .parent()
21782                .map(|parent| parent.join("logs"))
21783                .unwrap_or_else(|| std::path::PathBuf::from("logs"));
21784            (agents, p.clone(), log_dir)
21785        }
21786        None => {
21787            let supervisor = state.supervisor()?;
21788            let agents = supervisor.list().await;
21789            (
21790                agents,
21791                supervisor.manifest_path().to_path_buf(),
21792                supervisor.log_dir().to_path_buf(),
21793            )
21794        }
21795    };
21796    let agents_dir = manifest_path
21797        .parent()
21798        .map(|parent| parent.join("agents"))
21799        .unwrap_or_else(|| std::path::PathBuf::from("agents"));
21800    // Decorate each entry with `attached` + `session_id` so operators
21801    // see whether the supervised process has actually called
21802    // `session.auth { agent_id }` and bound a WS connection (#169) —
21803    // the lifecycle status (`Running`, etc.) only reports the
21804    // process-level view, which can't tell "alive but never
21805    // attached" from "alive and attached".
21806    let attached = state.attached_agents.lock().await.clone();
21807    let mut decorated: Vec<Value> = Vec::with_capacity(agents.len());
21808    for a in agents {
21809        let mut v = agent_to_wire(&a)?;
21810        let session_id = attached.get(&a.spec.id).cloned();
21811        if let Some(map) = v.as_object_mut() {
21812            map.insert("attached".to_string(), Value::Bool(session_id.is_some()));
21813            map.insert(
21814                "manifest_path".to_string(),
21815                Value::String(
21816                    agents_dir
21817                        .join(&a.spec.id)
21818                        .join("manifest.toml")
21819                        .to_string_lossy()
21820                        .into_owned(),
21821                ),
21822            );
21823            map.insert(
21824                "log_path".to_string(),
21825                Value::String(
21826                    log_dir
21827                        .join(format!("{}.stdout.log", a.spec.id))
21828                        .to_string_lossy()
21829                        .into_owned(),
21830                ),
21831            );
21832            map.insert(
21833                "stderr_log_path".to_string(),
21834                Value::String(
21835                    log_dir
21836                        .join(format!("{}.stderr.log", a.spec.id))
21837                        .to_string_lossy()
21838                        .into_owned(),
21839                ),
21840            );
21841            if let Some(sid) = session_id {
21842                map.insert("session_id".to_string(), Value::String(sid));
21843            }
21844        }
21845        decorated.push(v);
21846    }
21847    // Merge in declarative (in-daemon) agents the coder→agent loop built. They
21848    // carry `kind:"declarative"` + `enabled` instead of process status, and
21849    // never travel through the supervisor's command-validation path.
21850    decorated.extend(crate::coder::rpc::declarative_agent_rows(state).await);
21851    Ok(Value::Array(decorated))
21852}
21853
21854async fn handle_agents_upsert(
21855    req: &JsonRpcMessage,
21856    state: &Arc<ServerState>,
21857    session: &crate::session::ClientSession,
21858) -> Result<Value, String> {
21859    require_host_lifecycle_authority(session, state).await?;
21860    let mut params = req.params.clone();
21861    // Optional `interpreter` sugar (#171). When present, the
21862    // supervisor resolves the bare program name (`"node"`,
21863    // `"python"`, …) against `$PATH` and writes the absolute path
21864    // into `command` *before* validation. This keeps the strict
21865    // no-PATH-lookup rule at upsert time while letting callers
21866    // stop hand-coding `/opt/homebrew/bin/node` into every
21867    // agents.json entry. Resolution happens once; subsequent PATH
21868    // changes do not silently rewire the binding.
21869    if let Some(name) = params
21870        .get("interpreter")
21871        .and_then(|v| v.as_str())
21872        .map(str::to_string)
21873    {
21874        let resolved =
21875            car_registry::supervisor::resolve_interpreter(&name).map_err(|e| e.to_string())?;
21876        params["command"] = Value::String(resolved.to_string_lossy().into_owned());
21877    }
21878    let spec: car_registry::supervisor::AgentSpec =
21879        serde_json::from_value(params).map_err(|e| e.to_string())?;
21880    let supervisor = state.supervisor()?;
21881    let agent = supervisor.upsert(spec).await.map_err(|e| e.to_string())?;
21882    agent_to_wire(&agent)
21883}
21884
21885/// `agents.install` — install a contributed-agent manifest
21886/// (Parslee-ai/car#182 phase 3). Caller passes the parsed
21887/// `AgentManifest` JSON; the daemon runs install-time validation
21888/// (`car_min_version`, capability negotiation against the daemon's
21889/// own advertisement) and adopts the manifest. Returns
21890/// `{ report, agent? }` where `agent` is the spawnable
21891/// `ManagedAgent` for `external_process` transports and absent for
21892/// `pure_data` / health_url-only manifests.
21893///
21894/// The host capability advertisement comes from
21895/// `HostCapabilities::daemon_default(car_version)` — operators that
21896/// want a tighter advertisement go through a future config phase;
21897/// this MVP uses the runtime's natural surface.
21898async fn handle_agents_install(
21899    req: &JsonRpcMessage,
21900    state: &Arc<ServerState>,
21901    session: &crate::session::ClientSession,
21902) -> Result<Value, String> {
21903    require_host_lifecycle_authority(session, state).await?;
21904    let manifest: car_registry::manifest::AgentManifest =
21905        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21906    let host = car_registry::install::HostCapabilities::daemon_default(env!("CARGO_PKG_VERSION"));
21907    let supervisor = state.supervisor()?;
21908    let (report, managed) = supervisor
21909        .install_manifest(manifest, &host)
21910        .await
21911        .map_err(|e| e.to_string())?;
21912    Ok(serde_json::json!({
21913        "report": {
21914            "missingOptional": report
21915                .missing_optional
21916                .iter()
21917                .map(|(ns, feat)| serde_json::json!({ "namespace": ns, "feature": feat }))
21918                .collect::<Vec<_>>(),
21919        },
21920        "agent": managed,
21921    }))
21922}
21923
21924async fn handle_agents_health(state: &Arc<ServerState>) -> Result<Value, String> {
21925    // Observe-only mode (Parslee-ai/car-releases#44) — see
21926    // `handle_agents_list` for the rationale. The health view is a
21927    // pure function of each entry's `command` plus the on-disk
21928    // sandbox rules, so reading from the manifest is equivalent to
21929    // calling the live supervisor's `health()`.
21930    let entries = match state.observer_manifest_path() {
21931        Some(p) => car_registry::supervisor::Supervisor::health_from_manifest(p)
21932            .map_err(|e| e.to_string())?,
21933        None => {
21934            let supervisor = state.supervisor()?;
21935            supervisor.health().await
21936        }
21937    };
21938    serde_json::to_value(entries).map_err(|e| e.to_string())
21939}
21940
21941fn extract_agent_id(req: &JsonRpcMessage) -> Result<String, String> {
21942    req.params
21943        .get("id")
21944        .and_then(Value::as_str)
21945        .map(str::to_string)
21946        .ok_or_else(|| "missing required `id` parameter".to_string())
21947}
21948
21949async fn handle_agents_remove(
21950    req: &JsonRpcMessage,
21951    state: &Arc<ServerState>,
21952    session: &crate::session::ClientSession,
21953) -> Result<Value, String> {
21954    require_host_lifecycle_authority(session, state).await?;
21955    let id = extract_agent_id(req)?;
21956    let supervisor = state.supervisor()?;
21957    let removed = supervisor.remove(&id).await.map_err(|e| e.to_string())?;
21958    Ok(serde_json::json!({ "removed": removed }))
21959}
21960
21961async fn handle_agents_start(
21962    req: &JsonRpcMessage,
21963    state: &Arc<ServerState>,
21964    session: &crate::session::ClientSession,
21965) -> Result<Value, String> {
21966    let id = extract_agent_id(req)?;
21967    require_own_agent_or_host(session, state, "agents.start", &id).await?;
21968    let supervisor = state.supervisor()?;
21969    let agent = supervisor.start(&id).await.map_err(|e| e.to_string())?;
21970    agent_to_wire(&agent)
21971}
21972
21973async fn handle_agents_stop(
21974    req: &JsonRpcMessage,
21975    state: &Arc<ServerState>,
21976    session: &crate::session::ClientSession,
21977) -> Result<Value, String> {
21978    let id = extract_agent_id(req)?;
21979    require_own_agent_or_host(session, state, "agents.stop", &id).await?;
21980    let signal: car_registry::supervisor::StopSignal = req
21981        .params
21982        .get("signal")
21983        .map(|v| serde_json::from_value(v.clone()))
21984        .transpose()
21985        .map_err(|e| e.to_string())?
21986        .unwrap_or_default();
21987    let supervisor = state.supervisor()?;
21988    let agent = supervisor
21989        .stop(&id, signal)
21990        .await
21991        .map_err(|e| e.to_string())?;
21992    agent_to_wire(&agent)
21993}
21994
21995async fn handle_agents_restart(
21996    req: &JsonRpcMessage,
21997    state: &Arc<ServerState>,
21998    session: &crate::session::ClientSession,
21999) -> Result<Value, String> {
22000    let id = extract_agent_id(req)?;
22001    require_own_agent_or_host(session, state, "agents.restart", &id).await?;
22002    let supervisor = state.supervisor()?;
22003    let agent = supervisor.restart(&id).await.map_err(|e| e.to_string())?;
22004    agent_to_wire(&agent)
22005}
22006
22007/// Block until a managed agent reaches one of the target statuses, or the
22008/// timeout elapses. Params: `{ id, targets?: string[] = ["running"],
22009/// Ceiling for `agents.wait { timeout_secs }`. Far longer than any real
22010/// start or stop, and finite — an unbounded caller-supplied wait is a hold on
22011/// daemon resources for as long as the caller likes.
22012const MAX_AGENT_WAIT_SECS: f64 = 600.0;
22013
22014/// timeout_secs?: number = 30, poll_ms?: number = 200 }`. Returns the matching
22015/// `ManagedAgent`, or an error on timeout / unknown id.
22016async fn handle_agents_wait(
22017    req: &JsonRpcMessage,
22018    state: &Arc<ServerState>,
22019    bound_agent: Option<&str>,
22020) -> Result<Value, String> {
22021    let id = extract_agent_id(req)?;
22022    require_own_agent(bound_agent, "agents.wait", &id)?;
22023    let targets: Vec<car_registry::supervisor::AgentStatus> = match req.params.get("targets") {
22024        Some(v) => serde_json::from_value(v.clone())
22025            .map_err(|e| format!("invalid 'targets' (expected status string array): {e}"))?,
22026        None => vec![car_registry::supervisor::AgentStatus::Running],
22027    };
22028    let targets = if targets.is_empty() {
22029        vec![car_registry::supervisor::AgentStatus::Running]
22030    } else {
22031        targets
22032    };
22033    let timeout = std::time::Duration::from_secs_f64(
22034        req.params
22035            .get("timeout_secs")
22036            .and_then(Value::as_f64)
22037            .unwrap_or(30.0)
22038            // Bounded at both ends. Only the floor was enforced, so a caller
22039            // could ask the daemon to hold a supervisor poll open for an
22040            // arbitrary duration; ten minutes is far longer than any real
22041            // start/stop and still terminates.
22042            .clamp(0.0, MAX_AGENT_WAIT_SECS),
22043    );
22044    let poll = std::time::Duration::from_millis(
22045        req.params
22046            .get("poll_ms")
22047            .and_then(Value::as_u64)
22048            .unwrap_or(200)
22049            .max(10),
22050    );
22051    let supervisor = state.supervisor()?;
22052    let agent = supervisor
22053        .wait_for(&id, &targets, timeout, poll)
22054        .await
22055        .map_err(|e| e.to_string())?;
22056    agent_to_wire(&agent)
22057}
22058
22059async fn handle_agents_tail_log(
22060    req: &JsonRpcMessage,
22061    state: &Arc<ServerState>,
22062    bound_agent: Option<&str>,
22063) -> Result<Value, String> {
22064    let id = extract_agent_id(req)?;
22065    require_own_agent(bound_agent, "agents.tail_log", &id)?;
22066    // Honor `n` OR `lines` for the count — the CarHost UI historically
22067    // sent `lines: 200` while the daemon only read `n`, so the count
22068    // was silently ignored and the modal title lied (Parslee-ai/car#273).
22069    let n = req
22070        .params
22071        .get("n")
22072        .or_else(|| req.params.get("lines"))
22073        .and_then(Value::as_u64)
22074        .unwrap_or(100) as usize;
22075    let offset = req
22076        .params
22077        .get("offset")
22078        .and_then(Value::as_u64)
22079        .unwrap_or(0) as usize;
22080    let stream = car_registry::supervisor::LogStream::from_wire(
22081        req.params.get("stream").and_then(Value::as_str),
22082    );
22083    let supervisor = state.supervisor()?;
22084    let tail = supervisor
22085        .read_log(&id, stream, n, offset)
22086        .await
22087        .map_err(|e| e.to_string())?;
22088    serde_json::to_value(tail).map_err(|e| e.to_string())
22089}
22090
22091// ---------------------------------------------------------------------------
22092// External-agent detection (Phase 1 of docs/proposals/external-agent-detection.md)
22093//
22094// Discovery surface for agentic CLIs the user has already installed and
22095// authenticated (Claude Code, Codex, Gemini). Read-only — no invocation
22096// path yet; agents.invoke_external lands in Phase 2 alongside the JSON
22097// stdio adapter. The cache lives in car_ffi_common::external_agents so
22098// the in-process FFI singletons share the same snapshot.
22099// ---------------------------------------------------------------------------
22100
22101async fn handle_agents_list_external(req: &JsonRpcMessage) -> Result<Value, String> {
22102    let include_health = req
22103        .params
22104        .get("include_health")
22105        .and_then(Value::as_bool)
22106        .unwrap_or(false);
22107    let json = car_ffi_common::external_agents::list(include_health).await?;
22108    serde_json::from_str(&json).map_err(|e| e.to_string())
22109}
22110
22111async fn handle_agents_detect_external(req: &JsonRpcMessage) -> Result<Value, String> {
22112    let include_health = req
22113        .params
22114        .get("include_health")
22115        .and_then(Value::as_bool)
22116        .unwrap_or(false);
22117    let json = car_ffi_common::external_agents::detect(include_health).await?;
22118    serde_json::from_str(&json).map_err(|e| e.to_string())
22119}
22120
22121#[derive(Debug, Deserialize)]
22122struct AssistantInvokeParams {
22123    capability: String,
22124    #[serde(default)]
22125    agent_hint: Option<String>,
22126    payload_json: String,
22127}
22128
22129const ASSISTANT_INVOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
22130
22131/// Daemon-owned invocation for ready-to-use assistants. This mirrors the
22132/// UniFFI `invoke_capability` contract, but brackets the model call in the
22133/// durable run trace store so CarHost's Activity tab can explain what ran.
22134async fn handle_assistants_invoke(
22135    req: &JsonRpcMessage,
22136    state: &Arc<ServerState>,
22137    host_session: &Arc<crate::session::ClientSession>,
22138) -> Result<Value, String> {
22139    let params: AssistantInvokeParams =
22140        serde_json::from_value(req.params.clone()).map_err(|e| {
22141            format!("assistants.invoke requires {{ capability, agent_hint?, payload_json }}: {e}")
22142        })?;
22143    let capability = params.capability.trim();
22144    if capability.is_empty() {
22145        return Err("assistants.invoke requires a non-empty `capability`".to_string());
22146    }
22147
22148    let registry = car_engine::AgentCapabilityRegistry::new();
22149    car_engine::register_builtins(&registry);
22150    let agent = registry
22151        .select(capability, params.agent_hint.as_deref())
22152        .ok_or_else(|| format!("no ready-to-use assistant supports capability `{capability}`"))?;
22153    let task = car_engine::format_capability_payload(capability, &params.payload_json)
22154        .map_err(|e| e.to_string())?;
22155    let system_prompt = car_engine::agent_metadata(&agent)
22156        .map(|m| m.system_prompt.to_string())
22157        .unwrap_or_else(|| "You are a helpful assistant. Carry out the user's task.".into());
22158
22159    let run_id = uuid::Uuid::new_v4().to_string();
22160    let started_at = chrono::Utc::now();
22161    state
22162        .start_run(crate::session::RunMeta {
22163            run_id: run_id.clone(),
22164            agent_id: agent.clone(),
22165            client_id: host_session.client_id.clone(),
22166            active_client_id: host_session.client_id.clone(),
22167            resume_predecessor_client_id: None,
22168            resume_lease: None,
22169            intent: task.clone(),
22170            outcome_description: Some(format!("Run {capability} with a ready-to-use assistant")),
22171            started_at,
22172            termination: None,
22173            ended_at: None,
22174            turns: Vec::new(),
22175            start_committed: false,
22176            pending_terminal: None,
22177            cancellation_pending: None,
22178            cancellation_receipt: None,
22179            trace_corruption: None,
22180            durability_generation: 0,
22181        })
22182        .await?;
22183
22184    let req = car_inference::GenerateRequest {
22185        prompt: task.clone(),
22186        context: Some(system_prompt),
22187        intent: Some(car_inference::IntentHint {
22188            prefer_fast: true,
22189            ..Default::default()
22190        }),
22191        ..Default::default()
22192    };
22193    let engine = get_inference_engine(state);
22194    let generated: Result<String, String> = match assistant_model_setup_message(engine, &req).await
22195    {
22196        Some(message) => Err(message),
22197        None => match tokio::time::timeout(ASSISTANT_INVOKE_TIMEOUT, engine.generate(req)).await {
22198            Ok(Ok(result)) => Ok(result),
22199            Ok(Err(e)) => Err(format!("Assistant failed: {e}")),
22200            Err(_) => Err(format!(
22201                "Assistant timed out after {} seconds. Choose a faster model in Models, or try again after model setup finishes.",
22202                ASSISTANT_INVOKE_TIMEOUT.as_secs()
22203            )),
22204        },
22205    };
22206
22207    match generated {
22208        Ok(result) => {
22209            registry.note_used(capability, &agent);
22210            let _ = state
22211                .record_run_turns(
22212                    &run_id,
22213                    vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22214                        index: 0,
22215                        proposal_id: None,
22216                        action_id: None,
22217                        action_status: None,
22218                        action_duration_ms: None,
22219                        action_completed_at: None,
22220                        depends_on: None,
22221                        state_dependencies: None,
22222                        prompt: Some(task),
22223                        tool: Some(capability.to_string()),
22224                        parameters: serde_json::from_str(&params.payload_json).unwrap_or_else(
22225                            |_| serde_json::json!({ "payload_json": params.payload_json }),
22226                        ),
22227                        output: Some(serde_json::json!({ "text": result })),
22228                        cli_outcome: None,
22229                        verifier_verdict: car_proto::VerifierVerdict::NotRun,
22230                        policy_rejected: None,
22231                    })],
22232                )
22233                .await;
22234            state
22235                .complete_run(
22236                    &run_id,
22237                    car_proto::RunTermination::Outcome {
22238                        status: car_ir::OutcomeStatus::Success,
22239                        outcome: car_ir::AgentOutcome {
22240                            status: car_ir::OutcomeStatus::Success,
22241                            summary: "Assistant completed successfully.".to_string(),
22242                            evidence: Vec::new(),
22243                            metrics: Default::default(),
22244                            timestamp: chrono::Utc::now(),
22245                        },
22246                    },
22247                )
22248                .await?;
22249            Ok(serde_json::json!({
22250                "agent": agent,
22251                "result": result,
22252                "run_id": run_id,
22253            }))
22254        }
22255        Err(message) => {
22256            let _ = state
22257                .record_run_turns(
22258                    &run_id,
22259                    vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22260                        index: 0,
22261                        proposal_id: None,
22262                        action_id: None,
22263                        action_status: None,
22264                        action_duration_ms: None,
22265                        action_completed_at: None,
22266                        depends_on: None,
22267                        state_dependencies: None,
22268                        prompt: Some(task),
22269                        tool: Some(capability.to_string()),
22270                        parameters: serde_json::from_str(&params.payload_json).unwrap_or_else(
22271                            |_| serde_json::json!({ "payload_json": params.payload_json }),
22272                        ),
22273                        output: Some(serde_json::json!({ "error": message.clone() })),
22274                        cli_outcome: None,
22275                        verifier_verdict: car_proto::VerifierVerdict::NotRun,
22276                        policy_rejected: None,
22277                    })],
22278                )
22279                .await;
22280            state
22281                .complete_run(
22282                    &run_id,
22283                    car_proto::RunTermination::Outcome {
22284                        status: car_ir::OutcomeStatus::Failure,
22285                        outcome: car_ir::AgentOutcome {
22286                            status: car_ir::OutcomeStatus::Failure,
22287                            summary: message.clone(),
22288                            evidence: Vec::new(),
22289                            metrics: Default::default(),
22290                            timestamp: chrono::Utc::now(),
22291                        },
22292                    },
22293                )
22294                .await?;
22295            Err(message)
22296        }
22297    }
22298}
22299
22300async fn assistant_model_setup_message(
22301    engine: &car_inference::InferenceEngine,
22302    req: &car_inference::GenerateRequest,
22303) -> Option<String> {
22304    let decision = engine
22305        .route_adaptive_with_intent(&req.prompt, req.intent.clone())
22306        .await;
22307    match engine
22308        .unified_registry
22309        .ready_without_download(&decision.model_id)
22310    {
22311        Some(true) => None,
22312        Some(false) => Some(format!(
22313            "Assistant needs model setup before it can run. Open Models and install {}, then try again.",
22314            decision.model_name
22315        )),
22316        None => Some(format!(
22317            "Assistant selected {}, but that model is not registered. Open Models and choose an available model.",
22318            decision.model_name
22319        )),
22320    }
22321}
22322
22323/// Per-task invocation of an external CLI agent. Required params:
22324/// `id` (adapter, e.g. `"claude-code"`) and `task` (the prompt).
22325/// Optional: `cwd`, `allowed_tools`, `max_turns`, `timeout_secs`.
22326///
22327/// Phase 2 stage 3 ships with `claude-code` only. Other adapter
22328/// ids return `is_error: true` with a structured `error` so hosts
22329/// can surface the gap without a separate error code.
22330///
22331/// Phase 2 stage 4a (governance): every invocation appends a
22332/// structured audit record to `~/.car/external-agents.jsonl`. The
22333/// record captures id, task, options, result, and the full
22334/// `tool_uses` list the assistant emitted — so even though the
22335/// agent executes its built-in tools in-process (which we can't
22336/// gate via stream-json), there's a complete after-the-fact audit
22337/// trail. Full policy gating (proposing each tool_use to CAR's
22338/// validator + getting a yes/no) requires the MCP server route in
22339/// stage 4b.
22340async fn handle_agents_invoke_external(
22341    req: &JsonRpcMessage,
22342    state: &Arc<ServerState>,
22343    host_session: &Arc<crate::session::ClientSession>,
22344) -> Result<Value, String> {
22345    let id = req
22346        .params
22347        .get("id")
22348        .and_then(Value::as_str)
22349        .ok_or_else(|| "missing required `id` parameter".to_string())?
22350        .to_string();
22351    let task = req
22352        .params
22353        .get("task")
22354        .and_then(Value::as_str)
22355        .ok_or_else(|| "missing required `task` parameter".to_string())?
22356        .to_string();
22357    let stream = req
22358        .params
22359        .get("stream")
22360        .and_then(Value::as_bool)
22361        .unwrap_or(false);
22362    let session_id = req
22363        .params
22364        .get("session_id")
22365        .and_then(Value::as_str)
22366        .map(str::to_string)
22367        .unwrap_or_else(|| format!("ext-{}", uuid::Uuid::new_v4().simple()));
22368
22369    // Build the options sub-object directly from req.params so
22370    // hosts can pass `cwd` / `allowed_tools` / `max_turns` /
22371    // `timeout_secs` as siblings of `id`/`task`. Strip the
22372    // dispatch + streaming fields so they don't pollute the
22373    // options serde.
22374    let mut options_value = req.params.clone();
22375    if let Some(obj) = options_value.as_object_mut() {
22376        obj.remove("id");
22377        obj.remove("task");
22378        obj.remove("stream");
22379        obj.remove("session_id");
22380        // Auto-fill `mcp_endpoint` from the bound MCP URL when the
22381        // caller didn't supply one. This is the load-bearing
22382        // wiring of MCP-4: external agents get CAR's tools (memory,
22383        // skills, verify) routed through the daemon's policy +
22384        // shared memgine without any per-call host configuration.
22385        // Callers who want to opt out can pass `"mcp_endpoint": ""`
22386        // (empty string) — the runner skips the temp-file write
22387        // when the value isn't a non-empty URL.
22388        let has_explicit_mcp = obj.contains_key("mcp_endpoint");
22389        if !has_explicit_mcp {
22390            if let Some(url) = state.mcp_url.get() {
22391                obj.insert("mcp_endpoint".to_string(), Value::String(url.clone()));
22392            }
22393        }
22394    }
22395
22396    if !stream {
22397        // Legacy one-shot path. Unchanged shape for FFI consumers
22398        // and any caller that hasn't opted into streaming.
22399        let options_json = options_value.to_string();
22400        let json = car_ffi_common::external_agents::invoke(&id, &task, &options_json).await?;
22401        let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
22402        append_external_agent_audit(&id, &task, &options_value, &result);
22403        return Ok(result);
22404    }
22405
22406    // Streaming path. Returns an ack ({accepted, session_id})
22407    // immediately and streams `agents.chat.event` notifications
22408    // to the host's WS as the runner emits StreamEvents. Reuses
22409    // the chat_sessions routing infrastructure supervised agents
22410    // use — host UIs render both kinds through the same path.
22411    let opts: car_external_agents::InvokeOptions = serde_json::from_value(options_value.clone())
22412        .map_err(|e| format!("invalid options: {e}"))?;
22413
22414    // Register the chat session BEFORE spawning so the host's
22415    // subscriber is correctly bound to this session_id by the
22416    // time the first event arrives. Reusing the supervised
22417    // agent chat infrastructure means `agents.chat.cancel`
22418    // routes to chat_sessions[session_id] and can flip the same
22419    // local cancellation flag the external runner races against.
22420    let local_cancel = Arc::new(AtomicBool::new(false));
22421    let owns_chat_session = {
22422        // If a chat session is already registered for this id (the
22423        // typical proxy shape: host → agents.chat → supervised agent
22424        // → agents.invoke_external with the same session_id), DO NOT
22425        // overwrite it. The existing entry owns the routing to the
22426        // original host; clobbering it with our caller's client_id
22427        // would send streaming events to the proxying agent instead
22428        // of back to the host that issued agents.chat. Only register
22429        // a fresh entry when the slot is empty (a direct
22430        // host-to-invoke_external call without a prior agents.chat).
22431        let mut chats = state.chat_sessions.lock().await;
22432        if let Some(chat) = chats.get_mut(&session_id) {
22433            if chat.local_cancel.is_none() {
22434                chat.local_cancel = Some(local_cancel.clone());
22435            }
22436            false
22437        } else {
22438            let created_at = std::time::SystemTime::now()
22439                .duration_since(std::time::UNIX_EPOCH)
22440                .map(|d| d.as_secs())
22441                .unwrap_or(0);
22442            chats.insert(
22443                session_id.clone(),
22444                crate::session::ChatSession {
22445                    agent_id: id.clone(),
22446                    host_client_id: host_session.client_id.clone(),
22447                    created_at,
22448                    local_cancel: Some(local_cancel.clone()),
22449                },
22450            );
22451            true
22452        }
22453    };
22454
22455    // Single drain task pulls StreamEvents off an unbounded
22456    // channel and serializes WS sends to the host. Per-event
22457    // tokio::spawn would let sends race (which token arrives
22458    // first depends on lock acquisition order). The channel
22459    // is unbounded because claude's event volume is bounded
22460    // by user turn count — typically <50 events per invocation.
22461    use tokio::sync::mpsc;
22462    let (tx, mut rx) = mpsc::unbounded_channel::<car_external_agents::StreamEvent>();
22463
22464    let drain_state = state.clone();
22465    let drain_session_id = session_id.clone();
22466    let drain_agent_id = id.clone();
22467    tokio::spawn(async move {
22468        while let Some(event) = rx.recv().await {
22469            emit_external_chat_event(&drain_state, &drain_session_id, &drain_agent_id, event).await;
22470        }
22471    });
22472
22473    let emitter_tx = tx.clone();
22474    let emitter: car_external_agents::StreamEventEmitter = Arc::new(move |event| {
22475        // Send failure (rx dropped) means the drain task has
22476        // exited — usually because the host disconnected. The
22477        // runner will keep going; let it finish so the audit
22478        // log captures the full result.
22479        let _ = emitter_tx.send(event);
22480    });
22481
22482    // Run the invocation in a separate task so this handler
22483    // can return the ack right away. The runner's child-process
22484    // future owns the spawn lifetime; if the host disconnects
22485    // mid-stream, the runner still completes (its events fall
22486    // on the floor at the drain layer) so the audit log lands.
22487    let spawn_state = state.clone();
22488    let spawn_session_id = session_id.clone();
22489    let spawn_id = id.clone();
22490    let spawn_task = task.clone();
22491    let spawn_options = options_value.clone();
22492    tokio::spawn(async move {
22493        let outcome = car_external_agents::invoke_with_emitter_and_cancel(
22494            &spawn_id,
22495            &spawn_task,
22496            opts,
22497            Some(emitter),
22498            Some(local_cancel.clone()),
22499        )
22500        .await;
22501        drop(tx); // signal drain task to exit after queue empties
22502
22503        // Synthesize a terminal agents.chat.event so the host's
22504        // bubble finalizes. The runner doesn't emit a "done" event
22505        // itself — the result is the aggregate InvokeResult. We
22506        // translate it here.
22507        let terminal_params: Value;
22508        let result_value: Value;
22509        match outcome {
22510            Ok(res) => {
22511                // Pack the metadata into `finish_reason` as a
22512                // human-readable summary so the host's existing
22513                // ChatEvent decoder surfaces it without a schema
22514                // change. Hosts that want structured data can
22515                // re-issue `agents.invoke_external` with
22516                // `stream: false` and read the InvokeResult.
22517                let mut parts: Vec<String> = Vec::new();
22518                if res.turns > 0 {
22519                    parts.push(format!(
22520                        "{} turn{}",
22521                        res.turns,
22522                        if res.turns == 1 { "" } else { "s" }
22523                    ));
22524                }
22525                if res.tool_calls > 0 {
22526                    parts.push(format!(
22527                        "{} tool{}",
22528                        res.tool_calls,
22529                        if res.tool_calls == 1 { "" } else { "s" }
22530                    ));
22531                }
22532                if res.duration_ms > 0 {
22533                    parts.push(format!("{:.1}s", res.duration_ms as f64 / 1000.0));
22534                }
22535                if res.dropped_attachments > 0 {
22536                    parts.push(format!(
22537                        "{} image{} skipped",
22538                        res.dropped_attachments,
22539                        if res.dropped_attachments == 1 {
22540                            ""
22541                        } else {
22542                            "s"
22543                        }
22544                    ));
22545                }
22546                let summary = if parts.is_empty() {
22547                    "stop".to_string()
22548                } else {
22549                    parts.join(" · ")
22550                };
22551                if res.is_error {
22552                    terminal_params = serde_json::json!({
22553                        "session_id": spawn_session_id,
22554                        "agent_id": spawn_id,
22555                        "kind": "error",
22556                        "error": res.error.clone().unwrap_or_else(|| "external agent reported error".to_string()),
22557                    });
22558                } else {
22559                    terminal_params = serde_json::json!({
22560                        "session_id": spawn_session_id,
22561                        "agent_id": spawn_id,
22562                        "kind": "done",
22563                        "finish_reason": summary,
22564                        // Structured count so the host can warn distinctly
22565                        // from the human-readable summary. Omitted when 0.
22566                        "dropped_attachments": res.dropped_attachments,
22567                    });
22568                }
22569                result_value = serde_json::to_value(&res).unwrap_or(Value::Null);
22570            }
22571            Err(e) => {
22572                let message = format!("{e}");
22573                terminal_params = serde_json::json!({
22574                    "session_id": spawn_session_id,
22575                    "agent_id": spawn_id,
22576                    "kind": "error",
22577                    "error": message.clone(),
22578                });
22579                result_value = serde_json::json!({ "is_error": true, "error": message });
22580            }
22581        }
22582        send_external_chat_frame(&spawn_state, &spawn_session_id, terminal_params).await;
22583        remove_owned_external_chat_session(&spawn_state, &spawn_session_id, owns_chat_session)
22584            .await;
22585        append_external_agent_audit(&spawn_id, &spawn_task, &spawn_options, &result_value);
22586    });
22587
22588    Ok(serde_json::json!({
22589        "accepted": true,
22590        "session_id": session_id,
22591    }))
22592}
22593
22594async fn remove_owned_external_chat_session(
22595    state: &Arc<ServerState>,
22596    session_id: &str,
22597    owns_chat_session: bool,
22598) {
22599    if owns_chat_session {
22600        state.chat_sessions.lock().await.remove(session_id);
22601    }
22602}
22603
22604/// Translate one [`StreamEvent`] from the running external CLI
22605/// into an `agents.chat.event` notification on the originating
22606/// host's WS. Same wire shape supervised agents emit, so host
22607/// UIs render both kinds with one decoder.
22608///
22609/// Mapping:
22610/// - `Assistant` events with `text` content blocks → `kind: "token"`
22611///   per text block. Each block carries the full text the
22612///   assistant emitted in that turn (claude doesn't expose
22613///   word-level deltas via stream-json — it emits per-turn or
22614///   per-content-block chunks).
22615/// - `Assistant` events with `tool_use` blocks → `kind: "tool_call"`
22616///   per block (tool name in `detail`).
22617/// - `System` / `User` / `Result` / others → dropped (Result's
22618///   metadata is folded into the terminal `done` event the
22619///   outer task emits when the invocation finishes).
22620async fn emit_external_chat_event(
22621    state: &Arc<ServerState>,
22622    session_id: &str,
22623    agent_id: &str,
22624    event: car_external_agents::StreamEvent,
22625) {
22626    use car_external_agents::StreamEvent;
22627    match event {
22628        StreamEvent::Assistant(a) => {
22629            if let Some(content) = a.message.get("content").and_then(|v| v.as_array()) {
22630                for block in content {
22631                    let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
22632                    match block_type {
22633                        "text" => {
22634                            if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
22635                                if !text.is_empty() {
22636                                    let params = serde_json::json!({
22637                                        "session_id": session_id,
22638                                        "agent_id": agent_id,
22639                                        "kind": "token",
22640                                        "delta": text,
22641                                    });
22642                                    send_external_chat_frame(state, session_id, params).await;
22643                                }
22644                            }
22645                        }
22646                        "tool_use" => {
22647                            let name = block
22648                                .get("name")
22649                                .and_then(|v| v.as_str())
22650                                .unwrap_or("(unknown tool)");
22651                            // Emit the host-protocol.md `tool` + `params`
22652                            // shape so the host renders a structured
22653                            // tool-call card (name + args). `detail` is
22654                            // kept as a legacy alias for older hosts that
22655                            // only read it.
22656                            let mut frame = serde_json::json!({
22657                                "session_id": session_id,
22658                                "agent_id": agent_id,
22659                                "kind": "tool_call",
22660                                "tool": name,
22661                                "detail": name,
22662                            });
22663                            if let Some(input) = block.get("input") {
22664                                if !input.is_null() {
22665                                    frame["params"] = input.clone();
22666                                }
22667                            }
22668                            send_external_chat_frame(state, session_id, frame).await;
22669                        }
22670                        _ => {}
22671                    }
22672                }
22673            }
22674        }
22675        _ => {
22676            // System (session id init), User (tool result echo),
22677            // Result (final aggregate — folded into terminal
22678            // `done` event by the outer task), RateLimitEvent,
22679            // Other: not surfaced to host.
22680        }
22681    }
22682}
22683
22684/// Send a single `agents.chat.event` notification to the host
22685/// session bound by `session_id`. Best-effort: a missing route
22686/// or a closed WS is silently dropped, the runner continues so
22687/// the audit log lands.
22688async fn send_external_chat_frame(state: &Arc<ServerState>, session_id: &str, params: Value) {
22689    use futures::SinkExt;
22690    use tokio_tungstenite::tungstenite::Message;
22691
22692    update_chat_goal_from_event(state, session_id, &params).await;
22693
22694    let host_client_id = state
22695        .chat_sessions
22696        .lock()
22697        .await
22698        .get(session_id)
22699        .map(|s| s.host_client_id.clone());
22700    let Some(host_client_id) = host_client_id else {
22701        return;
22702    };
22703    let host_channel = {
22704        let sessions = state.sessions.lock().await;
22705        sessions.get(&host_client_id).map(|s| s.channel.clone())
22706    };
22707    let Some(channel) = host_channel else {
22708        return;
22709    };
22710    let frame = serde_json::json!({
22711        "jsonrpc": "2.0",
22712        "method": "agents.chat.event",
22713        "params": params,
22714    });
22715    if let Ok(text) = serde_json::to_string(&frame) {
22716        let _ = channel
22717            .write
22718            .lock()
22719            .await
22720            .send(Message::Text(text.into()))
22721            .await;
22722    }
22723}
22724
22725/// Append one JSONL audit record to `external-agents.jsonl` under the CAR state
22726/// root (`~/.car/external-agents.jsonl` unless `CAR_HOME` moves the root).
22727/// Best-effort: a failure to open the journal must NOT fail the
22728/// invocation; the in-memory result already returned is the
22729/// authoritative answer. Logs at warn level when the write fails so
22730/// operators notice repeated failures. An unresolvable root is the same
22731/// best-effort miss — no root, no journal, no failed invocation.
22732fn append_external_agent_audit(id: &str, task: &str, options: &Value, result: &Value) {
22733    use std::io::Write;
22734    let car_dir = match car_home::root() {
22735        Some(root) => root,
22736        None => return,
22737    };
22738    if std::fs::create_dir_all(&car_dir).is_err() {
22739        return;
22740    }
22741    let path = car_dir.join("external-agents.jsonl");
22742    let record = serde_json::json!({
22743        "ts": chrono::Utc::now().to_rfc3339(),
22744        "adapter_id": id,
22745        "task": task,
22746        "options": options,
22747        "result": result,
22748    });
22749    let line = match serde_json::to_string(&record) {
22750        Ok(s) => s,
22751        Err(_) => return,
22752    };
22753    if let Ok(mut f) = std::fs::OpenOptions::new()
22754        .create(true)
22755        .append(true)
22756        .open(&path)
22757    {
22758        let _ = writeln!(f, "{}", line);
22759    } else {
22760        tracing::warn!(
22761            path = %path.display(),
22762            "failed to append external-agent audit record"
22763        );
22764    }
22765}
22766
22767/// Ground-truth health check. Optional `id` param picks one tool;
22768/// without it, every detected adapter is checked. `force: true`
22769/// bypasses the 30s per-tool TTL cache. Replaces the Phase 1
22770/// credential-file shape heuristic as the load-bearing signal for
22771/// "is this tool ready to invoke."
22772async fn handle_agents_health_external(req: &JsonRpcMessage) -> Result<Value, String> {
22773    let force = req
22774        .params
22775        .get("force")
22776        .and_then(Value::as_bool)
22777        .unwrap_or(false);
22778    if let Some(id) = req.params.get("id").and_then(Value::as_str) {
22779        let json = car_ffi_common::external_agents::health_one(id, force).await?;
22780        serde_json::from_str(&json).map_err(|e| e.to_string())
22781    } else {
22782        let json = car_ffi_common::external_agents::health(force).await?;
22783        serde_json::from_str(&json).map_err(|e| e.to_string())
22784    }
22785}
22786
22787// ---------------------------------------------------------------------------
22788// agents.chat — unified chat surface (docs/proposals/agent-chat-surface.md)
22789// ---------------------------------------------------------------------------
22790//
22791// Host calls `agents.chat { agent_id, prompt, session_id?, stream? }`.
22792// The server looks up the target agent's attached WS connection,
22793// reverse-calls `agent.chat { session_id, prompt, context }` on it
22794// (same pattern as `tools.execute`), and returns once the agent acks.
22795// The agent then streams `agent.chat.event` notifications back, which
22796// the dispatcher intercepts (see `try_forward_agent_chat_event`) and
22797// rewrites as `agents.chat.event` notifications on the originating
22798// host's channel.
22799
22800/// Timeout the server waits for the agent to ack `agent.chat`. The
22801/// streamed tokens come later as separate notifications and have no
22802/// bearing on this — this is just "did the agent receive the prompt
22803/// and accept it." Five seconds is generous for a local IPC ack.
22804const AGENT_CHAT_ACK_TIMEOUT_SECS: u64 = 5;
22805
22806/// `agents.chat` — host issues a chat turn to a named agent. Returns
22807/// `{ accepted: true, session_id }` once the agent acks; streamed
22808/// tokens arrive on the host's channel as `agents.chat.event`
22809/// notifications keyed by the same `session_id`.
22810/// Validate and extract the optional `attachments` array from an
22811/// `agents.chat` request's params. Returns `Ok(None)` when absent or
22812/// null; `Err` on a malformed shape (a non-array, or any entry lacking
22813/// an image-`ContentBlock` `type`). Pulled out so the validation is
22814/// unit-testable without the full session machinery.
22815fn extract_chat_attachments(params: &Value) -> Result<Option<Value>, String> {
22816    const ALLOWED_MEDIA: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
22817    let items = match params.get("attachments") {
22818        None | Some(Value::Null) => return Ok(None),
22819        Some(Value::Array(items)) => items,
22820        Some(_) => return Err("`agents.chat` `attachments` must be an array".to_string()),
22821    };
22822    // Validate each entry's content, not just its `type` tag — we
22823    // forward this verbatim to the agent and on to a provider, so a
22824    // malformed/hostile entry (missing data, a non-image media type, a
22825    // `file://` URL) must not pass through.
22826    for item in items {
22827        let ty = item.get("type").and_then(Value::as_str).ok_or_else(|| {
22828            "`agents.chat` `attachments` entries must have a string `type`".to_string()
22829        })?;
22830        match ty {
22831            "image_base64" => {
22832                if item.get("data").and_then(Value::as_str).is_none() {
22833                    return Err("`image_base64` attachment requires a string `data`".to_string());
22834                }
22835                let media = item.get("media_type").and_then(Value::as_str).unwrap_or("");
22836                if !ALLOWED_MEDIA.contains(&media) {
22837                    return Err(format!(
22838                        "`image_base64` attachment `media_type` must be one of {ALLOWED_MEDIA:?}"
22839                    ));
22840                }
22841            }
22842            "image_url" => {
22843                let url = item.get("url").and_then(Value::as_str).unwrap_or("");
22844                if !(url.starts_with("https://") || url.starts_with("http://")) {
22845                    return Err("`image_url` attachment `url` must be http(s)".to_string());
22846                }
22847            }
22848            other => {
22849                return Err(format!(
22850                    "`agents.chat` `attachments` entries must be image ContentBlocks \
22851                     (`image_base64` or `image_url`), got `{other}`"
22852                ));
22853            }
22854        }
22855    }
22856    Ok(Some(Value::Array(items.clone())))
22857}
22858
22859/// Validate and extract an optional deterministic goal check for an
22860/// `agents.chat` turn. When present, the served assistant runs the prompt as a
22861/// goal loop and streams `goal_evaluated` after each verifier pass.
22862fn extract_chat_goal(params: &Value) -> Result<Option<Value>, String> {
22863    let Some(goal) = params.get("goal") else {
22864        return Ok(None);
22865    };
22866    if goal.is_null() {
22867        return Ok(None);
22868    }
22869    let obj = goal
22870        .as_object()
22871        .ok_or_else(|| "`agents.chat` `goal` must be an object".to_string())?;
22872    let check = obj
22873        .get("check")
22874        .and_then(Value::as_str)
22875        .map(str::trim)
22876        .filter(|s| !s.is_empty())
22877        .ok_or_else(|| "`agents.chat` `goal.check` must be a non-empty string".to_string())?;
22878    let max_iterations = obj
22879        .get("max_iterations")
22880        .and_then(Value::as_u64)
22881        .unwrap_or(8)
22882        .clamp(1, 50);
22883    Ok(Some(serde_json::json!({
22884        "check": check,
22885        "max_iterations": max_iterations,
22886    })))
22887}
22888
22889/// Validate the optional explicit inference model for a native chat turn.
22890/// Omitted/null/blank preserves the target agent's own model or adaptive
22891/// routing default. A non-empty value uses the same `model` selector carried
22892/// by CAR's typed inference requests.
22893fn extract_chat_model(params: &Value) -> Result<Option<String>, String> {
22894    match params.get("model") {
22895        None | Some(Value::Null) => Ok(None),
22896        Some(Value::String(model)) => {
22897            Ok((!model.trim().is_empty()).then(|| model.trim().to_string()))
22898        }
22899        Some(_) => Err("`agents.chat` `model` must be a string".to_string()),
22900    }
22901}
22902
22903fn unix_secs_now() -> u64 {
22904    std::time::SystemTime::now()
22905        .duration_since(std::time::UNIX_EPOCH)
22906        .map(|d| d.as_secs())
22907        .unwrap_or(0)
22908}
22909
22910fn chat_goal_value_to_state(session_id: &str, goal: &Value) -> Result<ChatGoalState, String> {
22911    let obj = goal
22912        .as_object()
22913        .ok_or_else(|| "`goal` must be an object".to_string())?;
22914    let check = obj
22915        .get("check")
22916        .and_then(Value::as_str)
22917        .map(str::trim)
22918        .filter(|s| !s.is_empty())
22919        .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
22920    let max_iterations = obj
22921        .get("max_iterations")
22922        .and_then(Value::as_u64)
22923        .unwrap_or(8)
22924        .clamp(1, 50) as u32;
22925    Ok(ChatGoalState {
22926        session_id: session_id.to_string(),
22927        check: check.to_string(),
22928        max_iterations,
22929        status: "active".to_string(),
22930        last_iteration: None,
22931        last_met: None,
22932        last_grounded: None,
22933        last_reason: None,
22934        terminal_kind: None,
22935        terminal_message: None,
22936        updated_at: unix_secs_now(),
22937    })
22938}
22939
22940fn chat_goal_state_to_value(goal: &ChatGoalState) -> Result<Value, String> {
22941    serde_json::to_value(goal).map_err(|e| e.to_string())
22942}
22943
22944fn chat_goal_state_to_wire_goal(goal: &ChatGoalState) -> Value {
22945    serde_json::json!({
22946        "check": goal.check,
22947        "max_iterations": goal.max_iterations,
22948    })
22949}
22950
22951async fn ensure_chat_goal_running(
22952    state: &Arc<ServerState>,
22953    session_id: &str,
22954    goal_value: &Value,
22955) -> Result<(), String> {
22956    let mut inserted = false;
22957    {
22958        let mut goals = state.chat_goals.lock().await;
22959        if let Some(stored) = goals.get_mut(session_id) {
22960            stored.status = "running".to_string();
22961            stored.terminal_kind = None;
22962            stored.terminal_message = None;
22963            stored.updated_at = unix_secs_now();
22964        } else {
22965            let mut goal = chat_goal_value_to_state(session_id, goal_value)?;
22966            goal.status = "running".to_string();
22967            goals.insert(session_id.to_string(), goal);
22968            inserted = true;
22969        }
22970    }
22971    state.persist_chat_goals().await?;
22972    if inserted {
22973        tracing::debug!(
22974            session_id,
22975            "created durable chat goal status for inline goal"
22976        );
22977    }
22978    Ok(())
22979}
22980
22981async fn handle_goal_suggest(
22982    req: &JsonRpcMessage,
22983    state: &Arc<ServerState>,
22984) -> Result<Value, String> {
22985    let prompt = req
22986        .params
22987        .get("prompt")
22988        .or_else(|| req.params.get("objective"))
22989        .and_then(Value::as_str)
22990        .map(str::trim)
22991        .filter(|s| !s.is_empty())
22992        .ok_or_else(|| "`goal.suggest` requires `prompt` or `objective`".to_string())?;
22993    let cwd = req
22994        .params
22995        .get("working_dir")
22996        .or_else(|| req.params.get("cwd"))
22997        .and_then(Value::as_str)
22998        .map(std::path::PathBuf::from);
22999    let mut result = crate::goal_suggest::synthesize_goal_check(prompt, cwd.as_deref());
23000
23001    let should_set = req
23002        .params
23003        .get("set")
23004        .and_then(Value::as_bool)
23005        .unwrap_or(false);
23006    if should_set {
23007        let session_id = req
23008            .params
23009            .get("session_id")
23010            .and_then(Value::as_str)
23011            .map(str::trim)
23012            .filter(|s| !s.is_empty())
23013            .ok_or_else(|| "`goal.suggest` with `set: true` requires `session_id`".to_string())?;
23014        let goal_value = result.get("goal").cloned().unwrap_or(Value::Null);
23015        if goal_value.is_null() {
23016            return Err("`goal.suggest` could not infer a goal to set".to_string());
23017        }
23018        let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23019        state
23020            .chat_goals
23021            .lock()
23022            .await
23023            .insert(session_id.to_string(), goal.clone());
23024        state.persist_chat_goals().await?;
23025        if let Some(obj) = result.as_object_mut() {
23026            obj.insert("set".to_string(), Value::Bool(true));
23027            obj.insert(
23028                "session_id".to_string(),
23029                Value::String(session_id.to_string()),
23030            );
23031            obj.insert("stored_goal".to_string(), chat_goal_state_to_value(&goal)?);
23032        }
23033    }
23034
23035    Ok(result)
23036}
23037
23038async fn handle_goal_set(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
23039    let session_id = req
23040        .params
23041        .get("session_id")
23042        .and_then(Value::as_str)
23043        .map(str::trim)
23044        .filter(|s| !s.is_empty())
23045        .ok_or_else(|| "`goal.set` requires `session_id`".to_string())?;
23046    let goal_value = if let Some(goal) = req.params.get("goal") {
23047        goal.clone()
23048    } else {
23049        serde_json::json!({
23050            "check": req.params.get("check").cloned().unwrap_or(Value::Null),
23051            "max_iterations": req.params.get("max_iterations").cloned().unwrap_or(Value::Null),
23052        })
23053    };
23054    let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23055    state
23056        .chat_goals
23057        .lock()
23058        .await
23059        .insert(session_id.to_string(), goal.clone());
23060    state.persist_chat_goals().await?;
23061    Ok(serde_json::json!({
23062        "set": true,
23063        "goal": chat_goal_state_to_value(&goal)?,
23064    }))
23065}
23066
23067async fn handle_goal_status(
23068    req: &JsonRpcMessage,
23069    state: &Arc<ServerState>,
23070) -> Result<Value, String> {
23071    let goals = state.chat_goals.lock().await;
23072    if let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) {
23073        let goal = goals.get(session_id).cloned();
23074        return Ok(serde_json::json!({
23075            "session_id": session_id,
23076            "goal": goal
23077                .as_ref()
23078                .map(chat_goal_state_to_value)
23079                .transpose()?
23080                .unwrap_or(Value::Null),
23081        }));
23082    }
23083    let mut values = Vec::with_capacity(goals.len());
23084    for goal in goals.values() {
23085        values.push(chat_goal_state_to_value(goal)?);
23086    }
23087    Ok(serde_json::json!({ "goals": values }))
23088}
23089
23090async fn handle_goal_clear(
23091    req: &JsonRpcMessage,
23092    state: &Arc<ServerState>,
23093) -> Result<Value, String> {
23094    let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) else {
23095        let mut goals = state.chat_goals.lock().await;
23096        let removed = goals.len();
23097        goals.clear();
23098        drop(goals);
23099        state.persist_chat_goals().await?;
23100        return Ok(serde_json::json!({ "cleared": removed }));
23101    };
23102    let removed = state.chat_goals.lock().await.remove(session_id).is_some();
23103    state.persist_chat_goals().await?;
23104    Ok(serde_json::json!({
23105        "cleared": removed,
23106        "session_id": session_id,
23107    }))
23108}
23109
23110/// Build the `params` object for the reverse `agent.chat` call. The
23111/// `attachments` key is inserted only when present, so agents that
23112/// don't read it see no change to the request shape. Pulled out so the
23113/// attachment-forwarding is unit-testable.
23114fn build_agent_chat_params(
23115    session_id: &str,
23116    prompt: &str,
23117    stream: bool,
23118    host_client_id: &str,
23119    voice_input: bool,
23120    model: Option<String>,
23121    attachments: Option<Value>,
23122    goal: Option<Value>,
23123) -> Value {
23124    let mut params = serde_json::json!({
23125        "session_id": session_id,
23126        "prompt": prompt,
23127        "stream": stream,
23128        "context": {
23129            "host_client_id": host_client_id,
23130            "voice_input": voice_input,
23131        },
23132    });
23133    if let Some(model) = model {
23134        if let Some(obj) = params.as_object_mut() {
23135            obj.insert("model".to_string(), Value::String(model));
23136        }
23137    }
23138    if let Some(att) = attachments {
23139        if let Some(obj) = params.as_object_mut() {
23140            obj.insert("attachments".to_string(), att);
23141        }
23142    }
23143    if let Some(goal) = goal {
23144        if let Some(obj) = params.as_object_mut() {
23145            obj.insert("goal".to_string(), goal);
23146        }
23147    }
23148    params
23149}
23150
23151/// Resolve a caller-supplied `agent_id` against the flagship assistant's
23152/// canonical/legacy id pair (car#1107). If `agent_id` names the assistant
23153/// under either spelling (`parslee-core` or `car-assistant`) and the daemon
23154/// only has the *other* spelling attached, return the attached one; otherwise
23155/// return `agent_id` unchanged. Keeps macOS (hardcoded `car-assistant`) and
23156/// mobile (hardcoded `parslee-core`) both working against a daemon that
23157/// attaches the flagship under one spelling at a time.
23158async fn resolve_assistant_agent_alias(agent_id: String, state: &Arc<ServerState>) -> String {
23159    if !crate::assistant::register::is_assistant_alias(&agent_id) {
23160        return agent_id;
23161    }
23162    let attached = state.attached_agents.lock().await;
23163    if attached.contains_key(&agent_id) {
23164        return agent_id;
23165    }
23166    for alias in [
23167        crate::assistant::register::ASSISTANT_AGENT_ID,
23168        crate::assistant::register::LEGACY_ASSISTANT_AGENT_ID,
23169    ] {
23170        if alias != agent_id && attached.contains_key(alias) {
23171            return alias.to_string();
23172        }
23173    }
23174    agent_id
23175}
23176
23177async fn handle_agents_chat(
23178    req: &JsonRpcMessage,
23179    state: &Arc<ServerState>,
23180    host_session: &Arc<crate::session::ClientSession>,
23181) -> Result<Value, String> {
23182    use futures::SinkExt;
23183    use tokio::sync::oneshot;
23184    use tokio_tungstenite::tungstenite::Message;
23185
23186    let agent_id = req
23187        .params
23188        .get("agent_id")
23189        .and_then(Value::as_str)
23190        .ok_or_else(|| "`agents.chat` requires `agent_id`".to_string())?
23191        .to_string();
23192    // Resolve `car-assistant` / `parslee-core` to whichever spelling the
23193    // flagship assistant actually attached under (car#1107). This is the
23194    // single call site: `agents.chat.approve` / `.cancel` read the resolved
23195    // id back out of `chat_sessions` (see `ChatSession::agent_id` below),
23196    // so they need no resolution of their own.
23197    let agent_id = resolve_assistant_agent_alias(agent_id, state).await;
23198    let prompt = req
23199        .params
23200        .get("prompt")
23201        .and_then(Value::as_str)
23202        .ok_or_else(|| "`agents.chat` requires `prompt`".to_string())?
23203        .to_string();
23204    let session_id = req
23205        .params
23206        .get("session_id")
23207        .and_then(Value::as_str)
23208        .map(str::to_string)
23209        .unwrap_or_else(|| format!("chat-{}", uuid::Uuid::new_v4().simple()));
23210    let stream = req
23211        .params
23212        .get("stream")
23213        .and_then(Value::as_bool)
23214        .unwrap_or(true);
23215    let voice_input = req
23216        .params
23217        .get("voice_input")
23218        .and_then(Value::as_bool)
23219        .unwrap_or(false);
23220    let model = extract_chat_model(&req.params)?;
23221
23222    // Optional image attachments (ContentBlock image variants). The
23223    // daemon is a pass-through here — it validates the shape lightly and
23224    // forwards them verbatim to the agent's `agent.chat` handler, which
23225    // hands them to inference as `imagesJson`.
23226    let attachments = extract_chat_attachments(&req.params)?;
23227    let inline_goal = extract_chat_goal(&req.params)?;
23228    let stored_goal = if inline_goal.is_none() {
23229        state
23230            .chat_goals
23231            .lock()
23232            .await
23233            .get(&session_id)
23234            .map(chat_goal_state_to_wire_goal)
23235    } else {
23236        None
23237    };
23238    let goal = inline_goal.or(stored_goal);
23239    if goal.is_some() && attachments.is_some() {
23240        return Err(
23241            "`agents.chat` `goal` currently cannot be combined with `attachments`".to_string(),
23242        );
23243    }
23244    let has_attached_agent = state.attached_agents.lock().await.contains_key(&agent_id);
23245    let declarative_spec = if has_attached_agent {
23246        None
23247    } else {
23248        state.declagents().ok().and_then(|reg| reg.get(&agent_id))
23249    };
23250
23251    // Same admission `agents.message` applies. Without it this method was the
23252    // way around that one: an agent denied at the read-only tier could reach
23253    // another agent by calling `agents.chat` instead, and two agents could
23254    // answer each other with nothing to terminate the loop. The host is exempt
23255    // inside the helper — it is the operator's own client.
23256    //
23257    // Placed AFTER the agent is known to exist, and that ordering is
23258    // load-bearing twice over. The guard records the (sender, body) pair on
23259    // admit, so admitting first meant an unroutable `agent_id` burned the
23260    // dedupe window and the caller's honest retry came back "already
23261    // delivered" — false, and it points away from the only thing that would
23262    // work. It also meant every caller-supplied string minted a permanent
23263    // `DeliveryGuard` in a map only a disconnecting bound agent ever prunes.
23264    // `handle_agents_message` never had either problem because it resolves the
23265    // directory before the guard; this now matches it.
23266    if !has_attached_agent && declarative_spec.is_none() {
23267        return Err(format!(
23268            "`{agent_id}` is not attached to this daemon and is not a declarative agent"
23269        ));
23270    }
23271    let chat_principal = session_principal_for_peers(host_session).await;
23272    {
23273        let sender_agent = host_session.agent_id.lock().await.clone();
23274        let is_host = host_session
23275            .is_host
23276            .load(std::sync::atomic::Ordering::Acquire);
23277        crate::peers::admit_turn(
23278            &state.clone(),
23279            &chat_principal,
23280            sender_agent,
23281            is_host,
23282            &agent_id,
23283            &prompt,
23284        )
23285        .await?;
23286    }
23287
23288    if !has_attached_agent {
23289        if let Some(spec) = declarative_spec {
23290            return handle_declarative_agents_chat(
23291                state,
23292                host_session,
23293                spec,
23294                prompt,
23295                session_id,
23296                stream,
23297                model,
23298                attachments,
23299                goal,
23300            )
23301            .await;
23302        }
23303    }
23304    // Resolve the agent's attached WS channel via `attached_agents` →
23305    // `sessions` → `channel`. Both lookups must hit; a missing entry on
23306    // either side means the agent is registered in `agents.json` but
23307    // hasn't `session.auth`'d (or has disconnected), so refuse with a
23308    // structured error rather than silently parking the chat.
23309    // A turn admitted above but not delivered here must give back its dedupe
23310    // record: the agent was attached when we checked and is gone now, so the
23311    // caller's immediate retry is a legitimate retry, not the loop the dedupe
23312    // window exists to break. The rate budget is deliberately not refunded —
23313    // the attempt did cost the channel something.
23314    let resolved = async {
23315        let agent_client_id = state
23316            .attached_agents
23317            .lock()
23318            .await
23319            .get(&agent_id)
23320            .cloned()
23321            .ok_or_else(|| {
23322                format!(
23323                    "agent `{}` is not attached to this daemon — supervisor may have it stopped, or it hasn't called session.auth yet",
23324                    agent_id
23325                )
23326            })?;
23327        let sessions = state.sessions.lock().await;
23328        sessions
23329            .get(&agent_client_id)
23330            .map(|s| s.channel.clone())
23331            .ok_or_else(|| {
23332                format!(
23333                    "agent `{}` client_id `{}` not found in session registry (raced with disconnect)",
23334                    agent_id, agent_client_id
23335                )
23336            })
23337    }
23338    .await;
23339    let agent_channel = match resolved {
23340        Ok(ch) => ch,
23341        Err(e) => {
23342            crate::peers::forget_synchronous_turn(state, &chat_principal, &agent_id, &prompt).await;
23343            return Err(e);
23344        }
23345    };
23346
23347    // Record who drove this turn. See `crate::peers::append_agent_chat_audit`
23348    // for why this lands here in the same change as `agents.message`.
23349    crate::peers::append_agent_chat_audit(
23350        state,
23351        &session_principal(host_session).await,
23352        &agent_id,
23353        &session_id,
23354    );
23355
23356    if let Some(goal_value) = &goal {
23357        ensure_chat_goal_running(state, &session_id, goal_value).await?;
23358    }
23359
23360    // Register the chat session BEFORE sending the reverse call so any
23361    // `agent.chat.event` notifications the agent sends as part of
23362    // accepting the chat (e.g. an immediate `token` delta) route
23363    // correctly. Indexed by session_id so the notification interceptor
23364    // can locate the originating host without scanning.
23365    {
23366        let created_at = std::time::SystemTime::now()
23367            .duration_since(std::time::UNIX_EPOCH)
23368            .map(|d| d.as_secs())
23369            .unwrap_or(0);
23370        state.chat_sessions.lock().await.insert(
23371            session_id.clone(),
23372            crate::session::ChatSession {
23373                agent_id: agent_id.clone(),
23374                host_client_id: host_session.client_id.clone(),
23375                created_at,
23376                local_cancel: None,
23377            },
23378        );
23379    }
23380
23381    // Reverse-callback: register a oneshot for the ack, send the
23382    // `agent.chat` JSON-RPC request on the agent's channel, await up
23383    // to AGENT_CHAT_ACK_TIMEOUT_SECS. Uses the same `pending` map the
23384    // tool-callback path uses (`WsToolExecutor`) — the dispatcher's
23385    // response demuxer at the top of `run_dispatch` already routes
23386    // `result` / `error` frames keyed by request id back through it.
23387    let request_id = agent_channel.next_request_id();
23388    let (tx, rx) = oneshot::channel();
23389    agent_channel
23390        .pending
23391        .lock()
23392        .await
23393        .insert(request_id.clone(), tx);
23394
23395    let chat_params = build_agent_chat_params(
23396        &session_id,
23397        &prompt,
23398        stream,
23399        &host_session.client_id,
23400        voice_input,
23401        model,
23402        attachments,
23403        goal,
23404    );
23405    let rpc_request = serde_json::json!({
23406        "jsonrpc": "2.0",
23407        "method": "agent.chat",
23408        "params": chat_params,
23409        "id": request_id,
23410    });
23411    let msg = Message::Text(
23412        serde_json::to_string(&rpc_request)
23413            .map_err(|e| e.to_string())?
23414            .into(),
23415    );
23416    if let Err(e) = agent_channel.write.lock().await.send(msg).await {
23417        // Send failed — drop the pending waiter and the chat session
23418        // entry so a retry can take a fresh session_id without
23419        // colliding.
23420        agent_channel.pending.lock().await.remove(&request_id);
23421        state.chat_sessions.lock().await.remove(&session_id);
23422        update_chat_goal_from_event(
23423            state,
23424            &session_id,
23425            &serde_json::json!({
23426                "kind": "error",
23427                "error": format!("failed to deliver agent.chat to `{}`: {}", agent_id, e),
23428            }),
23429        )
23430        .await;
23431        return Err(format!(
23432            "failed to deliver agent.chat to `{}`: {}",
23433            agent_id, e
23434        ));
23435    }
23436
23437    // Await the agent's ack. The dispatcher's response demuxer routes
23438    // the result/error back via the oneshot. Timeout means the agent
23439    // is alive but unresponsive — clean up routing state and surface a
23440    // structured error so the host UI doesn't hang.
23441    let ack = match tokio::time::timeout(
23442        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
23443        rx,
23444    )
23445    .await
23446    {
23447        Ok(Ok(resp)) => resp,
23448        Ok(Err(_)) => {
23449            // Channel closed — agent disconnected mid-call.
23450            state.chat_sessions.lock().await.remove(&session_id);
23451            update_chat_goal_from_event(
23452                state,
23453                &session_id,
23454                &serde_json::json!({
23455                    "kind": "error",
23456                    "error": format!("agent `{}` disconnected before acking agents.chat", agent_id),
23457                }),
23458            )
23459            .await;
23460            return Err(format!(
23461                "agent `{}` disconnected before acking agents.chat",
23462                agent_id
23463            ));
23464        }
23465        Err(_) => {
23466            // Timeout — agent didn't respond in time. Don't keep the
23467            // chat session around: any later events from the agent
23468            // would route to a host that already returned an error.
23469            agent_channel.pending.lock().await.remove(&request_id);
23470            state.chat_sessions.lock().await.remove(&session_id);
23471            update_chat_goal_from_event(
23472                state,
23473                &session_id,
23474                &serde_json::json!({
23475                    "kind": "error",
23476                    "error": format!(
23477                        "agent `{}` did not ack agents.chat within {}s",
23478                        agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
23479                    ),
23480                }),
23481            )
23482            .await;
23483            return Err(format!(
23484                "agent `{}` did not ack agents.chat within {}s",
23485                agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
23486            ));
23487        }
23488    };
23489
23490    if let Some(err) = ack.error {
23491        // Agent explicitly rejected — drop the session and propagate.
23492        state.chat_sessions.lock().await.remove(&session_id);
23493        update_chat_goal_from_event(
23494            state,
23495            &session_id,
23496            &serde_json::json!({
23497                "kind": "error",
23498                "error": format!("agent `{}` rejected chat: {}", agent_id, err),
23499            }),
23500        )
23501        .await;
23502        return Err(format!("agent `{}` rejected chat: {}", agent_id, err));
23503    }
23504
23505    Ok(serde_json::json!({
23506        "accepted": true,
23507        "session_id": session_id,
23508    }))
23509}
23510
23511async fn handle_declarative_agents_chat(
23512    state: &Arc<ServerState>,
23513    host_session: &Arc<crate::session::ClientSession>,
23514    spec: car_registry::declarative::DeclarativeAgentSpec,
23515    prompt: String,
23516    session_id: String,
23517    _stream: bool,
23518    model: Option<String>,
23519    attachments: Option<Value>,
23520    goal: Option<Value>,
23521) -> Result<Value, String> {
23522    if attachments.is_some() {
23523        return Err("declarative agents do not support `agents.chat` attachments".to_string());
23524    }
23525    if goal.is_some() {
23526        return Err(
23527            "`agents.chat` inline/standing goals are only supported by attached chat agents; \
23528             declarative agents use their manifest `goal`"
23529                .to_string(),
23530        );
23531    }
23532    if !spec.enabled {
23533        return Err(format!("agent '{}' is disabled", spec.id));
23534    }
23535
23536    let local_cancel = Arc::new(AtomicBool::new(false));
23537    {
23538        let created_at = std::time::SystemTime::now()
23539            .duration_since(std::time::UNIX_EPOCH)
23540            .map(|d| d.as_secs())
23541            .unwrap_or(0);
23542        state.chat_sessions.lock().await.insert(
23543            session_id.clone(),
23544            crate::session::ChatSession {
23545                agent_id: spec.id.clone(),
23546                host_client_id: host_session.client_id.clone(),
23547                created_at,
23548                local_cancel: Some(local_cancel.clone()),
23549            },
23550        );
23551    }
23552
23553    let state_for_task = state.clone();
23554    let sid = session_id.clone();
23555    tokio::spawn(async move {
23556        let result = crate::coder::rpc::run_declarative_with_cancel_and_model(
23557            &spec,
23558            &prompt,
23559            &state_for_task,
23560            Some(local_cancel.clone()),
23561            model,
23562        )
23563        .await;
23564        match result {
23565            Ok(run) => {
23566                if local_cancel.load(Ordering::SeqCst) {
23567                    send_external_chat_frame(
23568                        &state_for_task,
23569                        &sid,
23570                        serde_json::json!({
23571                            "session_id": sid.clone(),
23572                            "agent_id": spec.id.clone(),
23573                            "kind": "error",
23574                            "error": "cancelled",
23575                            "result": crate::coder::rpc::run_result_json(&run),
23576                        }),
23577                    )
23578                    .await;
23579                    state_for_task.chat_sessions.lock().await.remove(&sid);
23580                    return;
23581                }
23582                crate::coder::rpc::record_routing_outcome(&state_for_task, &spec.id, &run);
23583                if let Some(goal) = &run.goal {
23584                    send_external_chat_frame(
23585                        &state_for_task,
23586                        &sid,
23587                        serde_json::json!({
23588                            "session_id": sid.clone(),
23589                            "agent_id": spec.id.clone(),
23590                            "kind": "goal_evaluated",
23591                            "iteration": goal.iterations,
23592                            "met": goal.met,
23593                            "grounded": goal.grounded,
23594                            "reason": goal.last_reason,
23595                        }),
23596                    )
23597                    .await;
23598                }
23599                let result_json = crate::coder::rpc::run_result_json(&run);
23600                if let Some(err) = run.error.clone() {
23601                    send_external_chat_frame(
23602                        &state_for_task,
23603                        &sid,
23604                        serde_json::json!({
23605                            "session_id": sid.clone(),
23606                            "agent_id": spec.id.clone(),
23607                            "kind": "error",
23608                            "error": err,
23609                            "result": result_json,
23610                        }),
23611                    )
23612                    .await;
23613                } else {
23614                    let text = run.output.clone();
23615                    if !text.is_empty() {
23616                        send_external_chat_frame(
23617                            &state_for_task,
23618                            &sid,
23619                            serde_json::json!({
23620                                "session_id": sid.clone(),
23621                                "agent_id": spec.id.clone(),
23622                                "kind": "token",
23623                                "delta": text,
23624                            }),
23625                        )
23626                        .await;
23627                    }
23628                    send_external_chat_frame(
23629                        &state_for_task,
23630                        &sid,
23631                        serde_json::json!({
23632                            "session_id": sid.clone(),
23633                            "agent_id": spec.id.clone(),
23634                            "kind": "done",
23635                            "text": text,
23636                            "finish_reason": "declarative agent completed",
23637                            "result": result_json,
23638                        }),
23639                    )
23640                    .await;
23641                }
23642            }
23643            Err(e) => {
23644                send_external_chat_frame(
23645                    &state_for_task,
23646                    &sid,
23647                    serde_json::json!({
23648                        "session_id": sid.clone(),
23649                        "agent_id": spec.id.clone(),
23650                        "kind": "error",
23651                        "error": e,
23652                    }),
23653                )
23654                .await;
23655            }
23656        }
23657        state_for_task.chat_sessions.lock().await.remove(&sid);
23658    });
23659
23660    Ok(serde_json::json!({
23661        "accepted": true,
23662        "session_id": session_id,
23663        "in_daemon": true,
23664    }))
23665}
23666
23667/// Overall deadline for an A2A conversational turn: how long [`chat_collect`]
23668/// waits for the host agent to finish streaming after it acks. Generous (the
23669/// host may run several tool round-trips) but bounded so an A2A task can't hang
23670/// forever on a wedged agent.
23671const A2A_CHAT_TIMEOUT_SECS: u64 = 180;
23672
23673/// Reverse-call `agent.chat` on `host_channel` and aggregate the streamed
23674/// `agent.chat.event` deltas into a single reply string. The A2A conversational
23675/// bridge uses this — it has no host UI to stream to, so it registers an
23676/// in-process collector (keyed by a fresh `session_id`) that
23677/// [`try_forward_agent_chat_event`] feeds, then accumulates `token` deltas until
23678/// the terminal `done`/`error`. Always removes its collector on the way out.
23679/// Principal recorded for a conversational A2A message routed to the host
23680/// agent's loop. One bucket, not one per remote caller: the `a2a.start`
23681/// listener is `NoAuth`, so there is no verified caller to name — and inventing
23682/// a per-request identity from unauthenticated input would put a forgeable
23683/// string in the audit journal, which is worse than an honest single bucket.
23684pub(crate) const A2A_CONVERSATIONAL_PRINCIPAL: &str = "a2a:conversational";
23685
23686pub(crate) async fn chat_collect(
23687    state: &Arc<ServerState>,
23688    host_channel: &Arc<crate::session::WsChannel>,
23689    host_client_id: &str,
23690    prompt: &str,
23691) -> Result<String, String> {
23692    use futures::SinkExt;
23693    use tokio::sync::{mpsc, oneshot};
23694    use tokio_tungstenite::tungstenite::Message;
23695
23696    // This sends the same `agent.chat` frame as `handle_agents_chat` and had
23697    // none of its admission — no guard, no policy, no audit — while being
23698    // reachable from the `a2a.start` listener, which serves NO authentication.
23699    // Gating the *bind* (loopback unless `allow_non_loopback_bind`) bounds who
23700    // can reach it; this bounds what they can do once they have. There is no
23701    // agent principal to grade — the caller is a remote party the operator
23702    // deliberately exposed with `share_session_runtime` — so the channel guard
23703    // is the whole admission here, which is what stops a remote peer looping or
23704    // flooding the host agent's turns.
23705    crate::peers::admit_turn(
23706        state,
23707        A2A_CONVERSATIONAL_PRINCIPAL,
23708        None,
23709        false,
23710        host_client_id,
23711        prompt,
23712    )
23713    .await?;
23714
23715    let session_id = format!("a2a-chat-{}", uuid::Uuid::new_v4().simple());
23716    let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::<crate::session::ChatStreamChunk>();
23717    state.chat_collectors.lock().await.insert(
23718        session_id.clone(),
23719        crate::session::ChatCollector {
23720            tx: chunk_tx,
23721            host_client_id: host_client_id.to_string(),
23722        },
23723    );
23724
23725    // Ack round-trip reuses the tools.execute pending-response demuxer.
23726    let request_id = host_channel.next_request_id();
23727    let (ack_tx, ack_rx) = oneshot::channel();
23728    host_channel
23729        .pending
23730        .lock()
23731        .await
23732        .insert(request_id.clone(), ack_tx);
23733
23734    let req = serde_json::json!({
23735        "jsonrpc": "2.0",
23736        "method": "agent.chat",
23737        "params": {
23738            "session_id": session_id,
23739            "prompt": prompt,
23740            "stream": true,
23741            "context": { "source": "a2a" },
23742        },
23743        "id": request_id,
23744    });
23745    if let Err(e) = host_channel
23746        .write
23747        .lock()
23748        .await
23749        .send(Message::Text(
23750            serde_json::to_string(&req).unwrap_or_default().into(),
23751        ))
23752        .await
23753    {
23754        host_channel.pending.lock().await.remove(&request_id);
23755        state.chat_collectors.lock().await.remove(&session_id);
23756        return Err(format!("failed to deliver agent.chat to host: {e}"));
23757    }
23758
23759    // Wait for the host to ack (same 5s budget as agents.chat).
23760    let ack = tokio::time::timeout(
23761        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
23762        ack_rx,
23763    )
23764    .await;
23765    match ack {
23766        Ok(Ok(resp)) => {
23767            if let Some(err) = resp.error {
23768                state.chat_collectors.lock().await.remove(&session_id);
23769                return Err(format!("host agent rejected chat: {err}"));
23770            }
23771        }
23772        Ok(Err(_)) => {
23773            state.chat_collectors.lock().await.remove(&session_id);
23774            return Err("host agent disconnected before acking agent.chat".to_string());
23775        }
23776        Err(_) => {
23777            host_channel.pending.lock().await.remove(&request_id);
23778            state.chat_collectors.lock().await.remove(&session_id);
23779            return Err(format!(
23780                "host agent did not ack agent.chat within {AGENT_CHAT_ACK_TIMEOUT_SECS}s"
23781            ));
23782        }
23783    }
23784
23785    // Aggregate streamed deltas until the terminal event or the overall timeout.
23786    let collect = async {
23787        let mut text = String::new();
23788        while let Some(chunk) = chunk_rx.recv().await {
23789            match chunk.kind.as_str() {
23790                "token" => {
23791                    if let Some(d) = chunk.delta {
23792                        text.push_str(&d);
23793                    }
23794                }
23795                "done" => return Ok(text),
23796                "error" => {
23797                    return Err(chunk
23798                        .error
23799                        .unwrap_or_else(|| "host agent error".to_string()));
23800                }
23801                _ => {}
23802            }
23803        }
23804        Err("agent.chat stream ended before completion".to_string())
23805    };
23806    let result = tokio::time::timeout(
23807        std::time::Duration::from_secs(A2A_CHAT_TIMEOUT_SECS),
23808        collect,
23809    )
23810    .await;
23811    state.chat_collectors.lock().await.remove(&session_id);
23812    match result {
23813        Ok(r) => r,
23814        Err(_) => Err(format!(
23815            "host agent did not complete the reply within {A2A_CHAT_TIMEOUT_SECS}s"
23816        )),
23817    }
23818}
23819
23820/// [`car_a2a::ChatResponder`] backed by the WS session that started the A2A
23821/// listener: a conversational A2A `message/send` reverse-calls `agent.chat` on
23822/// that session and returns the host agent's aggregated reply. Holds a `Weak`
23823/// to `ServerState` so a dropped daemon doesn't keep it alive, and resolves the
23824/// host channel fresh each call so a reconnect/disconnect surfaces as an error.
23825pub(crate) struct WsChatResponder {
23826    pub state: std::sync::Weak<ServerState>,
23827    pub host_client_id: String,
23828}
23829
23830#[async_trait::async_trait]
23831impl car_a2a::ChatResponder for WsChatResponder {
23832    async fn respond(&self, prompt: &str) -> Result<String, String> {
23833        let state = self
23834            .state
23835            .upgrade()
23836            .ok_or_else(|| "daemon is shutting down".to_string())?;
23837        let host_channel = {
23838            let sessions = state.sessions.lock().await;
23839            sessions
23840                .get(&self.host_client_id)
23841                .map(|s| s.channel.clone())
23842        }
23843        .ok_or_else(|| "host session for the A2A listener has disconnected".to_string())?;
23844        chat_collect(&state, &host_channel, &self.host_client_id, prompt).await
23845    }
23846}
23847
23848/// `agents.chat.cancel` — host aborts an in-flight chat. Forwards
23849/// `agent.chat.cancel` to the bound agent so the agent can short-
23850/// circuit its inference stream + free upstream resources
23851/// (`inference.stream.cancel`). The chat session is dropped from
23852/// routing state immediately whether or not the agent acks the cancel
23853/// — further `agent.chat.event` notifications for this session_id
23854/// fall on the floor by design.
23855async fn handle_agents_chat_cancel(
23856    req: &JsonRpcMessage,
23857    state: &Arc<ServerState>,
23858    caller_client_id: &str,
23859    caller_is_host: bool,
23860) -> Result<Value, String> {
23861    use futures::SinkExt;
23862    use tokio_tungstenite::tungstenite::Message;
23863
23864    let session_id = req
23865        .params
23866        .get("session_id")
23867        .and_then(Value::as_str)
23868        .ok_or_else(|| "`agents.chat.cancel` requires `session_id`".to_string())?
23869        .to_string();
23870
23871    // Same ownership check `agents.chat.approve` applies one arm over. Without
23872    // it any authenticated session could abort any live turn by guessing or
23873    // observing a `session_id` — the caller was authenticated, and that was
23874    // taken to settle what it may do. Look up before removing, so a caller that
23875    // fails the check does not destroy the routing entry on its way out.
23876    {
23877        let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
23878        if let Some(chat) = chat {
23879            if !caller_is_host && caller_client_id != chat.host_client_id {
23880                return Err(
23881                    "`agents.chat.cancel` requires the originating host session or \
23882                     host-management role"
23883                        .to_string(),
23884                );
23885            }
23886        }
23887    }
23888
23889    let chat = state.chat_sessions.lock().await.remove(&session_id);
23890    let chat = match chat {
23891        Some(c) => c,
23892        None => {
23893            // Already cancelled or never existed — idempotent.
23894            return Ok(serde_json::json!({ "cancelled": false, "reason": "unknown session_id" }));
23895        }
23896    };
23897    if let Some(flag) = &chat.local_cancel {
23898        flag.store(true, Ordering::SeqCst);
23899    }
23900    update_chat_goal_from_event(
23901        state,
23902        &session_id,
23903        &serde_json::json!({
23904            "kind": "error",
23905            "error": "cancelled",
23906        }),
23907    )
23908    .await;
23909
23910    // Best-effort fire-and-forget to the agent. We've already removed
23911    // the routing entry, so no need to await any agent response.
23912    let agent_client_id = state
23913        .attached_agents
23914        .lock()
23915        .await
23916        .get(&chat.agent_id)
23917        .cloned();
23918    if let Some(client_id) = agent_client_id {
23919        let channel_opt = {
23920            let sessions = state.sessions.lock().await;
23921            sessions.get(&client_id).map(|s| s.channel.clone())
23922        };
23923        if let Some(channel) = channel_opt {
23924            let notification = serde_json::json!({
23925                "jsonrpc": "2.0",
23926                "method": "agent.chat.cancel",
23927                "params": { "session_id": session_id },
23928            });
23929            if let Ok(text) = serde_json::to_string(&notification) {
23930                let _ = channel
23931                    .write
23932                    .lock()
23933                    .await
23934                    .send(Message::Text(text.into()))
23935                    .await;
23936            }
23937        }
23938    }
23939
23940    Ok(serde_json::json!({ "cancelled": true, "session_id": session_id }))
23941}
23942
23943/// Resolve a chat-surface human-in-the-loop approval (`agents.chat.approve`).
23944///
23945/// The mirror of [`handle_agents_chat_cancel`], but for the `approval_pending`
23946/// round-trip: an agent parked a gated action awaiting a decision (emitting an
23947/// `approval_pending` event with an `approval_id`); a host renders Approve/Deny
23948/// and calls this. We route the decision to the agent's own
23949/// `agent.chat.approve` handler (which resolves the parked oneshot so the turn
23950/// resumes), keyed by the live `chat_sessions` map — the SAME routing
23951/// `agents.chat`/`agents.chat.cancel` use. Unlike cancel, we do NOT drop the
23952/// chat session: the turn continues after the approval.
23953///
23954/// Params: `session_id` (required, routes to the agent), `approval_id`
23955/// (required, identifies the parked action), `decision` (bool, or one of
23956/// `"approve"`/`"approved"`/`"yes"` vs anything else). Forwarded verbatim.
23957///
23958/// This is a reverse *request* (not fire-and-forget like cancel): the agent's
23959/// handler returns `{ resolved: bool }`, which we await (bounded) and relay so
23960/// the host gets a definitive answer rather than guessing from the stream.
23961async fn handle_agents_chat_approve(
23962    req: &JsonRpcMessage,
23963    state: &Arc<ServerState>,
23964    caller_client_id: &str,
23965    caller_is_host: bool,
23966) -> Result<Value, String> {
23967    use futures::SinkExt;
23968    use tokio::sync::oneshot;
23969    use tokio_tungstenite::tungstenite::Message;
23970
23971    let session_id = req
23972        .params
23973        .get("session_id")
23974        .and_then(Value::as_str)
23975        .ok_or_else(|| "`agents.chat.approve` requires `session_id`".to_string())?
23976        .to_string();
23977    let approval_id = req
23978        .params
23979        .get("approval_id")
23980        .and_then(Value::as_str)
23981        .ok_or_else(|| "`agents.chat.approve` requires `approval_id`".to_string())?
23982        .to_string();
23983    // `decision` is forwarded verbatim — the agent-side handler accepts a bool
23984    // or a string (`"approve"`/`"approved"`/`"yes"` → approved). Default to a
23985    // negative bool if omitted so an unspecified decision can never approve.
23986    let decision = req
23987        .params
23988        .get("decision")
23989        .cloned()
23990        .unwrap_or(Value::Bool(false));
23991
23992    // Look up (do NOT remove) the chat session so the turn keeps routing after
23993    // the approval resolves.
23994    let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
23995    let chat = chat.ok_or_else(|| {
23996        format!("`agents.chat.approve`: unknown or already-finished session_id `{session_id}`")
23997    })?;
23998    if !caller_is_host && caller_client_id != chat.host_client_id {
23999        return Err(
24000            "`agents.chat.approve` requires the originating host session or host-management role"
24001                .to_string(),
24002        );
24003    }
24004
24005    // Resolve the agent's attached WS channel, same as agents.chat.
24006    let agent_client_id = state
24007        .attached_agents
24008        .lock()
24009        .await
24010        .get(&chat.agent_id)
24011        .cloned()
24012        .ok_or_else(|| {
24013            format!(
24014                "agent `{}` is not attached to this daemon (raced with disconnect)",
24015                chat.agent_id
24016            )
24017        })?;
24018    let agent_channel = {
24019        let sessions = state.sessions.lock().await;
24020        sessions
24021            .get(&agent_client_id)
24022            .map(|s| s.channel.clone())
24023            .ok_or_else(|| {
24024                format!(
24025                    "agent `{}` client_id `{}` not found in session registry",
24026                    chat.agent_id, agent_client_id
24027                )
24028            })?
24029    };
24030
24031    // Reverse-request `agent.chat.approve` and await the agent's `{resolved}`
24032    // reply via the same `pending` demuxer the ack path uses.
24033    let request_id = agent_channel.next_request_id();
24034    let (tx, rx) = oneshot::channel();
24035    agent_channel
24036        .pending
24037        .lock()
24038        .await
24039        .insert(request_id.clone(), tx);
24040
24041    let rpc_request = serde_json::json!({
24042        "jsonrpc": "2.0",
24043        "method": "agent.chat.approve",
24044        "params": { "approval_id": approval_id, "decision": decision },
24045        "id": request_id,
24046    });
24047    let msg = Message::Text(
24048        serde_json::to_string(&rpc_request)
24049            .map_err(|e| e.to_string())?
24050            .into(),
24051    );
24052    if let Err(e) = agent_channel.write.lock().await.send(msg).await {
24053        agent_channel.pending.lock().await.remove(&request_id);
24054        return Err(format!(
24055            "failed to deliver agent.chat.approve to `{}`: {}",
24056            chat.agent_id, e
24057        ));
24058    }
24059
24060    let ack = match tokio::time::timeout(
24061        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
24062        rx,
24063    )
24064    .await
24065    {
24066        Ok(Ok(resp)) => resp,
24067        Ok(Err(_)) => {
24068            return Err(format!(
24069                "agent `{}` disconnected before acking agents.chat.approve",
24070                chat.agent_id
24071            ));
24072        }
24073        Err(_) => {
24074            agent_channel.pending.lock().await.remove(&request_id);
24075            return Err(format!(
24076                "agent `{}` did not ack agents.chat.approve within {}s",
24077                chat.agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24078            ));
24079        }
24080    };
24081    if let Some(err) = ack.error {
24082        return Err(format!(
24083            "agent `{}` rejected agents.chat.approve: {}",
24084            chat.agent_id, err
24085        ));
24086    }
24087
24088    // Relay the agent's `{resolved: bool}` result; default false if the agent
24089    // returned an unexpected shape.
24090    let resolved = ack
24091        .output
24092        .as_ref()
24093        .and_then(|r| r.get("resolved"))
24094        .and_then(Value::as_bool)
24095        .unwrap_or(false);
24096    Ok(serde_json::json!({ "resolved": resolved, "session_id": session_id }))
24097}
24098
24099async fn update_chat_goal_from_event(state: &Arc<ServerState>, session_id: &str, params: &Value) {
24100    let Some(kind) = params.get("kind").and_then(Value::as_str) else {
24101        return;
24102    };
24103    let changed = {
24104        let mut goals = state.chat_goals.lock().await;
24105        let Some(goal) = goals.get_mut(session_id) else {
24106            return;
24107        };
24108        match kind {
24109            "goal_evaluated" => {
24110                goal.last_iteration = params
24111                    .get("iteration")
24112                    .and_then(Value::as_u64)
24113                    .and_then(|n| u32::try_from(n).ok());
24114                goal.last_met = params.get("met").and_then(Value::as_bool);
24115                goal.last_grounded = params.get("grounded").and_then(Value::as_bool);
24116                goal.last_reason = params
24117                    .get("reason")
24118                    .and_then(Value::as_str)
24119                    .map(str::to_string);
24120                goal.status = if goal.last_met == Some(true) && goal.last_grounded == Some(true) {
24121                    "met".to_string()
24122                } else {
24123                    "running".to_string()
24124                };
24125                goal.updated_at = unix_secs_now();
24126                true
24127            }
24128            "done" | "error" => {
24129                goal.terminal_kind = Some(kind.to_string());
24130                goal.terminal_message = params
24131                    .get("finish_reason")
24132                    .or_else(|| params.get("error"))
24133                    .and_then(Value::as_str)
24134                    .map(str::to_string);
24135                // car#1113 review: a fail-open `done` (the goal check never
24136                // got the chance to run — see `chat::goal_turn_terminal_event`
24137                // / `GoalHalt::EvaluationTimeout`) must not be recorded as
24138                // `"met"`. `finish_reason` alone isn't enough to prevent that
24139                // — it lands in `terminal_message`, a secondary prose field,
24140                // while `status` is the one `docs/websocket-protocol.md`
24141                // documents as what a `goal.status` poller actually reads.
24142                // `goal_unevaluated` is the machine-readable marker
24143                // `goal_turn_terminal_event` sets for exactly this case.
24144                let unevaluated = params
24145                    .get("goal_unevaluated")
24146                    .and_then(Value::as_bool)
24147                    .unwrap_or(false);
24148                goal.status = if kind == "error" {
24149                    "error".to_string()
24150                } else if unevaluated {
24151                    "unevaluated".to_string()
24152                } else {
24153                    "met".to_string()
24154                };
24155                goal.updated_at = unix_secs_now();
24156                true
24157            }
24158            _ => false,
24159        }
24160    };
24161    if changed {
24162        if let Err(e) = state.persist_chat_goals().await {
24163            tracing::warn!(
24164                session_id,
24165                error = %e,
24166                "failed to persist chat goal status update"
24167            );
24168        }
24169    }
24170}
24171
24172/// Forward an `agent.chat.event` notification from an agent's
24173/// connection to the originating host's connection, rewritten as an
24174/// `agents.chat.event` notification. Returns `true` if the inbound
24175/// frame was a chat-event we routed (so the dispatcher can `continue`
24176/// past the normal method dispatch and skip the wasted "unknown
24177/// method" response), `false` otherwise.
24178///
24179/// Terminal events (`kind: "done"` / `"error"`) also drop the routing
24180/// entry from `state.chat_sessions` so a later stray notification can
24181/// be rejected as orphaned without leaking memory.
24182pub(crate) async fn try_forward_agent_chat_event(
24183    parsed: &JsonRpcMessage,
24184    state: &Arc<ServerState>,
24185) -> bool {
24186    use futures::SinkExt;
24187    use tokio_tungstenite::tungstenite::Message;
24188
24189    // Notification predicate: method is `agent.chat.event`, id is
24190    // missing/null (per JSON-RPC, notifications have no id), and
24191    // params carry a session_id.
24192    let Some(method) = parsed.method.as_deref() else {
24193        return false;
24194    };
24195    if method != "agent.chat.event" {
24196        return false;
24197    }
24198    if !parsed.id.is_null() {
24199        // Has an id → it's a request, not a notification. Let the
24200        // normal dispatcher handle it (and reply with method-not-found).
24201        return false;
24202    }
24203    let Some(session_id) = parsed.params.get("session_id").and_then(Value::as_str) else {
24204        return false;
24205    };
24206    let session_id = session_id.to_string();
24207
24208    // In-process collector path (the A2A conversational bridge has no host UI
24209    // to stream to). If a collector is registered for this session, feed it the
24210    // normalized chunk and consume the event — the collecting task owns the
24211    // collector's lifetime, so we don't touch chat_sessions or remove it here.
24212    let collector = state
24213        .chat_collectors
24214        .lock()
24215        .await
24216        .get(&session_id)
24217        .map(|c| c.tx.clone());
24218    if let Some(tx) = collector {
24219        let kind = parsed
24220            .params
24221            .get("kind")
24222            .and_then(Value::as_str)
24223            .map(str::to_string)
24224            .unwrap_or_else(|| {
24225                if parsed.params.get("error").is_some() {
24226                    "error".to_string()
24227                } else if parsed.params.get("finish_reason").is_some() {
24228                    "done".to_string()
24229                } else {
24230                    "token".to_string()
24231                }
24232            });
24233        let delta = parsed
24234            .params
24235            .get("delta")
24236            .and_then(Value::as_str)
24237            .map(str::to_string);
24238        let error = parsed
24239            .params
24240            .get("error")
24241            .and_then(Value::as_str)
24242            .map(str::to_string);
24243        let _ = tx.send(crate::session::ChatStreamChunk { kind, delta, error });
24244        return true;
24245    }
24246
24247    // Look up the routing entry. If gone (cancelled, agent dropped,
24248    // disconnect cleanup), drop the event silently — late frames from
24249    // a respawned agent for a stale session are not the host's
24250    // problem.
24251    let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24252    let Some(chat) = chat else {
24253        return true; // recognized the method, but routing has dropped — consumed.
24254    };
24255
24256    // Pull the kind early so terminal-event cleanup runs even if the
24257    // host's send fails. Agents may omit `kind` and signal terminal
24258    // state via `finish_reason` / `error` instead (car#222) — derive
24259    // it from the frame shape so both the cleanup below AND the host
24260    // see a correct, host-protocol-compliant kind. The old code
24261    // defaulted a `finish_reason`-only "done" frame to "token", so
24262    // terminal cleanup never ran and the host (which requires `kind`)
24263    // dropped every frame silently.
24264    let kind = parsed
24265        .params
24266        .get("kind")
24267        .and_then(Value::as_str)
24268        .map(str::to_string)
24269        .unwrap_or_else(|| {
24270            if parsed.params.get("error").is_some() {
24271                "error".to_string()
24272            } else if parsed.params.get("finish_reason").is_some() {
24273                "done".to_string()
24274            } else {
24275                "token".to_string()
24276            }
24277        });
24278    update_chat_goal_from_event(
24279        state,
24280        &session_id,
24281        &serde_json::json!({
24282            "kind": kind.clone(),
24283            "finish_reason": parsed.params.get("finish_reason").cloned().unwrap_or(Value::Null),
24284            "error": parsed.params.get("error").cloned().unwrap_or(Value::Null),
24285            "iteration": parsed.params.get("iteration").cloned().unwrap_or(Value::Null),
24286            "met": parsed.params.get("met").cloned().unwrap_or(Value::Null),
24287            "grounded": parsed.params.get("grounded").cloned().unwrap_or(Value::Null),
24288            "reason": parsed.params.get("reason").cloned().unwrap_or(Value::Null),
24289        }),
24290    )
24291    .await;
24292
24293    // Forward to the host. Rewrites the method name to the host-facing
24294    // form and attaches `agent_id` so the host doesn't have to remember
24295    // which agent owns each session.
24296    let host_channel = {
24297        let sessions = state.sessions.lock().await;
24298        sessions
24299            .get(&chat.host_client_id)
24300            .map(|s| s.channel.clone())
24301    };
24302    if let Some(channel) = host_channel {
24303        let mut params = parsed.params.clone();
24304        if let Some(obj) = params.as_object_mut() {
24305            obj.insert("agent_id".to_string(), Value::String(chat.agent_id.clone()));
24306            // host-protocol.md requires a top-level `kind` on every
24307            // agents.chat.event. Agents that omit it (signalling via
24308            // finish_reason/error) were dropped wholesale by the host
24309            // decoder — normalize here so the contract holds. car#222.
24310            obj.entry("kind")
24311                .or_insert_with(|| Value::String(kind.clone()));
24312        }
24313        let forward = serde_json::json!({
24314            "jsonrpc": "2.0",
24315            "method": "agents.chat.event",
24316            "params": params,
24317        });
24318        if let Ok(text) = serde_json::to_string(&forward) {
24319            let send_result = channel
24320                .write
24321                .lock()
24322                .await
24323                .send(Message::Text(text.into()))
24324                .await;
24325            if let Err(e) = send_result {
24326                tracing::warn!(
24327                    session_id = %session_id,
24328                    agent_id = %chat.agent_id,
24329                    host_client_id = %chat.host_client_id,
24330                    kind = %kind,
24331                    error = %e,
24332                    "agent.chat.event forward to host failed at the WS send step"
24333                );
24334            }
24335        }
24336    } else {
24337        // Host disconnected mid-stream — chat_sessions still holds
24338        // the routing entry but the originating client_id no longer
24339        // resolves to a session. Pre-#233 this was silent and the
24340        // operator had no way to tell whether the event was dropped
24341        // here or never emitted by the agent. Log + drop the
24342        // routing entry so subsequent stray events are no-ops.
24343        tracing::warn!(
24344            session_id = %session_id,
24345            agent_id = %chat.agent_id,
24346            host_client_id = %chat.host_client_id,
24347            kind = %kind,
24348            "agent.chat.event from supervised agent had no host channel \
24349             (host disconnected since `agents.chat`); dropping routing entry"
24350        );
24351        state.chat_sessions.lock().await.remove(&session_id);
24352        return true;
24353    }
24354
24355    // Terminal-kind cleanup. The host_channel branch above already
24356    // forwarded the terminal event; we just remove the routing entry
24357    // here so subsequent stray frames are no-ops.
24358    if matches!(kind.as_str(), "done" | "error") {
24359        state.chat_sessions.lock().await.remove(&session_id);
24360    }
24361
24362    true
24363}
24364
24365#[cfg(test)]
24366mod credential_event_lag_regression {
24367    use super::{credential_fanout_test_gate, credential_handoff_test_gate, run_dispatch};
24368    use futures::{Sink, SinkExt, StreamExt};
24369    use std::pin::Pin;
24370    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
24371    use std::sync::Arc;
24372    use std::task::{Context, Poll};
24373    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24374
24375    const HOST_TOKEN: &str = "lag-test-host-token-cccccccccccccccccccc";
24376
24377    struct StallAfterTwoWrites {
24378        outbound: futures::channel::mpsc::UnboundedSender<Message>,
24379        sends: Arc<AtomicUsize>,
24380        stalled: Arc<AtomicBool>,
24381    }
24382
24383    impl Sink<Message> for StallAfterTwoWrites {
24384        type Error = WsError;
24385
24386        fn poll_ready(
24387            self: Pin<&mut Self>,
24388            _context: &mut Context<'_>,
24389        ) -> Poll<Result<(), Self::Error>> {
24390            if self.sends.load(Ordering::SeqCst) >= 2 {
24391                self.stalled.store(true, Ordering::SeqCst);
24392                Poll::Pending
24393            } else {
24394                Poll::Ready(Ok(()))
24395            }
24396        }
24397
24398        fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
24399            self.outbound
24400                .unbounded_send(message)
24401                .map_err(|_| WsError::ConnectionClosed)?;
24402            self.sends.fetch_add(1, Ordering::SeqCst);
24403            Ok(())
24404        }
24405
24406        fn poll_flush(
24407            self: Pin<&mut Self>,
24408            _context: &mut Context<'_>,
24409        ) -> Poll<Result<(), Self::Error>> {
24410            Poll::Ready(Ok(()))
24411        }
24412
24413        fn poll_close(
24414            self: Pin<&mut Self>,
24415            _context: &mut Context<'_>,
24416        ) -> Poll<Result<(), Self::Error>> {
24417            Poll::Ready(Ok(()))
24418        }
24419    }
24420
24421    fn request(id: &str, method: &str, params: serde_json::Value) -> Message {
24422        Message::Text(
24423            serde_json::json!({
24424                "jsonrpc": "2.0",
24425                "id": id,
24426                "method": method,
24427                "params": params,
24428            })
24429            .to_string()
24430            .into(),
24431        )
24432    }
24433
24434    #[tokio::test]
24435    async fn credential_event_queue_overflow_terminates_host_dispatch() {
24436        const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_LAG_CHILD";
24437        if std::env::var_os(SENTINEL).is_none() {
24438            let status = std::process::Command::new(std::env::current_exe().unwrap())
24439                .arg("--exact")
24440                .arg(
24441                    "handler::credential_event_lag_regression::credential_event_queue_overflow_terminates_host_dispatch",
24442                )
24443                .arg("--nocapture")
24444                .env(SENTINEL, "1")
24445                .env("CAR_NO_INFERENCE_WORKER", "1")
24446                .status()
24447                .expect("spawn isolated credential-event lag contract");
24448            assert!(
24449                status.success(),
24450                "isolated credential-event lag contract failed"
24451            );
24452            return;
24453        }
24454
24455        let temp = tempfile::TempDir::new().unwrap();
24456        let secrets = tempfile::TempDir::new().unwrap();
24457        std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
24458        std::env::set_var(car_home::ENV_VAR, temp.path());
24459        car_auth::commit_login(
24460            "https://api.example.test",
24461            &car_auth::TokenSet {
24462                access_token: "lag-test-access".into(),
24463                refresh_token: "lag-test-refresh".into(),
24464                expires_in: 3_600,
24465                token_type: "Bearer".into(),
24466            },
24467            r#"{"Account":{"Id":"lag-account","Email":"lag@example.test"}}"#,
24468            None,
24469        )
24470        .await
24471        .unwrap();
24472
24473        let state = Arc::new(crate::session::ServerState::standalone(
24474            temp.path().join("journal"),
24475        ));
24476        state
24477            .install_host_token(HOST_TOKEN.to_string())
24478            .expect("install host token");
24479
24480        let (inbound_tx, inbound_rx) =
24481            futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
24482        let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
24483        let sends = Arc::new(AtomicUsize::new(0));
24484        let credential_write_stalled = Arc::new(AtomicBool::new(false));
24485        let sink = StallAfterTwoWrites {
24486            outbound: outbound_tx,
24487            sends: Arc::clone(&sends),
24488            stalled: Arc::clone(&credential_write_stalled),
24489        };
24490        let write: crate::session::WsSink = Box::pin(sink);
24491        let dispatch_state = Arc::clone(&state);
24492        let mut dispatch = tokio::spawn(async move {
24493            run_dispatch(
24494                inbound_rx,
24495                write,
24496                "credential-lag-test".to_string(),
24497                dispatch_state,
24498            )
24499            .await
24500            .map_err(|error| error.to_string())
24501        });
24502
24503        inbound_tx
24504            .unbounded_send(Ok(request(
24505                "auth",
24506                "session.auth",
24507                serde_json::json!({ "host_token": HOST_TOKEN }),
24508            )))
24509            .unwrap();
24510        let auth_response = outbound_rx.next().await.expect("host auth response");
24511        assert!(auth_response
24512            .into_text()
24513            .unwrap()
24514            .contains("\"role\":\"host\""));
24515
24516        inbound_tx
24517            .unbounded_send(Ok(request(
24518                "handshake",
24519                "server.handshake",
24520                serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
24521            )))
24522            .unwrap();
24523        let handshake_response = outbound_rx.next().await.expect("handshake response");
24524        assert!(handshake_response.into_text().unwrap().contains(&format!(
24525            "\"protocol_version\":{}",
24526            car_proto::PROTOCOL_VERSION
24527        )));
24528
24529        car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24530            .await
24531            .expect("credential generation should resolve");
24532        for _ in 0..100 {
24533            if credential_write_stalled.load(Ordering::SeqCst) {
24534                break;
24535            }
24536            tokio::task::yield_now().await;
24537        }
24538        assert!(
24539            credential_write_stalled.load(Ordering::SeqCst),
24540            "the real host fanout must be stalled on its credential-event write"
24541        );
24542        for _ in 0..3 {
24543            car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24544                .await
24545                .expect("credential generation should resolve while host fanout is stalled");
24546        }
24547
24548        let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
24549            .await
24550            .expect("credential-event overflow must cancel the owning host connection")
24551            .expect("dispatcher task should join");
24552        assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
24553        assert!(
24554            state.sessions.lock().await.is_empty(),
24555            "a lagged host cannot remain connected but silently unsubscribed"
24556        );
24557    }
24558
24559    #[tokio::test]
24560    async fn preauth_activity_cannot_disconnect_a_later_eligible_host() {
24561        const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_PREAUTH_CHILD";
24562        if std::env::var_os(SENTINEL).is_none() {
24563            let status = std::process::Command::new(std::env::current_exe().unwrap())
24564                .arg("--exact")
24565                .arg(
24566                    "handler::credential_event_lag_regression::preauth_activity_cannot_disconnect_a_later_eligible_host",
24567                )
24568                .arg("--nocapture")
24569                .env(SENTINEL, "1")
24570                .env("CAR_NO_INFERENCE_WORKER", "1")
24571                .env("CAR_TEST_PAUSE_CREDENTIAL_FANOUT", "1")
24572                .status()
24573                .expect("spawn isolated pre-auth credential-event contract");
24574            assert!(status.success(), "isolated pre-auth contract failed");
24575            return;
24576        }
24577
24578        let temp = tempfile::TempDir::new().unwrap();
24579        let secrets = tempfile::TempDir::new().unwrap();
24580        std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
24581        std::env::set_var(car_home::ENV_VAR, temp.path());
24582        car_auth::commit_login(
24583            "https://api.example.test",
24584            &car_auth::TokenSet {
24585                access_token: "preauth-test-access".into(),
24586                refresh_token: "preauth-test-refresh".into(),
24587                expires_in: 3_600,
24588                token_type: "Bearer".into(),
24589            },
24590            r#"{"Account":{"Id":"preauth-account","Email":"preauth@example.test"}}"#,
24591            None,
24592        )
24593        .await
24594        .unwrap();
24595
24596        let state = Arc::new(crate::session::ServerState::standalone(
24597            temp.path().join("journal"),
24598        ));
24599        state
24600            .install_host_token(HOST_TOKEN.to_string())
24601            .expect("install host token");
24602
24603        let (inbound_tx, inbound_rx) =
24604            futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
24605        let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
24606        let write: crate::session::WsSink = Box::pin(
24607            outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
24608        );
24609        let dispatch_state = Arc::clone(&state);
24610        let mut dispatch = tokio::spawn(async move {
24611            run_dispatch(
24612                inbound_rx,
24613                write,
24614                "credential-preauth-test".to_string(),
24615                dispatch_state,
24616            )
24617            .await
24618            .map_err(|error| error.to_string())
24619        });
24620
24621        credential_fanout_test_gate().ready.notified().await;
24622        for _ in 0..3 {
24623            car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24624                .await
24625                .expect("pre-auth credential generation should resolve");
24626        }
24627
24628        inbound_tx
24629            .unbounded_send(Ok(request(
24630                "auth",
24631                "session.auth",
24632                serde_json::json!({ "host_token": HOST_TOKEN }),
24633            )))
24634            .unwrap();
24635        let auth_response = outbound_rx.next().await.expect("host auth response");
24636        assert!(auth_response
24637            .into_text()
24638            .unwrap()
24639            .contains("\"role\":\"host\""));
24640
24641        inbound_tx
24642            .unbounded_send(Ok(request(
24643                "handshake",
24644                "server.handshake",
24645                serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
24646            )))
24647            .unwrap();
24648        let mut negotiated = false;
24649        for _ in 0..100 {
24650            negotiated = state.sessions.lock().await.values().any(|session| {
24651                session.negotiated_protocol_version.load(Ordering::Acquire)
24652                    == car_proto::PROTOCOL_VERSION
24653            });
24654            if negotiated {
24655                break;
24656            }
24657            tokio::task::yield_now().await;
24658        }
24659        assert!(
24660            negotiated,
24661            "host protocol negotiation must complete while fanout is paused"
24662        );
24663
24664        credential_fanout_test_gate().release.notify_one();
24665        let mut saw_handshake = false;
24666        let mut saw_reconciled = false;
24667        for _ in 0..2 {
24668            let frame =
24669                tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
24670                    .await
24671                    .expect("eligible host should receive handshake and reconciled snapshot")
24672                    .expect("eligible host connection must remain open")
24673                    .into_text()
24674                    .unwrap();
24675            saw_handshake |= frame.contains(&format!(
24676                "\"protocol_version\":{}",
24677                car_proto::PROTOCOL_VERSION
24678            ));
24679            saw_reconciled |= frame.contains("\"method\":\"auth.credential.event\"")
24680                && frame.contains("\"state\":\"configured\"");
24681        }
24682        assert!(saw_handshake, "host must receive handshake success");
24683        assert!(
24684            saw_reconciled,
24685            "host must receive the retained credential snapshot"
24686        );
24687
24688        inbound_tx
24689            .unbounded_send(Ok(request(
24690                "still-connected",
24691                "auth.authority_hint",
24692                serde_json::json!({}),
24693            )))
24694            .unwrap();
24695        let response =
24696            tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
24697                .await
24698                .expect("host should remain responsive after pre-auth activity")
24699                .expect("host connection should remain open")
24700                .into_text()
24701                .unwrap();
24702        assert!(response.contains("\"id\":\"still-connected\""));
24703        assert!(!dispatch.is_finished());
24704
24705        inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
24706        let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
24707            .await
24708            .expect("dispatcher should stop after close")
24709            .expect("dispatcher task should join");
24710        assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
24711    }
24712
24713    #[tokio::test]
24714    async fn handoff_cannot_send_terminal_before_queued_pending() {
24715        const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_HANDOFF_CHILD";
24716        if std::env::var_os(SENTINEL).is_none() {
24717            let status = std::process::Command::new(std::env::current_exe().unwrap())
24718                .arg("--exact")
24719                .arg(
24720                    "handler::credential_event_lag_regression::handoff_cannot_send_terminal_before_queued_pending",
24721                )
24722                .arg("--nocapture")
24723                .env(SENTINEL, "1")
24724                .env("CAR_NO_INFERENCE_WORKER", "1")
24725                .env("CAR_TEST_PAUSE_CREDENTIAL_HANDOFF", "1")
24726                .status()
24727                .expect("spawn isolated credential handoff contract");
24728            assert!(status.success(), "isolated credential handoff failed");
24729            return;
24730        }
24731
24732        let temp = tempfile::TempDir::new().unwrap();
24733        let secrets = tempfile::TempDir::new().unwrap();
24734        std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
24735        std::env::set_var(car_home::ENV_VAR, temp.path());
24736        car_auth::commit_login(
24737            "https://api.example.test",
24738            &car_auth::TokenSet {
24739                access_token: "handoff-test-access".into(),
24740                refresh_token: "handoff-test-refresh".into(),
24741                expires_in: 3_600,
24742                token_type: "Bearer".into(),
24743            },
24744            r#"{"Account":{"Id":"handoff-account","Email":"handoff@example.test"}}"#,
24745            None,
24746        )
24747        .await
24748        .unwrap();
24749
24750        let state = Arc::new(crate::session::ServerState::standalone(
24751            temp.path().join("journal"),
24752        ));
24753        state
24754            .install_host_token(HOST_TOKEN.to_string())
24755            .expect("install host token");
24756
24757        let (inbound_tx, inbound_rx) =
24758            futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
24759        let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
24760        let write: crate::session::WsSink = Box::pin(
24761            outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
24762        );
24763        let dispatch_state = Arc::clone(&state);
24764        let mut dispatch = tokio::spawn(async move {
24765            run_dispatch(
24766                inbound_rx,
24767                write,
24768                "credential-handoff-test".to_string(),
24769                dispatch_state,
24770            )
24771            .await
24772            .map_err(|error| error.to_string())
24773        });
24774
24775        inbound_tx
24776            .unbounded_send(Ok(request(
24777                "auth",
24778                "session.auth",
24779                serde_json::json!({ "host_token": HOST_TOKEN }),
24780            )))
24781            .unwrap();
24782        let auth_response = outbound_rx.next().await.expect("host auth response");
24783        assert!(auth_response
24784            .into_text()
24785            .unwrap()
24786            .contains("\"role\":\"host\""));
24787
24788        inbound_tx
24789            .unbounded_send(Ok(request(
24790                "handshake",
24791                "server.handshake",
24792                serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
24793            )))
24794            .unwrap();
24795        credential_handoff_test_gate().ready.notified().await;
24796
24797        car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
24798            .await
24799            .expect("interleaved credential generation should resolve");
24800        credential_handoff_test_gate().release.notify_one();
24801
24802        let mut saw_handshake = false;
24803        let mut states = Vec::new();
24804        while !saw_handshake || states.len() < 2 {
24805            let frame =
24806                tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
24807                    .await
24808                    .expect("handoff must deliver handshake and exact credential lifecycle")
24809                    .expect("eligible host connection must remain open")
24810                    .into_text()
24811                    .unwrap();
24812            let value: serde_json::Value =
24813                serde_json::from_str(&frame).expect("credential handoff frame JSON");
24814            saw_handshake |= value["id"] == "handshake";
24815            if value["method"] == "auth.credential.event" {
24816                states.push(value["params"]["state"].as_str().unwrap().to_string());
24817            }
24818        }
24819        assert_eq!(states, ["pending", "configured"]);
24820
24821        inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
24822        let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
24823            .await
24824            .expect("dispatcher should stop after close")
24825            .expect("dispatcher task should join");
24826        assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
24827    }
24828}
24829
24830#[cfg(test)]
24831mod fd_leak_regression {
24832    //! car#209 regression: an abrupt transport error must still run
24833    //! the connection cleanup. Before the fix, `let msg = msg?;`
24834    //! propagated the read error out of `run_dispatch`, skipping
24835    //! `remove_session`, so `state.sessions` (holding the
24836    //! `Arc<ClientSession>` -> `Arc<WsChannel>` -> socket FD) leaked
24837    //! forever on every peer reset / crash-loop disconnect.
24838    use super::run_dispatch;
24839    use futures::SinkExt;
24840    use std::sync::Arc;
24841    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24842
24843    #[tokio::test]
24844    async fn abrupt_read_error_still_runs_session_cleanup() {
24845        let tmp = tempfile::TempDir::new().unwrap();
24846        let state = Arc::new(crate::session::ServerState::standalone(
24847            tmp.path().to_path_buf(),
24848        ));
24849
24850        // Read stream that immediately yields a transport error (peer
24851        // reset), then ends -- the exact shape of an ungraceful
24852        // client disconnect.
24853        let read = futures::stream::iter(vec![Err::<Message, WsError>(WsError::ConnectionClosed)]);
24854        let write: crate::session::WsSink =
24855            Box::pin(futures::sink::drain().sink_map_err(|_| WsError::ConnectionClosed));
24856
24857        let result = run_dispatch(read, write, "test-peer".to_string(), state.clone()).await;
24858        assert!(
24859            result.is_ok(),
24860            "run_dispatch must return Ok after cleanup, got {result:?}"
24861        );
24862
24863        // The session (and its channel/FD) must be gone -- cleanup
24864        // ran despite the abrupt error.
24865        assert!(
24866            state.sessions.lock().await.is_empty(),
24867            "state.sessions must be empty after an abrupt disconnect (car#209)"
24868        );
24869    }
24870}
24871
24872#[cfg(test)]
24873mod a2ui_action_delivery {
24874    //! Parslee-ai/car-releases#58: a ClientAction must reach A2UI
24875    //! subscribers on the `a2ui.event` channel — the same one that
24876    //! already carries surface updates — so an agent that created a
24877    //! surface receives the button click it would otherwise never see.
24878    use super::{handle_a2ui_action, JsonRpcMessage};
24879    use crate::session::{ServerState, WsChannel, WsSink};
24880    use futures::{SinkExt, StreamExt};
24881    use std::collections::HashMap;
24882    use std::sync::atomic::AtomicU64;
24883    use std::sync::Arc;
24884    use tokio::sync::Mutex;
24885    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24886
24887    #[tokio::test]
24888    async fn client_action_broadcasts_to_a2ui_subscribers() {
24889        let tmp = tempfile::TempDir::new().unwrap();
24890        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
24891
24892        // Capturing subscriber channel: a futures mpsc whose sink half is
24893        // erased into the WsSink the server writes to, receiver kept here.
24894        let (tx, mut rx) = futures::channel::mpsc::unbounded::<Message>();
24895        let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
24896        let channel = Arc::new(WsChannel {
24897            write: Mutex::new(sink),
24898            pending: Mutex::new(HashMap::new()),
24899            active_actions: Mutex::new(HashMap::new()),
24900            next_id: AtomicU64::new(0),
24901        });
24902        state
24903            .a2ui_subscribers
24904            .lock()
24905            .await
24906            .insert("test-sub".to_string(), channel);
24907
24908        // Send the action under the `action` key (not `name`) to also
24909        // exercise the serde alias — the web renderer forwards
24910        // `@a2ui/web_core`'s object verbatim.
24911        let req: JsonRpcMessage = serde_json::from_value(serde_json::json!({
24912            "jsonrpc": "2.0",
24913            "method": "a2ui.action",
24914            "id": 1,
24915            "params": {
24916                "action": "trader:pause",
24917                "surfaceId": "surf-1",
24918                "sourceComponentId": "b1",
24919                "timestamp": "2026-06-03T00:00:00Z"
24920            }
24921        }))
24922        .unwrap();
24923
24924        let out = handle_a2ui_action(&req, &state).await;
24925        assert!(out.is_ok(), "handle_a2ui_action failed: {out:?}");
24926
24927        let msg = rx.next().await.expect("subscriber received no frame");
24928        let text = match msg {
24929            Message::Text(t) => t.to_string(),
24930            other => panic!("expected text frame, got {other:?}"),
24931        };
24932        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
24933        assert_eq!(v["method"], "a2ui.event");
24934        assert_eq!(v["params"]["kind"], "a2ui.action");
24935        // Alias resolved `action` -> `name`.
24936        assert_eq!(
24937            v["params"]["result"]["action"]["name"], "trader:pause",
24938            "ClientAction.name should accept the `action` alias"
24939        );
24940        assert_eq!(v["params"]["result"]["surfaceId"], "surf-1");
24941    }
24942}
24943
24944#[cfg(test)]
24945mod a2a_chat_collector {
24946    //! car-releases#65 part 2: when an A2A conversational turn registers an
24947    //! in-process collector, `try_forward_agent_chat_event` feeds it the
24948    //! normalized stream chunks instead of forwarding to a host UI channel.
24949    use super::{try_forward_agent_chat_event, JsonRpcMessage};
24950    use crate::session::{ChatCollector, ServerState};
24951    use std::sync::Arc;
24952    use tokio::sync::mpsc;
24953
24954    fn event(session_id: &str, extra: serde_json::Value) -> JsonRpcMessage {
24955        let mut params = serde_json::Map::new();
24956        params.insert("session_id".into(), session_id.into());
24957        if let Some(obj) = extra.as_object() {
24958            for (k, v) in obj {
24959                params.insert(k.clone(), v.clone());
24960            }
24961        }
24962        serde_json::from_value(serde_json::json!({
24963            "jsonrpc": "2.0",
24964            "method": "agent.chat.event",
24965            "params": params,
24966        }))
24967        .unwrap()
24968    }
24969
24970    #[tokio::test]
24971    async fn collector_receives_normalized_chunks() {
24972        let tmp = tempfile::TempDir::new().unwrap();
24973        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
24974        let (tx, mut rx) = mpsc::unbounded_channel();
24975        state.chat_collectors.lock().await.insert(
24976            "a2a-chat-xyz".into(),
24977            ChatCollector {
24978                tx,
24979                host_client_id: "host-1".into(),
24980            },
24981        );
24982
24983        // Explicit token delta.
24984        assert!(
24985            try_forward_agent_chat_event(
24986                &event(
24987                    "a2a-chat-xyz",
24988                    serde_json::json!({ "kind": "token", "delta": "Hello " })
24989                ),
24990                &state,
24991            )
24992            .await
24993        );
24994        // kind omitted, has delta → derived "token".
24995        try_forward_agent_chat_event(
24996            &event("a2a-chat-xyz", serde_json::json!({ "delta": "world" })),
24997            &state,
24998        )
24999        .await;
25000        // kind omitted, finish_reason present → derived terminal "done".
25001        try_forward_agent_chat_event(
25002            &event(
25003                "a2a-chat-xyz",
25004                serde_json::json!({ "finish_reason": "1 turn" }),
25005            ),
25006            &state,
25007        )
25008        .await;
25009
25010        let c1 = rx.recv().await.unwrap();
25011        assert_eq!(c1.kind, "token");
25012        assert_eq!(c1.delta.as_deref(), Some("Hello "));
25013        let c2 = rx.recv().await.unwrap();
25014        assert_eq!(c2.delta.as_deref(), Some("world"));
25015        let c3 = rx.recv().await.unwrap();
25016        assert_eq!(c3.kind, "done");
25017
25018        // The collector is left in place for the collecting task to drain/remove.
25019        assert!(state
25020            .chat_collectors
25021            .lock()
25022            .await
25023            .contains_key("a2a-chat-xyz"));
25024    }
25025
25026    #[tokio::test]
25027    async fn error_event_carries_error_text() {
25028        let tmp = tempfile::TempDir::new().unwrap();
25029        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25030        let (tx, mut rx) = mpsc::unbounded_channel();
25031        state.chat_collectors.lock().await.insert(
25032            "a2a-chat-err".into(),
25033            ChatCollector {
25034                tx,
25035                host_client_id: "h".into(),
25036            },
25037        );
25038        try_forward_agent_chat_event(
25039            &event(
25040                "a2a-chat-err",
25041                serde_json::json!({ "error": "model exploded" }),
25042            ),
25043            &state,
25044        )
25045        .await;
25046        let c = rx.recv().await.unwrap();
25047        assert_eq!(c.kind, "error"); // derived from `error` presence
25048        assert_eq!(c.error.as_deref(), Some("model exploded"));
25049    }
25050}
25051
25052#[cfg(test)]
25053mod scheduler_stateful_dedup {
25054    //! The WS `scheduler.run` path deserializes a fresh `Task` from client
25055    //! params on every call and used to run it through a throwaway `Executor`
25056    //! without persisting the mutated task. Because the executor's
25057    //! deterministic occurrence guard lives in `task.executions`, that history
25058    //! was empty on every call and a replayed Interval/Once occurrence ran
25059    //! twice. `run_scheduler_task_once` now seeds the task from a `TaskStore`
25060    //! and saves it back, so the guard survives the JSON-RPC boundary.
25061    use super::run_scheduler_task_once;
25062    use car_multi::{AgentOutput, AgentRunner, AgentSpec, Mailbox, MultiError};
25063    use car_scheduler::{Task, TaskStore, TaskTrigger};
25064    use std::sync::atomic::{AtomicU32, Ordering};
25065    use std::sync::Arc;
25066
25067    /// Runner that counts how many times the runtime actually invoked it.
25068    struct CountingRunner {
25069        runs: Arc<AtomicU32>,
25070    }
25071
25072    #[async_trait::async_trait]
25073    impl AgentRunner for CountingRunner {
25074        async fn run(
25075            &self,
25076            spec: &AgentSpec,
25077            _task: &str,
25078            _runtime: &car_engine::Runtime,
25079            _mailbox: &Mailbox,
25080        ) -> Result<AgentOutput, MultiError> {
25081            self.runs.fetch_add(1, Ordering::SeqCst);
25082            Ok(AgentOutput {
25083                name: spec.name.clone(),
25084                answer: "done".to_string(),
25085                turns: 1,
25086                tool_calls: 0,
25087                duration_ms: 1.0,
25088                error: None,
25089                outcome: None,
25090                tokens: None,
25091                tools_used: Vec::new(),
25092            })
25093        }
25094    }
25095
25096    #[tokio::test]
25097    async fn second_run_dedups_across_the_jsonrpc_boundary() {
25098        let dir = tempfile::TempDir::new().unwrap();
25099        let store = TaskStore::new(dir.path());
25100        let runs = Arc::new(AtomicU32::new(0));
25101
25102        // The same Task JSON a client would post twice: an Interval trigger on a
25103        // long schedule (both calls land in slot 0) with an empty execution
25104        // history each time. Serializing/deserializing models the JSON-RPC hop.
25105        let task =
25106            Task::new("dedup_task", "do the thing").with_trigger(TaskTrigger::Interval, "1h");
25107        let task_json = serde_json::to_value(&task).unwrap();
25108
25109        // First call: runs and persists the occurrence.
25110        let mut t1: Task = serde_json::from_value(task_json.clone()).unwrap();
25111        assert!(t1.executions.is_empty());
25112        let runner1: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25113        run_scheduler_task_once(&mut t1, runner1, &store).await;
25114
25115        // Second call: a fresh deserialized Task (empty executions again), but
25116        // the store seeds the prior occurrence so the guard dedups it.
25117        let mut t2: Task = serde_json::from_value(task_json).unwrap();
25118        assert!(t2.executions.is_empty());
25119        let runner2: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25120        run_scheduler_task_once(&mut t2, runner2, &store).await;
25121
25122        assert_eq!(
25123            runs.load(Ordering::SeqCst),
25124            1,
25125            "the runner must be invoked exactly once across two stateless calls"
25126        );
25127
25128        // The persisted task carries the single occurrence.
25129        let persisted = store.load(&task.id).expect("task should be persisted");
25130        assert_eq!(persisted.run_count, 1);
25131        assert_eq!(persisted.executions.len(), 1);
25132    }
25133}
25134
25135#[cfg(test)]
25136mod a2ui_apply_delivery {
25137    //! Parslee-ai/car-releases#29: applying an A2UI envelope must reach
25138    //! every WS subscriber on the `a2ui.event` channel. A `createSurface`
25139    //! apply broadcasts `a2ui.surface_updated`; a `deleteSurface` apply
25140    //! broadcasts `a2ui.surface_deleted`. Reuses the fake
25141    //! WsChannel-over-mpsc harness from `a2ui_action_delivery`.
25142    use super::apply_a2ui_envelope;
25143    use crate::session::{ServerState, WsChannel, WsSink};
25144    use futures::{SinkExt, StreamExt};
25145    use std::collections::HashMap;
25146    use std::sync::atomic::AtomicU64;
25147    use std::sync::Arc;
25148    use tokio::sync::Mutex;
25149    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25150
25151    fn fake_subscriber() -> (
25152        Arc<WsChannel>,
25153        futures::channel::mpsc::UnboundedReceiver<Message>,
25154    ) {
25155        let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
25156        let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
25157        let channel = Arc::new(WsChannel {
25158            write: Mutex::new(sink),
25159            pending: Mutex::new(HashMap::new()),
25160            active_actions: Mutex::new(HashMap::new()),
25161            next_id: AtomicU64::new(0),
25162        });
25163        (channel, rx)
25164    }
25165
25166    fn envelope(value: serde_json::Value) -> car_a2ui::A2uiEnvelope {
25167        serde_json::from_value(value).expect("valid envelope")
25168    }
25169
25170    #[tokio::test]
25171    async fn apply_create_broadcasts_surface_updated() {
25172        let tmp = tempfile::TempDir::new().unwrap();
25173        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25174        let (channel, mut rx) = fake_subscriber();
25175        state
25176            .a2ui_subscribers
25177            .lock()
25178            .await
25179            .insert("test-sub".to_string(), channel);
25180
25181        apply_a2ui_envelope(
25182            &state,
25183            envelope(serde_json::json!({
25184                "version": "v0.9",
25185                "createSurface": { "surfaceId": "surf-a" }
25186            })),
25187            None,
25188            None,
25189        )
25190        .await
25191        .expect("apply must succeed");
25192
25193        let msg = rx.next().await.expect("subscriber received no frame");
25194        let text = match msg {
25195            Message::Text(t) => t.to_string(),
25196            other => panic!("expected text frame, got {other:?}"),
25197        };
25198        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25199        assert_eq!(v["method"], "a2ui.event");
25200        assert_eq!(v["params"]["kind"], "a2ui.surface_updated");
25201        assert_eq!(v["params"]["result"]["surfaceId"], "surf-a");
25202    }
25203
25204    #[tokio::test]
25205    async fn apply_delete_broadcasts_surface_deleted() {
25206        let tmp = tempfile::TempDir::new().unwrap();
25207        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25208        let (channel, mut rx) = fake_subscriber();
25209        state
25210            .a2ui_subscribers
25211            .lock()
25212            .await
25213            .insert("test-sub".to_string(), channel);
25214
25215        // Create the surface first so the delete targets something real.
25216        apply_a2ui_envelope(
25217            &state,
25218            envelope(serde_json::json!({
25219                "version": "v0.9",
25220                "createSurface": { "surfaceId": "surf-d" }
25221            })),
25222            None,
25223            None,
25224        )
25225        .await
25226        .expect("create must succeed");
25227        let _ = rx.next().await.expect("surface_updated frame");
25228
25229        apply_a2ui_envelope(
25230            &state,
25231            envelope(serde_json::json!({
25232                "version": "v0.9",
25233                "deleteSurface": { "surfaceId": "surf-d" }
25234            })),
25235            None,
25236            None,
25237        )
25238        .await
25239        .expect("delete must succeed");
25240
25241        let msg = rx
25242            .next()
25243            .await
25244            .expect("subscriber received no delete frame");
25245        let text = match msg {
25246            Message::Text(t) => t.to_string(),
25247            other => panic!("expected text frame, got {other:?}"),
25248        };
25249        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25250        assert_eq!(v["method"], "a2ui.event");
25251        assert_eq!(v["params"]["kind"], "a2ui.surface_deleted");
25252        assert_eq!(v["params"]["result"]["surfaceId"], "surf-d");
25253    }
25254}
25255
25256#[cfg(test)]
25257mod agents_chat_attachments {
25258    //! Image attachments ride the `agents.chat` → `agent.chat`
25259    //! reverse-call as an optional `attachments` array of image
25260    //! ContentBlocks. The daemon validates the shape and forwards it
25261    //! verbatim; absent attachments must not alter the request shape.
25262    use super::{
25263        build_agent_chat_params, ensure_chat_goal_running, extract_chat_attachments,
25264        extract_chat_goal, extract_chat_model, handle_agents_chat_cancel, handle_goal_clear,
25265        handle_goal_set, handle_goal_status, handle_goal_suggest, send_external_chat_frame,
25266        update_chat_goal_from_event, JsonRpcMessage,
25267    };
25268    use crate::session::{ChatSession, ServerState};
25269    use serde_json::{json, Value};
25270    use std::sync::atomic::AtomicBool;
25271    use std::sync::Arc;
25272
25273    fn req(params: Value) -> JsonRpcMessage {
25274        JsonRpcMessage {
25275            jsonrpc: "2.0".into(),
25276            method: None,
25277            params,
25278            id: json!(1),
25279            result: None,
25280            error: None,
25281        }
25282    }
25283
25284    #[test]
25285    fn extract_returns_none_when_absent_or_null() {
25286        assert_eq!(extract_chat_attachments(&json!({"prompt": "hi"})), Ok(None));
25287        assert_eq!(
25288            extract_chat_attachments(&json!({"attachments": null})),
25289            Ok(None)
25290        );
25291    }
25292
25293    #[test]
25294    fn extract_accepts_image_content_blocks() {
25295        let params = json!({
25296            "attachments": [
25297                {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
25298                {"type": "image_url", "url": "https://example.com/a.jpg", "detail": "auto"}
25299            ]
25300        });
25301        let got = extract_chat_attachments(&params).unwrap().unwrap();
25302        assert_eq!(got.as_array().map(|a| a.len()), Some(2));
25303    }
25304
25305    #[test]
25306    fn extract_rejects_non_array() {
25307        assert!(extract_chat_attachments(&json!({"attachments": "nope"})).is_err());
25308    }
25309
25310    #[test]
25311    fn extract_rejects_non_image_entry() {
25312        let params = json!({
25313            "attachments": [
25314                {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
25315                {"type": "text", "text": "not an image"}
25316            ]
25317        });
25318        assert!(extract_chat_attachments(&params).is_err());
25319    }
25320
25321    #[test]
25322    fn extract_rejects_base64_missing_data() {
25323        let params = json!({"attachments": [{"type": "image_base64", "media_type": "image/png"}]});
25324        assert!(extract_chat_attachments(&params).is_err());
25325    }
25326
25327    #[test]
25328    fn extract_rejects_disallowed_media_type() {
25329        let params = json!({
25330            "attachments": [{"type": "image_base64", "data": "AAAA", "media_type": "image/svg+xml"}]
25331        });
25332        assert!(extract_chat_attachments(&params).is_err());
25333    }
25334
25335    #[test]
25336    fn extract_rejects_non_http_image_url() {
25337        let params = json!({"attachments": [{"type": "image_url", "url": "file:///etc/passwd"}]});
25338        assert!(extract_chat_attachments(&params).is_err());
25339    }
25340
25341    #[test]
25342    fn build_params_omits_attachments_when_none() {
25343        let params =
25344            build_agent_chat_params("sess-1", "hello", true, "host-1", false, None, None, None);
25345        assert_eq!(params["session_id"], "sess-1");
25346        assert_eq!(params["prompt"], "hello");
25347        assert_eq!(params["context"]["host_client_id"], "host-1");
25348        assert!(
25349            params.get("attachments").is_none(),
25350            "no `attachments` key when there are none — agents that ignore it see the legacy shape"
25351        );
25352    }
25353
25354    #[test]
25355    fn build_params_carries_attachments_when_present() {
25356        let blocks = json!([
25357            {"type": "image_base64", "data": "AAAA", "media_type": "image/png"}
25358        ]);
25359        let params = build_agent_chat_params(
25360            "sess-2",
25361            "describe",
25362            true,
25363            "host-2",
25364            false,
25365            None,
25366            Some(blocks),
25367            None,
25368        );
25369        let att = params["attachments"]
25370            .as_array()
25371            .expect("attachments present");
25372        assert_eq!(att.len(), 1);
25373        assert_eq!(att[0]["type"], "image_base64");
25374        assert_eq!(att[0]["media_type"], "image/png");
25375    }
25376
25377    #[test]
25378    fn extract_goal_returns_none_when_absent_or_null() {
25379        assert_eq!(extract_chat_goal(&json!({"prompt": "hi"})), Ok(None));
25380        assert_eq!(extract_chat_goal(&json!({"goal": null})), Ok(None));
25381    }
25382
25383    #[test]
25384    fn extract_goal_normalizes_check_and_default_iterations() {
25385        let got = extract_chat_goal(&json!({"goal": {"check": "  cargo test -q  "}}))
25386            .unwrap()
25387            .unwrap();
25388        assert_eq!(got["check"], "cargo test -q");
25389        assert_eq!(got["max_iterations"], 8);
25390    }
25391
25392    #[test]
25393    fn extract_goal_clamps_iteration_budget() {
25394        let low = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 0}}))
25395            .unwrap()
25396            .unwrap();
25397        let high = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 500}}))
25398            .unwrap()
25399            .unwrap();
25400        assert_eq!(low["max_iterations"], 1);
25401        assert_eq!(high["max_iterations"], 50);
25402    }
25403
25404    #[test]
25405    fn extract_goal_rejects_bad_shape() {
25406        assert!(extract_chat_goal(&json!({"goal": "nope"})).is_err());
25407        assert!(extract_chat_goal(&json!({"goal": {"check": ""}})).is_err());
25408    }
25409
25410    #[test]
25411    fn selected_chat_model_is_forwarded_and_unset_preserves_agent_default() {
25412        assert_eq!(
25413            extract_chat_model(&json!({
25414                "model": "  openrouter/deepseek/deepseek-v3.2  "
25415            }))
25416            .unwrap()
25417            .as_deref(),
25418            Some("openrouter/deepseek/deepseek-v3.2")
25419        );
25420        assert_eq!(extract_chat_model(&json!({})), Ok(None));
25421        assert_eq!(extract_chat_model(&json!({"model": "  "})), Ok(None));
25422        assert!(extract_chat_model(&json!({"model": 42})).is_err());
25423
25424        let selected = build_agent_chat_params(
25425            "sess-model",
25426            "hello",
25427            true,
25428            "host-model",
25429            false,
25430            Some("openrouter/deepseek/deepseek-v3.2".into()),
25431            None,
25432            None,
25433        );
25434        assert_eq!(
25435            selected["model"], "openrouter/deepseek/deepseek-v3.2",
25436            "the actual daemon-to-agent chat request must carry the selected CAR model"
25437        );
25438
25439        let adaptive = build_agent_chat_params(
25440            "sess-adaptive",
25441            "hello",
25442            true,
25443            "host-adaptive",
25444            false,
25445            None,
25446            None,
25447            None,
25448        );
25449        assert!(
25450            adaptive.get("model").is_none(),
25451            "omission preserves the supervised agent or adaptive-router default"
25452        );
25453    }
25454
25455    #[test]
25456    fn build_params_carries_goal_when_present() {
25457        let goal = json!({"check": "cargo test -q", "max_iterations": 4});
25458        let params = build_agent_chat_params(
25459            "sess-3",
25460            "fix tests",
25461            true,
25462            "host-3",
25463            false,
25464            None,
25465            None,
25466            Some(goal),
25467        );
25468        assert_eq!(params["goal"]["check"], "cargo test -q");
25469        assert_eq!(params["goal"]["max_iterations"], 4);
25470        assert!(params.get("attachments").is_none());
25471    }
25472
25473    #[tokio::test]
25474    async fn goal_set_status_and_clear_roundtrip() {
25475        let tmp = tempfile::TempDir::new().unwrap();
25476        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25477
25478        let set = handle_goal_set(
25479            &req(json!({
25480                "session_id": "chat-1",
25481                "goal": {"check": "  test -f done  ", "max_iterations": 500}
25482            })),
25483            &state,
25484        )
25485        .await
25486        .unwrap();
25487        assert_eq!(set["set"], true);
25488        assert_eq!(set["goal"]["check"], "test -f done");
25489        assert_eq!(set["goal"]["max_iterations"], 50);
25490        assert_eq!(set["goal"]["status"], "active");
25491
25492        let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
25493            .await
25494            .unwrap();
25495        assert_eq!(status["goal"]["session_id"], "chat-1");
25496        assert_eq!(status["goal"]["check"], "test -f done");
25497
25498        let clear = handle_goal_clear(&req(json!({"session_id": "chat-1"})), &state)
25499            .await
25500            .unwrap();
25501        assert_eq!(clear["cleared"], true);
25502        let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
25503            .await
25504            .unwrap();
25505        assert!(status["goal"].is_null());
25506    }
25507
25508    #[tokio::test]
25509    async fn goal_status_tracks_evaluator_and_terminal_events() {
25510        let tmp = tempfile::TempDir::new().unwrap();
25511        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25512        handle_goal_set(
25513            &req(json!({"session_id": "chat-2", "check": "test -f done", "max_iterations": 4})),
25514            &state,
25515        )
25516        .await
25517        .unwrap();
25518
25519        update_chat_goal_from_event(
25520            &state,
25521            "chat-2",
25522            &json!({
25523                "kind": "goal_evaluated",
25524                "iteration": 1,
25525                "met": false,
25526                "grounded": false,
25527                "reason": "file missing"
25528            }),
25529        )
25530        .await;
25531        let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
25532            .await
25533            .unwrap();
25534        assert_eq!(status["goal"]["status"], "running");
25535        assert_eq!(status["goal"]["last_iteration"], 1);
25536        assert_eq!(status["goal"]["last_reason"], "file missing");
25537
25538        update_chat_goal_from_event(
25539            &state,
25540            "chat-2",
25541            &json!({
25542                "kind": "goal_evaluated",
25543                "iteration": 2,
25544                "met": true,
25545                "grounded": true,
25546                "reason": "file exists"
25547            }),
25548        )
25549        .await;
25550        update_chat_goal_from_event(
25551            &state,
25552            "chat-2",
25553            &json!({"kind": "done", "finish_reason": "goal reached"}),
25554        )
25555        .await;
25556        let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
25557            .await
25558            .unwrap();
25559        assert_eq!(status["goal"]["status"], "met");
25560        assert_eq!(status["goal"]["last_met"], true);
25561        assert_eq!(status["goal"]["terminal_kind"], "done");
25562        assert_eq!(status["goal"]["terminal_message"], "goal reached");
25563    }
25564
25565    /// car#1113 review: a fail-open `done` — the goal check never got the
25566    /// chance to run (`GoalHalt::EvaluationTimeout`, `chat::
25567    /// goal_turn_terminal_event`) — must NOT be recorded as `status: "met"`.
25568    /// Before this fix, `kind == "done"` mapped to `"met"` unconditionally,
25569    /// so this exact event persisted a "verified" status for a goal that was
25570    /// never actually checked, surviving a daemon restart via
25571    /// `chat-goals.json`.
25572    #[tokio::test]
25573    async fn a_fail_open_done_is_recorded_as_unevaluated_not_met() {
25574        let tmp = tempfile::TempDir::new().unwrap();
25575        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25576        handle_goal_set(
25577            &req(json!({"session_id": "chat-timeout", "check": "test -f done"})),
25578            &state,
25579        )
25580        .await
25581        .unwrap();
25582
25583        // Shape mirrors `chat::goal_turn_terminal_event`'s `EvaluationTimeout`
25584        // arm exactly: `kind: "done"` (fail-open) plus the machine-readable
25585        // `goal_unevaluated` marker alongside the human-readable
25586        // `finish_reason`.
25587        update_chat_goal_from_event(
25588            &state,
25589            "chat-timeout",
25590            &json!({
25591                "kind": "done",
25592                "finish_reason": "goal check unevaluated (timed out) — reply delivered unverified",
25593                "goal_unevaluated": true,
25594            }),
25595        )
25596        .await;
25597
25598        let status = handle_goal_status(&req(json!({"session_id": "chat-timeout"})), &state)
25599            .await
25600            .unwrap();
25601        assert_eq!(
25602            status["goal"]["status"], "unevaluated",
25603            "a goal check that never ran must not be recorded as met: {status}"
25604        );
25605        assert_eq!(status["goal"]["terminal_kind"], "done");
25606        assert_eq!(
25607            status["goal"]["terminal_message"],
25608            "goal check unevaluated (timed out) — reply delivered unverified"
25609        );
25610
25611        // A genuine achieved completion (no `goal_unevaluated` marker) is
25612        // unaffected by the new branch — still "met".
25613        handle_goal_set(
25614            &req(json!({"session_id": "chat-achieved", "check": "test -f done"})),
25615            &state,
25616        )
25617        .await
25618        .unwrap();
25619        update_chat_goal_from_event(
25620            &state,
25621            "chat-achieved",
25622            &json!({"kind": "done", "finish_reason": "goal reached"}),
25623        )
25624        .await;
25625        let status = handle_goal_status(&req(json!({"session_id": "chat-achieved"})), &state)
25626            .await
25627            .unwrap();
25628        assert_eq!(status["goal"]["status"], "met");
25629    }
25630
25631    #[tokio::test]
25632    async fn inline_goal_creates_durable_running_status() {
25633        let tmp = tempfile::TempDir::new().unwrap();
25634        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25635
25636        ensure_chat_goal_running(
25637            &state,
25638            "chat-inline",
25639            &json!({"check": "test -f done", "max_iterations": 99}),
25640        )
25641        .await
25642        .unwrap();
25643
25644        let status = handle_goal_status(&req(json!({"session_id": "chat-inline"})), &state)
25645            .await
25646            .unwrap();
25647        assert_eq!(status["goal"]["session_id"], "chat-inline");
25648        assert_eq!(status["goal"]["check"], "test -f done");
25649        assert_eq!(status["goal"]["max_iterations"], 50);
25650        assert_eq!(status["goal"]["status"], "running");
25651    }
25652
25653    #[tokio::test]
25654    async fn rerun_goal_clears_prior_terminal_status() {
25655        let tmp = tempfile::TempDir::new().unwrap();
25656        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25657        handle_goal_set(
25658            &req(json!({"session_id": "chat-rerun", "check": "test -f done"})),
25659            &state,
25660        )
25661        .await
25662        .unwrap();
25663        update_chat_goal_from_event(
25664            &state,
25665            "chat-rerun",
25666            &json!({"kind": "error", "error": "cancelled"}),
25667        )
25668        .await;
25669
25670        ensure_chat_goal_running(
25671            &state,
25672            "chat-rerun",
25673            &json!({"check": "test -f done", "max_iterations": 4}),
25674        )
25675        .await
25676        .unwrap();
25677
25678        let status = handle_goal_status(&req(json!({"session_id": "chat-rerun"})), &state)
25679            .await
25680            .unwrap();
25681        assert_eq!(status["goal"]["status"], "running");
25682        assert!(status["goal"]["terminal_kind"].is_null());
25683        assert!(status["goal"]["terminal_message"].is_null());
25684    }
25685
25686    #[tokio::test]
25687    async fn daemon_owned_chat_frames_update_goal_status_before_host_delivery() {
25688        let tmp = tempfile::TempDir::new().unwrap();
25689        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25690        handle_goal_set(
25691            &req(json!({"session_id": "chat-local", "check": "test -f done"})),
25692            &state,
25693        )
25694        .await
25695        .unwrap();
25696
25697        // No chat_sessions route is registered here. `send_external_chat_frame`
25698        // should still persist the goal status before best-effort host delivery
25699        // decides there is no host channel to send to.
25700        send_external_chat_frame(
25701            &state,
25702            "chat-local",
25703            json!({
25704                "session_id": "chat-local",
25705                "agent_id": "local",
25706                "kind": "goal_evaluated",
25707                "iteration": 1,
25708                "met": true,
25709                "grounded": true,
25710                "reason": "file exists"
25711            }),
25712        )
25713        .await;
25714
25715        let status = handle_goal_status(&req(json!({"session_id": "chat-local"})), &state)
25716            .await
25717            .unwrap();
25718        assert_eq!(status["goal"]["status"], "met");
25719        assert_eq!(status["goal"]["last_iteration"], 1);
25720        assert_eq!(status["goal"]["last_reason"], "file exists");
25721    }
25722
25723    #[tokio::test]
25724    async fn cancelling_goal_chat_marks_standing_goal_terminal() {
25725        let tmp = tempfile::TempDir::new().unwrap();
25726        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25727        handle_goal_set(
25728            &req(json!({"session_id": "chat-cancel", "check": "test -f done"})),
25729            &state,
25730        )
25731        .await
25732        .unwrap();
25733        update_chat_goal_from_event(
25734            &state,
25735            "chat-cancel",
25736            &json!({"kind": "goal_evaluated", "iteration": 1, "met": false, "grounded": false, "reason": "not yet"}),
25737        )
25738        .await;
25739        let cancel_flag = Arc::new(AtomicBool::new(false));
25740        state.chat_sessions.lock().await.insert(
25741            "chat-cancel".to_string(),
25742            ChatSession {
25743                agent_id: "local".to_string(),
25744                host_client_id: "host".to_string(),
25745                created_at: 0,
25746                local_cancel: Some(cancel_flag.clone()),
25747            },
25748        );
25749
25750        // A different authenticated session may not abort someone else's turn.
25751        // `agents.chat.approve` has always required the originating host; this
25752        // is the same check on the arm next to it, which had none — being
25753        // authenticated was taken to settle what the caller may do.
25754        let stolen = handle_agents_chat_cancel(
25755            &req(json!({"session_id": "chat-cancel"})),
25756            &state,
25757            "someone-else",
25758            false,
25759        )
25760        .await;
25761        assert!(
25762            stolen.is_err(),
25763            "a foreign session must not cancel this turn: {stolen:?}"
25764        );
25765        assert!(
25766            state.chat_sessions.lock().await.contains_key("chat-cancel"),
25767            "a refused cancel must not destroy the routing entry on its way out"
25768        );
25769        assert!(
25770            !cancel_flag.load(std::sync::atomic::Ordering::SeqCst),
25771            "and must not have signalled cancellation"
25772        );
25773
25774        let cancel = handle_agents_chat_cancel(
25775            &req(json!({"session_id": "chat-cancel"})),
25776            &state,
25777            "host",
25778            true,
25779        )
25780        .await
25781        .unwrap();
25782        assert_eq!(cancel["cancelled"], true);
25783        let status = handle_goal_status(&req(json!({"session_id": "chat-cancel"})), &state)
25784            .await
25785            .unwrap();
25786        assert_eq!(status["goal"]["status"], "error");
25787        assert_eq!(status["goal"]["terminal_kind"], "error");
25788        assert_eq!(status["goal"]["terminal_message"], "cancelled");
25789        assert!(cancel_flag.load(std::sync::atomic::Ordering::SeqCst));
25790    }
25791
25792    #[tokio::test]
25793    async fn goal_status_persists_across_server_state_restart() {
25794        let tmp = tempfile::TempDir::new().unwrap();
25795        let journal_dir = tmp.path().join(".car").join("journals");
25796        let state = Arc::new(ServerState::standalone(journal_dir.clone()));
25797
25798        handle_goal_set(
25799            &req(json!({"session_id": "chat-durable", "check": "test -f done"})),
25800            &state,
25801        )
25802        .await
25803        .unwrap();
25804        update_chat_goal_from_event(
25805            &state,
25806            "chat-durable",
25807            &json!({
25808                "kind": "goal_evaluated",
25809                "iteration": 1,
25810                "met": false,
25811                "grounded": false,
25812                "reason": "not yet"
25813            }),
25814        )
25815        .await;
25816
25817        let restarted = Arc::new(ServerState::standalone(journal_dir.clone()));
25818        let status = handle_goal_status(&req(json!({"session_id": "chat-durable"})), &restarted)
25819            .await
25820            .unwrap();
25821        assert_eq!(status["goal"]["check"], "test -f done");
25822        assert_eq!(status["goal"]["status"], "active");
25823        assert_eq!(status["goal"]["last_iteration"], 1);
25824        assert_eq!(status["goal"]["last_reason"], "not yet");
25825
25826        update_chat_goal_from_event(
25827            &restarted,
25828            "chat-durable",
25829            &json!({
25830                "kind": "goal_evaluated",
25831                "iteration": 2,
25832                "met": true,
25833                "grounded": true,
25834                "reason": "done exists"
25835            }),
25836        )
25837        .await;
25838        update_chat_goal_from_event(
25839            &restarted,
25840            "chat-durable",
25841            &json!({"kind": "done", "finish_reason": "goal reached"}),
25842        )
25843        .await;
25844
25845        let restarted_again = Arc::new(ServerState::standalone(journal_dir));
25846        let status = handle_goal_status(
25847            &req(json!({"session_id": "chat-durable"})),
25848            &restarted_again,
25849        )
25850        .await
25851        .unwrap();
25852        assert_eq!(status["goal"]["status"], "met");
25853        assert_eq!(status["goal"]["last_iteration"], 2);
25854        assert_eq!(status["goal"]["terminal_kind"], "done");
25855    }
25856
25857    #[tokio::test]
25858    async fn restart_downgrades_stale_running_goal_to_active() {
25859        let tmp = tempfile::TempDir::new().unwrap();
25860        let journal_dir = tmp.path().join(".car").join("journals");
25861        let state = Arc::new(ServerState::standalone(journal_dir.clone()));
25862
25863        handle_goal_set(
25864            &req(json!({"session_id": "chat-running", "check": "true"})),
25865            &state,
25866        )
25867        .await
25868        .unwrap();
25869        {
25870            let mut goals = state.chat_goals.lock().await;
25871            goals.get_mut("chat-running").unwrap().status = "running".to_string();
25872        }
25873        state.persist_chat_goals().await.unwrap();
25874
25875        let restarted = Arc::new(ServerState::standalone(journal_dir));
25876        let status = handle_goal_status(&req(json!({"session_id": "chat-running"})), &restarted)
25877            .await
25878            .unwrap();
25879        assert_eq!(status["goal"]["status"], "active");
25880    }
25881
25882    #[tokio::test]
25883    async fn goal_suggest_detects_cargo_project() {
25884        let tmp = tempfile::TempDir::new().unwrap();
25885        std::fs::write(
25886            tmp.path().join("Cargo.toml"),
25887            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
25888        )
25889        .unwrap();
25890        let state = Arc::new(ServerState::standalone(
25891            tmp.path().join(".car").join("journals"),
25892        ));
25893
25894        let got = handle_goal_suggest(
25895            &req(json!({
25896                "prompt": "make the tests pass",
25897                "working_dir": tmp.path().to_string_lossy()
25898            })),
25899            &state,
25900        )
25901        .await
25902        .unwrap();
25903        assert_eq!(got["suggested"], true);
25904        assert_eq!(got["confidence"], "high");
25905        assert!(got["goal"]["check"]
25906            .as_str()
25907            .unwrap()
25908            .ends_with(" && cargo test -q"));
25909    }
25910
25911    #[tokio::test]
25912    async fn goal_suggest_prefers_project_check_over_existing_path_mentions() {
25913        let tmp = tempfile::TempDir::new().unwrap();
25914        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
25915        std::fs::write(
25916            tmp.path().join("Cargo.toml"),
25917            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
25918        )
25919        .unwrap();
25920        std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
25921        let state = Arc::new(ServerState::standalone(
25922            tmp.path().join(".car").join("journals"),
25923        ));
25924
25925        let got = handle_goal_suggest(
25926            &req(json!({
25927                "prompt": "fix src/lib.rs",
25928                "working_dir": tmp.path().to_string_lossy()
25929            })),
25930            &state,
25931        )
25932        .await
25933        .unwrap();
25934        assert!(got["goal"]["check"]
25935            .as_str()
25936            .unwrap()
25937            .ends_with(" && cargo test -q"));
25938    }
25939
25940    #[tokio::test]
25941    async fn goal_suggest_combines_created_file_with_project_check() {
25942        let tmp = tempfile::TempDir::new().unwrap();
25943        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
25944        std::fs::write(
25945            tmp.path().join("Cargo.toml"),
25946            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
25947        )
25948        .unwrap();
25949        let state = Arc::new(ServerState::standalone(
25950            tmp.path().join(".car").join("journals"),
25951        ));
25952
25953        let got = handle_goal_suggest(
25954            &req(json!({
25955                "prompt": "create src/lib.rs",
25956                "working_dir": tmp.path().to_string_lossy()
25957            })),
25958            &state,
25959        )
25960        .await
25961        .unwrap();
25962        assert_eq!(got["suggested"], true);
25963        assert_eq!(got["confidence"], "high");
25964        let check = got["goal"]["check"].as_str().unwrap();
25965        assert!(check.contains("test -f 'src/lib.rs'"), "{check}");
25966        assert!(check.ends_with(" && cargo test -q"), "{check}");
25967        let signals = got["signals"].as_array().unwrap();
25968        assert!(signals.contains(&json!("prompt_mentions_path:src/lib.rs")));
25969        assert!(signals.contains(&json!("Cargo.toml")));
25970    }
25971
25972    #[tokio::test]
25973    async fn goal_suggest_chooses_package_script_from_prompt() {
25974        let tmp = tempfile::TempDir::new().unwrap();
25975        std::fs::write(
25976            tmp.path().join("package.json"),
25977            r#"{"scripts":{"test":"vitest run","typecheck":"tsc --noEmit","check":"biome check","build":"vite build","lint":"eslint ."}}"#,
25978        )
25979        .unwrap();
25980        std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
25981        let state = Arc::new(ServerState::standalone(
25982            tmp.path().join(".car").join("journals"),
25983        ));
25984
25985        let got = handle_goal_suggest(
25986            &req(json!({
25987                "prompt": "fix the TypeScript errors",
25988                "working_dir": tmp.path().to_string_lossy()
25989            })),
25990            &state,
25991        )
25992        .await
25993        .unwrap();
25994        assert_eq!(got["suggested"], true);
25995        assert!(got["goal"]["check"]
25996            .as_str()
25997            .unwrap()
25998            .ends_with(" && pnpm typecheck"));
25999    }
26000
26001    #[tokio::test]
26002    async fn goal_suggest_chooses_check_script_from_prompt() {
26003        let tmp = tempfile::TempDir::new().unwrap();
26004        std::fs::write(
26005            tmp.path().join("package.json"),
26006            r#"{"scripts":{"test":"vitest run","check":"biome check","build":"vite build"}}"#,
26007        )
26008        .unwrap();
26009        std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
26010        let state = Arc::new(ServerState::standalone(
26011            tmp.path().join(".car").join("journals"),
26012        ));
26013
26014        let got = handle_goal_suggest(
26015            &req(json!({
26016                "prompt": "fix the CI check failures",
26017                "working_dir": tmp.path().to_string_lossy()
26018            })),
26019            &state,
26020        )
26021        .await
26022        .unwrap();
26023        assert_eq!(got["suggested"], true);
26024        assert!(got["goal"]["check"]
26025            .as_str()
26026            .unwrap()
26027            .ends_with(" && pnpm check"));
26028        let signals = got["signals"].as_array().unwrap();
26029        assert!(signals.contains(&json!("package_script:check")));
26030    }
26031
26032    #[tokio::test]
26033    async fn goal_suggest_chooses_build_script_from_nested_directory_prompt() {
26034        let tmp = tempfile::TempDir::new().unwrap();
26035        std::fs::create_dir_all(tmp.path().join("apps/web")).unwrap();
26036        std::fs::write(
26037            tmp.path().join("apps/web/package.json"),
26038            r#"{"scripts":{"test":"vitest run","build":"vite build","lint":"eslint ."}}"#,
26039        )
26040        .unwrap();
26041        let state = Arc::new(ServerState::standalone(
26042            tmp.path().join(".car").join("journals"),
26043        ));
26044
26045        let got = handle_goal_suggest(
26046            &req(json!({
26047                "prompt": "fix the production build in apps/web",
26048                "working_dir": tmp.path().to_string_lossy()
26049            })),
26050            &state,
26051        )
26052        .await
26053        .unwrap();
26054        assert_eq!(got["suggested"], true);
26055        let check = got["goal"]["check"].as_str().unwrap();
26056        assert!(check.contains("apps/web"), "{check}");
26057        assert!(check.ends_with(" && npm run build"), "{check}");
26058        let signals = got["signals"].as_array().unwrap();
26059        assert!(signals.contains(&json!("package_script:build")));
26060    }
26061
26062    #[tokio::test]
26063    async fn goal_suggest_chooses_lint_script_from_prompt() {
26064        let tmp = tempfile::TempDir::new().unwrap();
26065        std::fs::write(
26066            tmp.path().join("package.json"),
26067            r#"{"scripts":{"test":"vitest run","lint":"eslint .","build":"vite build"}}"#,
26068        )
26069        .unwrap();
26070        let state = Arc::new(ServerState::standalone(
26071            tmp.path().join(".car").join("journals"),
26072        ));
26073
26074        let got = handle_goal_suggest(
26075            &req(json!({
26076                "prompt": "fix lint errors",
26077                "working_dir": tmp.path().to_string_lossy()
26078            })),
26079            &state,
26080        )
26081        .await
26082        .unwrap();
26083        assert_eq!(got["suggested"], true);
26084        assert!(got["goal"]["check"]
26085            .as_str()
26086            .unwrap()
26087            .ends_with(" && npm run lint"));
26088        let signals = got["signals"].as_array().unwrap();
26089        assert!(signals.contains(&json!("package_script:lint")));
26090    }
26091
26092    #[tokio::test]
26093    async fn goal_suggest_uses_nearest_nested_project_for_created_file() {
26094        let tmp = tempfile::TempDir::new().unwrap();
26095        std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
26096        std::fs::write(
26097            tmp.path().join("apps/web/package.json"),
26098            r#"{"scripts":{"test":"vitest run"}}"#,
26099        )
26100        .unwrap();
26101        let state = Arc::new(ServerState::standalone(
26102            tmp.path().join(".car").join("journals"),
26103        ));
26104
26105        let got = handle_goal_suggest(
26106            &req(json!({
26107                "prompt": "create apps/web/src/App.ts",
26108                "working_dir": tmp.path().to_string_lossy()
26109            })),
26110            &state,
26111        )
26112        .await
26113        .unwrap();
26114        assert_eq!(got["suggested"], true);
26115        assert_eq!(got["confidence"], "high");
26116        let check = got["goal"]["check"].as_str().unwrap();
26117        assert!(check.contains("apps/web"), "{check}");
26118        assert!(check.contains("test -f 'src/App.ts'"), "{check}");
26119        assert!(check.ends_with(" && npm test"), "{check}");
26120        let signals = got["signals"].as_array().unwrap();
26121        assert!(signals.contains(&json!("project_dir:apps/web")));
26122        assert!(signals.contains(&json!("package.json")));
26123    }
26124
26125    #[tokio::test]
26126    async fn goal_suggest_uses_nearest_nested_project_for_existing_file_prompt() {
26127        let tmp = tempfile::TempDir::new().unwrap();
26128        std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
26129        std::fs::write(
26130            tmp.path().join("apps/web/package.json"),
26131            r#"{"scripts":{"typecheck":"tsc --noEmit","test":"vitest run"}}"#,
26132        )
26133        .unwrap();
26134        std::fs::write(tmp.path().join("apps/web/src/App.ts"), "export {}\n").unwrap();
26135        let state = Arc::new(ServerState::standalone(
26136            tmp.path().join(".car").join("journals"),
26137        ));
26138
26139        let got = handle_goal_suggest(
26140            &req(json!({
26141                "prompt": "fix TypeScript errors in apps/web/src/App.ts",
26142                "working_dir": tmp.path().to_string_lossy()
26143            })),
26144            &state,
26145        )
26146        .await
26147        .unwrap();
26148        assert_eq!(got["suggested"], true);
26149        assert_eq!(got["confidence"], "high");
26150        let check = got["goal"]["check"].as_str().unwrap();
26151        assert!(check.contains("apps/web"), "{check}");
26152        assert!(!check.contains("test -f"), "{check}");
26153        assert!(check.ends_with(" && npm run typecheck"), "{check}");
26154        let signals = got["signals"].as_array().unwrap();
26155        assert!(signals.contains(&json!("prompt_mentions_path:apps/web/src/App.ts")));
26156        assert!(signals.contains(&json!("project_dir:apps/web")));
26157        assert!(signals.contains(&json!("package_script:typecheck")));
26158    }
26159
26160    #[tokio::test]
26161    async fn goal_suggest_uses_nearest_nested_project_for_directory_prompt() {
26162        let tmp = tempfile::TempDir::new().unwrap();
26163        std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
26164        std::fs::write(
26165            tmp.path().join("apps/web/package.json"),
26166            r#"{"scripts":{"test":"vitest run"}}"#,
26167        )
26168        .unwrap();
26169        let state = Arc::new(ServerState::standalone(
26170            tmp.path().join(".car").join("journals"),
26171        ));
26172
26173        let got = handle_goal_suggest(
26174            &req(json!({
26175                "prompt": "fix apps/web",
26176                "working_dir": tmp.path().to_string_lossy()
26177            })),
26178            &state,
26179        )
26180        .await
26181        .unwrap();
26182        assert_eq!(got["suggested"], true);
26183        assert_eq!(got["confidence"], "high");
26184        let check = got["goal"]["check"].as_str().unwrap();
26185        assert!(check.contains("apps/web"), "{check}");
26186        assert!(!check.contains("test -f"), "{check}");
26187        assert!(check.ends_with(" && npm test"), "{check}");
26188        let signals = got["signals"].as_array().unwrap();
26189        assert!(signals.contains(&json!("prompt_mentions_path:apps/web")));
26190        assert!(signals.contains(&json!("project_dir:apps/web")));
26191    }
26192
26193    #[tokio::test]
26194    async fn goal_suggest_can_store_the_suggested_goal() {
26195        let tmp = tempfile::TempDir::new().unwrap();
26196        std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
26197        let journal_dir = tmp.path().join(".car").join("journals");
26198        let state = Arc::new(ServerState::standalone(journal_dir.clone()));
26199
26200        let got = handle_goal_suggest(
26201            &req(json!({
26202                "prompt": "make it work",
26203                "working_dir": tmp.path().to_string_lossy(),
26204                "session_id": "chat-suggest",
26205                "set": true
26206            })),
26207            &state,
26208        )
26209        .await
26210        .unwrap();
26211        assert_eq!(got["set"], true);
26212        assert_eq!(got["stored_goal"]["session_id"], "chat-suggest");
26213
26214        let restarted = Arc::new(ServerState::standalone(journal_dir));
26215        let status = handle_goal_status(&req(json!({"session_id": "chat-suggest"})), &restarted)
26216            .await
26217            .unwrap();
26218        assert!(status["goal"]["check"]
26219            .as_str()
26220            .unwrap()
26221            .ends_with(" && go test ./..."));
26222    }
26223
26224    #[tokio::test]
26225    async fn goal_suggest_detects_swift_package() {
26226        let tmp = tempfile::TempDir::new().unwrap();
26227        std::fs::write(
26228            tmp.path().join("Package.swift"),
26229            "// swift-tools-version: 6.0\nimport PackageDescription\n",
26230        )
26231        .unwrap();
26232        let state = Arc::new(ServerState::standalone(
26233            tmp.path().join(".car").join("journals"),
26234        ));
26235
26236        let got = handle_goal_suggest(
26237            &req(json!({
26238                "prompt": "make the package tests pass",
26239                "working_dir": tmp.path().to_string_lossy()
26240            })),
26241            &state,
26242        )
26243        .await
26244        .unwrap();
26245        assert_eq!(got["suggested"], true);
26246        assert_eq!(got["confidence"], "high");
26247        assert!(got["goal"]["check"]
26248            .as_str()
26249            .unwrap()
26250            .ends_with(" && swift test"));
26251        let signals = got["signals"].as_array().unwrap();
26252        assert!(signals.contains(&json!("Package.swift")));
26253    }
26254
26255    #[tokio::test]
26256    async fn goal_suggest_combines_created_swift_file_with_package_check() {
26257        let tmp = tempfile::TempDir::new().unwrap();
26258        std::fs::create_dir_all(tmp.path().join("Sources/App")).unwrap();
26259        std::fs::write(
26260            tmp.path().join("Package.swift"),
26261            "// swift-tools-version: 6.0\nimport PackageDescription\n",
26262        )
26263        .unwrap();
26264        let state = Arc::new(ServerState::standalone(
26265            tmp.path().join(".car").join("journals"),
26266        ));
26267
26268        let got = handle_goal_suggest(
26269            &req(json!({
26270                "prompt": "create Sources/App/Main.swift",
26271                "working_dir": tmp.path().to_string_lossy()
26272            })),
26273            &state,
26274        )
26275        .await
26276        .unwrap();
26277        assert_eq!(got["suggested"], true);
26278        let check = got["goal"]["check"].as_str().unwrap();
26279        assert!(
26280            check.contains("test -f 'Sources/App/Main.swift'"),
26281            "{check}"
26282        );
26283        assert!(check.ends_with(" && swift test"), "{check}");
26284    }
26285
26286    #[tokio::test]
26287    async fn goal_suggest_detects_cmake_project() {
26288        let tmp = tempfile::TempDir::new().unwrap();
26289        std::fs::write(
26290            tmp.path().join("CMakeLists.txt"),
26291            "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
26292        )
26293        .unwrap();
26294        let state = Arc::new(ServerState::standalone(
26295            tmp.path().join(".car").join("journals"),
26296        ));
26297
26298        let got = handle_goal_suggest(
26299            &req(json!({
26300                "prompt": "fix the C++ build",
26301                "working_dir": tmp.path().to_string_lossy()
26302            })),
26303            &state,
26304        )
26305        .await
26306        .unwrap();
26307        assert_eq!(got["suggested"], true);
26308        assert_eq!(got["confidence"], "high");
26309        assert!(got["goal"]["check"]
26310            .as_str()
26311            .unwrap()
26312            .ends_with(" && cmake -S . -B build && cmake --build build"));
26313        let signals = got["signals"].as_array().unwrap();
26314        assert!(signals.contains(&json!("CMakeLists.txt")));
26315    }
26316
26317    #[tokio::test]
26318    async fn goal_suggest_combines_created_cpp_file_with_cmake_check() {
26319        let tmp = tempfile::TempDir::new().unwrap();
26320        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26321        std::fs::write(
26322            tmp.path().join("CMakeLists.txt"),
26323            "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
26324        )
26325        .unwrap();
26326        let state = Arc::new(ServerState::standalone(
26327            tmp.path().join(".car").join("journals"),
26328        ));
26329
26330        let got = handle_goal_suggest(
26331            &req(json!({
26332                "prompt": "create src/widget.cpp",
26333                "working_dir": tmp.path().to_string_lossy()
26334            })),
26335            &state,
26336        )
26337        .await
26338        .unwrap();
26339        assert_eq!(got["suggested"], true);
26340        assert_eq!(got["confidence"], "high");
26341        let check = got["goal"]["check"].as_str().unwrap();
26342        assert!(check.contains("test -f 'src/widget.cpp'"), "{check}");
26343        assert!(
26344            check.ends_with(" && cmake -S . -B build && cmake --build build"),
26345            "{check}"
26346        );
26347        let signals = got["signals"].as_array().unwrap();
26348        assert!(signals.contains(&json!("prompt_mentions_path:src/widget.cpp")));
26349        assert!(signals.contains(&json!("CMakeLists.txt")));
26350    }
26351
26352    #[tokio::test]
26353    async fn goal_suggest_uses_ctest_for_cmake_test_prompts() {
26354        let tmp = tempfile::TempDir::new().unwrap();
26355        std::fs::write(
26356            tmp.path().join("CMakeLists.txt"),
26357            "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
26358        )
26359        .unwrap();
26360        let state = Arc::new(ServerState::standalone(
26361            tmp.path().join(".car").join("journals"),
26362        ));
26363
26364        let got = handle_goal_suggest(
26365            &req(json!({
26366                "prompt": "make the cmake unit tests pass",
26367                "working_dir": tmp.path().to_string_lossy()
26368            })),
26369            &state,
26370        )
26371        .await
26372        .unwrap();
26373        assert_eq!(got["suggested"], true);
26374        let check = got["goal"]["check"].as_str().unwrap();
26375        assert!(check.ends_with(
26376            " && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure"
26377        ), "{check}");
26378    }
26379
26380    #[tokio::test]
26381    async fn goal_suggest_detects_gradle_project() {
26382        let tmp = tempfile::TempDir::new().unwrap();
26383        std::fs::write(
26384            tmp.path().join("build.gradle.kts"),
26385            "plugins { kotlin(\"jvm\") }\n",
26386        )
26387        .unwrap();
26388        std::fs::write(tmp.path().join("gradlew"), "#!/bin/sh\n").unwrap();
26389        let state = Arc::new(ServerState::standalone(
26390            tmp.path().join(".car").join("journals"),
26391        ));
26392
26393        let got = handle_goal_suggest(
26394            &req(json!({
26395                "prompt": "make the kotlin tests pass",
26396                "working_dir": tmp.path().to_string_lossy()
26397            })),
26398            &state,
26399        )
26400        .await
26401        .unwrap();
26402        assert_eq!(got["suggested"], true);
26403        assert_eq!(got["confidence"], "high");
26404        assert!(got["goal"]["check"]
26405            .as_str()
26406            .unwrap()
26407            .ends_with(" && ./gradlew test"));
26408        let signals = got["signals"].as_array().unwrap();
26409        assert!(signals.contains(&json!("build.gradle.kts")));
26410        assert!(signals.contains(&json!("gradlew")));
26411    }
26412
26413    #[tokio::test]
26414    async fn goal_suggest_combines_created_kotlin_file_with_gradle_check() {
26415        let tmp = tempfile::TempDir::new().unwrap();
26416        std::fs::create_dir_all(tmp.path().join("app/src/main/java/com/demo")).unwrap();
26417        std::fs::write(tmp.path().join("build.gradle"), "plugins { id 'java' }\n").unwrap();
26418        let state = Arc::new(ServerState::standalone(
26419            tmp.path().join(".car").join("journals"),
26420        ));
26421
26422        let got = handle_goal_suggest(
26423            &req(json!({
26424                "prompt": "create app/src/main/java/com/demo/Main.kt",
26425                "working_dir": tmp.path().to_string_lossy()
26426            })),
26427            &state,
26428        )
26429        .await
26430        .unwrap();
26431        assert_eq!(got["suggested"], true);
26432        assert_eq!(got["confidence"], "high");
26433        let check = got["goal"]["check"].as_str().unwrap();
26434        assert!(
26435            check.contains("test -f 'app/src/main/java/com/demo/Main.kt'"),
26436            "{check}"
26437        );
26438        assert!(check.ends_with(" && gradle test"), "{check}");
26439    }
26440
26441    #[tokio::test]
26442    async fn goal_suggest_detects_maven_project() {
26443        let tmp = tempfile::TempDir::new().unwrap();
26444        std::fs::write(
26445            tmp.path().join("pom.xml"),
26446            "<project><modelVersion>4.0.0</modelVersion></project>\n",
26447        )
26448        .unwrap();
26449        std::fs::write(tmp.path().join("mvnw"), "#!/bin/sh\n").unwrap();
26450        let state = Arc::new(ServerState::standalone(
26451            tmp.path().join(".car").join("journals"),
26452        ));
26453
26454        let got = handle_goal_suggest(
26455            &req(json!({
26456                "prompt": "make the java tests pass",
26457                "working_dir": tmp.path().to_string_lossy()
26458            })),
26459            &state,
26460        )
26461        .await
26462        .unwrap();
26463        assert_eq!(got["suggested"], true);
26464        assert_eq!(got["confidence"], "high");
26465        assert!(got["goal"]["check"]
26466            .as_str()
26467            .unwrap()
26468            .ends_with(" && ./mvnw test"));
26469        let signals = got["signals"].as_array().unwrap();
26470        assert!(signals.contains(&json!("pom.xml")));
26471        assert!(signals.contains(&json!("mvnw")));
26472    }
26473
26474    #[tokio::test]
26475    async fn goal_suggest_combines_created_java_file_with_maven_check() {
26476        let tmp = tempfile::TempDir::new().unwrap();
26477        std::fs::create_dir_all(tmp.path().join("src/main/java/com/demo")).unwrap();
26478        std::fs::write(
26479            tmp.path().join("pom.xml"),
26480            "<project><modelVersion>4.0.0</modelVersion></project>\n",
26481        )
26482        .unwrap();
26483        let state = Arc::new(ServerState::standalone(
26484            tmp.path().join(".car").join("journals"),
26485        ));
26486
26487        let got = handle_goal_suggest(
26488            &req(json!({
26489                "prompt": "create src/main/java/com/demo/Main.java",
26490                "working_dir": tmp.path().to_string_lossy()
26491            })),
26492            &state,
26493        )
26494        .await
26495        .unwrap();
26496        assert_eq!(got["suggested"], true);
26497        assert_eq!(got["confidence"], "high");
26498        let check = got["goal"]["check"].as_str().unwrap();
26499        assert!(
26500            check.contains("test -f 'src/main/java/com/demo/Main.java'"),
26501            "{check}"
26502        );
26503        assert!(check.ends_with(" && mvn test"), "{check}");
26504    }
26505
26506    #[tokio::test]
26507    async fn goal_suggest_detects_dotnet_solution() {
26508        let tmp = tempfile::TempDir::new().unwrap();
26509        std::fs::write(tmp.path().join("Demo.sln"), "").unwrap();
26510        let state = Arc::new(ServerState::standalone(
26511            tmp.path().join(".car").join("journals"),
26512        ));
26513
26514        let got = handle_goal_suggest(
26515            &req(json!({
26516                "prompt": "make the csharp tests pass",
26517                "working_dir": tmp.path().to_string_lossy()
26518            })),
26519            &state,
26520        )
26521        .await
26522        .unwrap();
26523        assert_eq!(got["suggested"], true);
26524        assert_eq!(got["confidence"], "high");
26525        assert!(got["goal"]["check"]
26526            .as_str()
26527            .unwrap()
26528            .ends_with(" && dotnet test"));
26529        let signals = got["signals"].as_array().unwrap();
26530        assert!(signals.contains(&json!("Demo.sln")));
26531    }
26532
26533    #[tokio::test]
26534    async fn goal_suggest_combines_created_csharp_file_with_dotnet_check() {
26535        let tmp = tempfile::TempDir::new().unwrap();
26536        std::fs::create_dir_all(tmp.path().join("src/Demo")).unwrap();
26537        std::fs::write(
26538            tmp.path().join("src").join("Demo").join("Demo.csproj"),
26539            "<Project Sdk=\"Microsoft.NET.Sdk\" />\n",
26540        )
26541        .unwrap();
26542        let state = Arc::new(ServerState::standalone(
26543            tmp.path().join(".car").join("journals"),
26544        ));
26545
26546        let got = handle_goal_suggest(
26547            &req(json!({
26548                "prompt": "create Program.cs",
26549                "working_dir": tmp.path().join("src").join("Demo").to_string_lossy()
26550            })),
26551            &state,
26552        )
26553        .await
26554        .unwrap();
26555        assert_eq!(got["suggested"], true);
26556        assert_eq!(got["confidence"], "high");
26557        let check = got["goal"]["check"].as_str().unwrap();
26558        assert!(check.contains("test -f 'Program.cs'"), "{check}");
26559        assert!(check.ends_with(" && dotnet test"), "{check}");
26560    }
26561
26562    #[tokio::test]
26563    async fn goal_suggest_detects_php_composer_project() {
26564        let tmp = tempfile::TempDir::new().unwrap();
26565        std::fs::create_dir_all(tmp.path().join("vendor/bin")).unwrap();
26566        std::fs::write(tmp.path().join("composer.json"), r#"{"require-dev":{}}"#).unwrap();
26567        std::fs::write(
26568            tmp.path().join("vendor/bin/phpunit"),
26569            "#!/usr/bin/env php\n",
26570        )
26571        .unwrap();
26572        let state = Arc::new(ServerState::standalone(
26573            tmp.path().join(".car").join("journals"),
26574        ));
26575
26576        let got = handle_goal_suggest(
26577            &req(json!({
26578                "prompt": "make the php tests pass",
26579                "working_dir": tmp.path().to_string_lossy()
26580            })),
26581            &state,
26582        )
26583        .await
26584        .unwrap();
26585        assert_eq!(got["suggested"], true);
26586        assert_eq!(got["confidence"], "high");
26587        assert!(got["goal"]["check"]
26588            .as_str()
26589            .unwrap()
26590            .ends_with(" && vendor/bin/phpunit"));
26591        let signals = got["signals"].as_array().unwrap();
26592        assert!(signals.contains(&json!("composer.json")));
26593        assert!(signals.contains(&json!("phpunit")));
26594    }
26595
26596    #[tokio::test]
26597    async fn goal_suggest_combines_created_php_file_with_composer_script() {
26598        let tmp = tempfile::TempDir::new().unwrap();
26599        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26600        std::fs::write(
26601            tmp.path().join("composer.json"),
26602            r#"{"scripts":{"test":"phpunit"}}"#,
26603        )
26604        .unwrap();
26605        let state = Arc::new(ServerState::standalone(
26606            tmp.path().join(".car").join("journals"),
26607        ));
26608
26609        let got = handle_goal_suggest(
26610            &req(json!({
26611                "prompt": "create src/App.php",
26612                "working_dir": tmp.path().to_string_lossy()
26613            })),
26614            &state,
26615        )
26616        .await
26617        .unwrap();
26618        assert_eq!(got["suggested"], true);
26619        assert_eq!(got["confidence"], "high");
26620        let check = got["goal"]["check"].as_str().unwrap();
26621        assert!(check.contains("test -f 'src/App.php'"), "{check}");
26622        assert!(check.ends_with(" && composer test"), "{check}");
26623        let signals = got["signals"].as_array().unwrap();
26624        assert!(signals.contains(&json!("composer_script:test")));
26625    }
26626
26627    #[tokio::test]
26628    async fn goal_suggest_uses_php_lint_when_composer_has_no_test_signal() {
26629        let tmp = tempfile::TempDir::new().unwrap();
26630        std::fs::write(tmp.path().join("composer.json"), r#"{"scripts":{}}"#).unwrap();
26631        let state = Arc::new(ServerState::standalone(
26632            tmp.path().join(".car").join("journals"),
26633        ));
26634
26635        let got = handle_goal_suggest(
26636            &req(json!({
26637                "prompt": "make the php project valid",
26638                "working_dir": tmp.path().to_string_lossy()
26639            })),
26640            &state,
26641        )
26642        .await
26643        .unwrap();
26644        assert_eq!(got["suggested"], true);
26645        assert_eq!(got["confidence"], "medium");
26646        let check = got["goal"]["check"].as_str().unwrap();
26647        assert!(
26648            check.ends_with(
26649                " && find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l"
26650            ),
26651            "{check}"
26652        );
26653        let signals = got["signals"].as_array().unwrap();
26654        assert!(!signals.contains(&json!("composer_script:test")));
26655    }
26656
26657    #[tokio::test]
26658    async fn goal_suggest_detects_ruby_bundler_project() {
26659        let tmp = tempfile::TempDir::new().unwrap();
26660        std::fs::write(
26661            tmp.path().join("Gemfile"),
26662            "source 'https://rubygems.org'\n",
26663        )
26664        .unwrap();
26665        std::fs::write(tmp.path().join("Rakefile"), "task :test\n").unwrap();
26666        let state = Arc::new(ServerState::standalone(
26667            tmp.path().join(".car").join("journals"),
26668        ));
26669
26670        let got = handle_goal_suggest(
26671            &req(json!({
26672                "prompt": "make the ruby tests pass",
26673                "working_dir": tmp.path().to_string_lossy()
26674            })),
26675            &state,
26676        )
26677        .await
26678        .unwrap();
26679        assert_eq!(got["suggested"], true);
26680        assert_eq!(got["confidence"], "high");
26681        assert!(got["goal"]["check"]
26682            .as_str()
26683            .unwrap()
26684            .ends_with(" && bundle exec rake test"));
26685        let signals = got["signals"].as_array().unwrap();
26686        assert!(signals.contains(&json!("Gemfile")));
26687        assert!(signals.contains(&json!("Rakefile")));
26688    }
26689
26690    #[tokio::test]
26691    async fn goal_suggest_uses_ruby_syntax_check_without_test_runner_signal() {
26692        let tmp = tempfile::TempDir::new().unwrap();
26693        std::fs::write(
26694            tmp.path().join("Gemfile"),
26695            "source 'https://rubygems.org'\n",
26696        )
26697        .unwrap();
26698        let state = Arc::new(ServerState::standalone(
26699            tmp.path().join(".car").join("journals"),
26700        ));
26701
26702        let got = handle_goal_suggest(
26703            &req(json!({
26704                "prompt": "make the ruby project valid",
26705                "working_dir": tmp.path().to_string_lossy()
26706            })),
26707            &state,
26708        )
26709        .await
26710        .unwrap();
26711        assert_eq!(got["suggested"], true);
26712        assert_eq!(got["confidence"], "medium");
26713        let check = got["goal"]["check"].as_str().unwrap();
26714        assert!(
26715            check.ends_with(
26716                " && find . -name '*.rb' -not -path './vendor/*' -print0 | xargs -0 -n1 ruby -c"
26717            ),
26718            "{check}"
26719        );
26720        let signals = got["signals"].as_array().unwrap();
26721        assert!(signals.contains(&json!("Gemfile")));
26722        assert!(!signals.contains(&json!("Rakefile")));
26723    }
26724
26725    #[tokio::test]
26726    async fn goal_suggest_combines_created_elixir_file_with_mix_check() {
26727        let tmp = tempfile::TempDir::new().unwrap();
26728        std::fs::create_dir_all(tmp.path().join("lib/demo")).unwrap();
26729        std::fs::write(
26730            tmp.path().join("mix.exs"),
26731            "defmodule Demo.MixProject do\nend\n",
26732        )
26733        .unwrap();
26734        let state = Arc::new(ServerState::standalone(
26735            tmp.path().join(".car").join("journals"),
26736        ));
26737
26738        let got = handle_goal_suggest(
26739            &req(json!({
26740                "prompt": "create lib/demo/worker.ex",
26741                "working_dir": tmp.path().to_string_lossy()
26742            })),
26743            &state,
26744        )
26745        .await
26746        .unwrap();
26747        assert_eq!(got["suggested"], true);
26748        assert_eq!(got["confidence"], "high");
26749        let check = got["goal"]["check"].as_str().unwrap();
26750        assert!(check.contains("test -f 'lib/demo/worker.ex'"), "{check}");
26751        assert!(check.ends_with(" && mix test"), "{check}");
26752    }
26753
26754    #[tokio::test]
26755    async fn goal_suggest_falls_through_when_package_has_no_scripts() {
26756        let tmp = tempfile::TempDir::new().unwrap();
26757        std::fs::write(tmp.path().join("package.json"), r#"{"scripts":{}}"#).unwrap();
26758        std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
26759        let state = Arc::new(ServerState::standalone(
26760            tmp.path().join(".car").join("journals"),
26761        ));
26762
26763        let got = handle_goal_suggest(
26764            &req(json!({
26765                "prompt": "make it work",
26766                "working_dir": tmp.path().to_string_lossy()
26767            })),
26768            &state,
26769        )
26770        .await
26771        .unwrap();
26772        assert_eq!(got["suggested"], true);
26773        assert!(got["goal"]["check"]
26774            .as_str()
26775            .unwrap()
26776            .ends_with(" && go test ./..."));
26777        let warnings = got["warnings"].as_array().unwrap();
26778        assert!(warnings.contains(&json!(
26779            "package.json has no test/typecheck/check/build/lint script"
26780        )));
26781    }
26782
26783    #[tokio::test]
26784    async fn goal_suggest_refuses_to_invent_a_weak_check() {
26785        let tmp = tempfile::TempDir::new().unwrap();
26786        let state = Arc::new(ServerState::standalone(
26787            tmp.path().join(".car").join("journals"),
26788        ));
26789        let got = handle_goal_suggest(
26790            &req(json!({
26791                "prompt": "make it better",
26792                "working_dir": tmp.path().to_string_lossy()
26793            })),
26794            &state,
26795        )
26796        .await
26797        .unwrap();
26798        assert_eq!(got["suggested"], false);
26799        assert!(got["goal"].is_null());
26800    }
26801}
26802
26803#[cfg(test)]
26804mod metrics_alert_surface {
26805    use super::{handle_metrics_alerts, JsonRpcMessage};
26806    use crate::session::{ServerState, WsChannel};
26807    use car_verify::goal::GoalCondition;
26808    use serde_json::{json, Value};
26809    use std::sync::Arc;
26810
26811    fn req(params: Value) -> JsonRpcMessage {
26812        JsonRpcMessage {
26813            jsonrpc: "2.0".into(),
26814            method: None,
26815            params,
26816            id: json!(1),
26817            result: None,
26818            error: None,
26819        }
26820    }
26821
26822    #[tokio::test]
26823    async fn metrics_alerts_returns_goal_ungrounded() {
26824        let tmp = tempfile::TempDir::new().unwrap();
26825        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26826        let session = state
26827            .create_session("metrics-alerts", Arc::new(WsChannel::test_stub()))
26828            .await
26829            .unwrap();
26830        let condition = GoalCondition::Command {
26831            id: "tests".to_string(),
26832            expect_exit: 0,
26833        };
26834        session
26835            .runtime
26836            .record_goal_evaluated(
26837                "make tests pass",
26838                &condition,
26839                1,
26840                true,
26841                false,
26842                "ungrounded assistant summary claim(s): tests were run/passed",
26843                "mlx/qwen3-8b:4bit",
26844            )
26845            .await;
26846
26847        let got = handle_metrics_alerts(&req(json!({"max_goals_ungrounded": 0})), &session)
26848            .await
26849            .unwrap();
26850        assert_eq!(got["summary"]["goals_ungrounded"], json!(1));
26851        assert_eq!(got["alerts"][0]["kind"], json!("goal_ungrounded"));
26852        assert_eq!(got["alerts"][0]["observed"], json!(1.0));
26853        assert_eq!(got["alerts"][0]["threshold"], json!(0.0));
26854    }
26855}
26856
26857#[cfg(test)]
26858mod tool_registry_surface {
26859    //! `tools.list` / `tools.unregister` (Parslee-ai/car#892) — the enumerate
26860    //! and removal halves that `tools.register` shipped without, mirroring
26861    //! `policy.list` / `policy.unregister` (Parslee-ai/car#623).
26862    //!
26863    //! Each WS connection gets its own `car_engine::Runtime`, so the listed set
26864    //! is a per-session fact and can be asserted literally rather than
26865    //! approximately.
26866    //!
26867    //! A fresh daemon session is **not** empty, which is worth pinning here
26868    //! because it is the sort of thing this surface exists to reveal:
26869    //! `create_session` builds its runtime with `.with_message_sink(...)`, and
26870    //! `Runtime::with_message_sink` registers the `messaging.send` built-in
26871    //! into both the registry and the legacy schema map — sink and schema
26872    //! arrive together by design, so a tool the runtime cannot execute is never
26873    //! advertised. The commodity stdlib is absent until the session asks for
26874    //! it — both `session.bindSubstrate` and `session.bindSandbox` register
26875    //! it, and neither runs here. So the baseline is exactly
26876    //! `["messaging.send"]`, and the assertions below are exact vectors over
26877    //! that baseline plus whatever the test registered.
26878    use super::{
26879        handle_tools_list, handle_tools_register, handle_tools_unregister, JsonRpcMessage,
26880    };
26881    use crate::session::{ServerState, WsChannel};
26882    use serde_json::{json, Value};
26883    use std::sync::Arc;
26884
26885    fn req(params: Value) -> JsonRpcMessage {
26886        JsonRpcMessage {
26887            jsonrpc: "2.0".into(),
26888            method: None,
26889            params,
26890            id: json!(1),
26891            result: None,
26892            error: None,
26893        }
26894    }
26895
26896    async fn session(client_id: &str) -> Arc<crate::session::ClientSession> {
26897        let tmp = tempfile::TempDir::new().unwrap();
26898        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26899        state
26900            .create_session(client_id, Arc::new(WsChannel::test_stub()))
26901            .await
26902            .unwrap()
26903    }
26904
26905    /// Names in `tools.list` order, so assertions can be literal vectors.
26906    fn names(listed: &Value) -> Vec<String> {
26907        listed["tools"]
26908            .as_array()
26909            .expect("tools must be an array")
26910            .iter()
26911            .map(|t| {
26912                t["name"]
26913                    .as_str()
26914                    .expect("name must be a string")
26915                    .to_string()
26916            })
26917            .collect()
26918    }
26919
26920    /// The session baseline: a connection that registered nothing still has the
26921    /// daemon's `messaging.send` built-in, and `tools.list` says so. Asserting
26922    /// the exact vector (not just a count) is the point — a client auditing its
26923    /// own session needs to see the tool it never registered.
26924    #[tokio::test]
26925    async fn fresh_session_lists_only_the_built_in_messaging_send() {
26926        let session = session("c-tools-list-empty").await;
26927        let listed = handle_tools_list(&session).await.unwrap();
26928        assert_eq!(listed["count"], json!(1));
26929        assert_eq!(names(&listed), vec!["messaging.send"]);
26930        assert_eq!(listed["tools"][0]["source"], json!("builtin"));
26931    }
26932
26933    /// The goal's verifiable target: after registering exactly two tools, the
26934    /// wire surface reports exactly those two on top of the session baseline,
26935    /// in sorted order — which is what makes it usable as proof of a governed
26936    /// session's effective toolset.
26937    #[tokio::test]
26938    async fn list_returns_exactly_the_two_registered_tools_over_the_baseline_in_sorted_order() {
26939        let session = session("c-tools-list-two").await;
26940
26941        // Registered out of alphabetical order on purpose: the sort is the
26942        // contract, not an accident of insertion order. `messaging.send` was
26943        // registered first of all (at runtime construction) and still sorts
26944        // first here, and `write_file`/`read_file` come back swapped relative
26945        // to how they went in — so the ordering below cannot be insertion order.
26946        let registered = handle_tools_register(
26947            &req(json!([
26948                { "name": "write_file" },
26949                { "name": "read_file" },
26950            ])),
26951            &session,
26952        )
26953        .await
26954        .unwrap();
26955        assert_eq!(registered, json!(2));
26956
26957        let listed = handle_tools_list(&session).await.unwrap();
26958        assert_eq!(listed["count"], json!(3));
26959        assert_eq!(
26960            names(&listed),
26961            vec!["messaging.send", "read_file", "write_file"]
26962        );
26963    }
26964
26965    #[tokio::test]
26966    async fn register_rejects_a_server_owned_tool_name_without_changing_its_source() {
26967        let session = session("c-tools-register-shadow").await;
26968        let error = handle_tools_register(
26969            &req(json!([{ "name": "messaging.send", "description": "shadow" }])),
26970            &session,
26971        )
26972        .await
26973        .expect_err("callback registration must not shadow a built-in");
26974        assert_eq!(
26975            error,
26976            "tool 'messaging.send' is server-owned and cannot be shadowed by tools.register"
26977        );
26978
26979        let listed = handle_tools_list(&session).await.unwrap();
26980        assert_eq!(listed["count"], json!(1));
26981        assert_eq!(names(&listed), vec!["messaging.send"]);
26982        assert_eq!(listed["tools"][0]["source"], json!("builtin"));
26983    }
26984
26985    #[tokio::test]
26986    async fn unregister_drops_one_tool_and_list_reports_the_remainder() {
26987        let session = session("c-tools-unregister").await;
26988        handle_tools_register(
26989            &req(json!([{ "name": "read_file" }, { "name": "write_file" }])),
26990            &session,
26991        )
26992        .await
26993        .unwrap();
26994
26995        let dropped = handle_tools_unregister(&req(json!({ "name": "write_file" })), &session)
26996            .await
26997            .unwrap();
26998        assert_eq!(dropped["unregistered"], json!("write_file"));
26999        assert_eq!(dropped["removed"], json!(1));
27000
27001        let listed = handle_tools_list(&session).await.unwrap();
27002        assert_eq!(listed["count"], json!(2));
27003        assert_eq!(names(&listed), vec!["messaging.send", "read_file"]);
27004    }
27005
27006    /// Unknown tool → `removed: 0` and `Ok`, never `Err`, so cleanup can call
27007    /// this unconditionally without listing first (`policy.unregister`'s
27008    /// contract). Missing `name` is still an error, house style.
27009    #[tokio::test]
27010    async fn unregistering_an_unknown_tool_reports_zero_rather_than_erroring() {
27011        let session = session("c-tools-unregister-unknown").await;
27012
27013        let dropped =
27014            handle_tools_unregister(&req(json!({ "name": "never_registered" })), &session)
27015                .await
27016                .expect("unknown tool must not be an error");
27017        assert_eq!(dropped["unregistered"], json!("never_registered"));
27018        assert_eq!(dropped["removed"], json!(0));
27019
27020        assert_eq!(
27021            handle_tools_unregister(&req(json!({})), &session)
27022                .await
27023                .unwrap_err(),
27024            "missing 'name'"
27025        );
27026    }
27027
27028    /// The surface proves the *effective* toolset, not a name list: the full
27029    /// schema a caller registered comes back, parameters included.
27030    #[tokio::test]
27031    async fn list_round_trips_the_full_tool_schema() {
27032        let session = session("c-tools-list-schema").await;
27033        handle_tools_register(
27034            &req(json!([{
27035                "name": "read_file",
27036                "description": "Read a UTF-8 file from disk",
27037                "parameters": {
27038                    "type": "object",
27039                    "properties": { "path": { "type": "string" } },
27040                    "required": ["path"],
27041                },
27042                "idempotent": true,
27043            }])),
27044            &session,
27045        )
27046        .await
27047        .unwrap();
27048
27049        let listed = handle_tools_list(&session).await.unwrap();
27050        let tool = listed["tools"]
27051            .as_array()
27052            .unwrap()
27053            .iter()
27054            .find(|t| t["name"] == json!("read_file"))
27055            .expect("read_file must be listed");
27056        assert_eq!(tool["name"], json!("read_file"));
27057        assert_eq!(tool["source"], json!("user_defined"));
27058        assert_eq!(tool["description"], json!("Read a UTF-8 file from disk"));
27059        assert_eq!(tool["idempotent"], json!(true));
27060        assert_eq!(
27061            tool["parameters"],
27062            json!({
27063                "type": "object",
27064                "properties": { "path": { "type": "string" } },
27065                "required": ["path"],
27066            })
27067        );
27068    }
27069
27070    /// `ToolDefinition` deliberately omits `source` — the runtime assigns
27071    /// `user_defined` server-side rather than trusting the caller. This pins
27072    /// that security boundary end-to-end: a registration payload carrying a
27073    /// spoofed `"source": "builtin"` must parse (serde ignores the unknown
27074    /// field) but the stored/listed schema still reports `user_defined`.
27075    #[tokio::test]
27076    async fn registered_tool_cannot_claim_a_source_it_did_not_earn() {
27077        let session = session("c-tools-spoofed-source").await;
27078        handle_tools_register(
27079            &req(json!([{
27080                "name": "read_file",
27081                "description": "Claims to be a builtin",
27082                "source": "builtin",
27083                "parameters": {},
27084            }])),
27085            &session,
27086        )
27087        .await
27088        .expect("an unknown field must not fail registration");
27089
27090        let listed = handle_tools_list(&session).await.unwrap();
27091        let tool = listed["tools"]
27092            .as_array()
27093            .unwrap()
27094            .iter()
27095            .find(|t| t["name"] == json!("read_file"))
27096            .expect("read_file must be listed");
27097        assert_eq!(
27098            tool["source"],
27099            json!("user_defined"),
27100            "the runtime assigns user_defined; a client cannot claim builtin"
27101        );
27102    }
27103}
27104
27105#[cfg(test)]
27106mod tool_stream_surface {
27107    //! C2 WS surface: `tools.poll` drains a detached handle's chunks and
27108    //! reports status, `tools.cancel` seals it. An unknown handle polls to
27109    //! `null` (absence, not an error) and cancels to `{cancelled: false}`.
27110    use super::{handle_tools_cancel, handle_tools_poll, JsonRpcMessage};
27111    use crate::session::{ServerState, WsChannel};
27112    use serde_json::{json, Value};
27113    use std::sync::Arc;
27114
27115    fn req(params: Value) -> JsonRpcMessage {
27116        JsonRpcMessage {
27117            jsonrpc: "2.0".into(),
27118            method: None,
27119            params,
27120            id: json!(1),
27121            result: None,
27122            error: None,
27123        }
27124    }
27125
27126    #[tokio::test]
27127    async fn poll_and_cancel_roundtrip() {
27128        let tmp = tempfile::TempDir::new().unwrap();
27129        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27130        let channel = Arc::new(WsChannel::test_stub());
27131        let session = state.create_session("c-tools-c2", channel).await.unwrap();
27132
27133        // Unknown handle → null, not an error.
27134        let poll = handle_tools_poll(&req(json!({"handle": "nope"})), &session)
27135            .await
27136            .unwrap();
27137        assert!(poll.is_null(), "unknown handle must poll to null");
27138
27139        // Missing param → error (house style).
27140        assert!(handle_tools_poll(&req(json!({})), &session).await.is_err());
27141
27142        // Start a detached invocation on the session runtime's registry —
27143        // the same path a detached ToolCall dispatch takes.
27144        let (h, _tok) = session
27145            .runtime
27146            .tool_handles
27147            .register("tail_log", "a1")
27148            .await;
27149        session
27150            .runtime
27151            .tool_handles
27152            .push_chunk(
27153                &h.id,
27154                car_ir::ToolStreamChunk::Text {
27155                    text: "line".into(),
27156                },
27157            )
27158            .await;
27159
27160        let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
27161            .await
27162            .unwrap();
27163        assert_eq!(poll["status"], "running");
27164        assert_eq!(poll["chunks"][0]["kind"], "text");
27165        assert_eq!(poll["chunks"][0]["text"], "line");
27166        assert_eq!(poll["tool"], "tail_log");
27167
27168        // Cancel seals the invocation; a subsequent poll observes it.
27169        let cancel = handle_tools_cancel(&req(json!({"handle": h.id})), &session)
27170            .await
27171            .unwrap();
27172        assert_eq!(cancel["cancelled"], true);
27173        let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
27174            .await
27175            .unwrap();
27176        assert_eq!(poll["status"], "cancelled");
27177
27178        // Unknown handle cancels to false.
27179        let cancel = handle_tools_cancel(&req(json!({"handle": "nope"})), &session)
27180            .await
27181            .unwrap();
27182        assert_eq!(cancel["cancelled"], false);
27183    }
27184}
27185
27186#[cfg(test)]
27187mod chat_cancel_surface {
27188    use super::{handle_agents_chat_cancel, remove_owned_external_chat_session, JsonRpcMessage};
27189    use crate::session::{ChatSession, ServerState};
27190    use serde_json::{json, Value};
27191    use std::sync::atomic::{AtomicBool, Ordering};
27192    use std::sync::Arc;
27193
27194    fn req(params: Value) -> JsonRpcMessage {
27195        JsonRpcMessage {
27196            jsonrpc: "2.0".into(),
27197            method: None,
27198            params,
27199            id: json!(1),
27200            result: None,
27201            error: None,
27202        }
27203    }
27204
27205    #[tokio::test]
27206    async fn cancel_sets_local_declarative_flag_and_drops_session() {
27207        let tmp = tempfile::TempDir::new().unwrap();
27208        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27209        let flag = Arc::new(AtomicBool::new(false));
27210        state.chat_sessions.lock().await.insert(
27211            "decl-chat".to_string(),
27212            ChatSession {
27213                agent_id: "writer".to_string(),
27214                host_client_id: "host-1".to_string(),
27215                created_at: 0,
27216                local_cancel: Some(flag.clone()),
27217            },
27218        );
27219
27220        let cancel = handle_agents_chat_cancel(
27221            &req(json!({"session_id": "decl-chat"})),
27222            &state,
27223            "host",
27224            true,
27225        )
27226        .await
27227        .unwrap();
27228        assert_eq!(cancel["cancelled"], true);
27229        assert!(flag.load(Ordering::SeqCst));
27230        assert!(!state.chat_sessions.lock().await.contains_key("decl-chat"));
27231
27232        let again = handle_agents_chat_cancel(
27233            &req(json!({"session_id": "decl-chat"})),
27234            &state,
27235            "host",
27236            true,
27237        )
27238        .await
27239        .unwrap();
27240        assert_eq!(again["cancelled"], false);
27241    }
27242
27243    #[tokio::test]
27244    async fn external_cleanup_preserves_proxied_chat_route() {
27245        let tmp = tempfile::TempDir::new().unwrap();
27246        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27247        let flag = Arc::new(AtomicBool::new(false));
27248        state.chat_sessions.lock().await.insert(
27249            "proxied-chat".to_string(),
27250            ChatSession {
27251                agent_id: "car-assistant".to_string(),
27252                host_client_id: "host-1".to_string(),
27253                created_at: 0,
27254                local_cancel: Some(flag),
27255            },
27256        );
27257
27258        remove_owned_external_chat_session(&state, "proxied-chat", false).await;
27259        assert!(state
27260            .chat_sessions
27261            .lock()
27262            .await
27263            .contains_key("proxied-chat"));
27264
27265        remove_owned_external_chat_session(&state, "proxied-chat", true).await;
27266        assert!(!state
27267            .chat_sessions
27268            .lock()
27269            .await
27270            .contains_key("proxied-chat"));
27271    }
27272}
27273
27274#[cfg(test)]
27275mod assistant_agent_alias_surface {
27276    //! car#1107: `agents.chat` must resolve either spelling of the flagship
27277    //! assistant's id — `parslee-core` (canonical since car#1107, and what
27278    //! mobile has always sent) or `car-assistant` (the pre-car#1107 id macOS
27279    //! still hardcodes) — to whichever one the daemon actually has attached.
27280    use super::resolve_assistant_agent_alias;
27281    use crate::session::ServerState;
27282    use std::sync::Arc;
27283
27284    fn state() -> Arc<ServerState> {
27285        let tmp = tempfile::TempDir::new().unwrap();
27286        Arc::new(ServerState::standalone(tmp.path().to_path_buf()))
27287    }
27288
27289    #[tokio::test]
27290    async fn legacy_id_resolves_to_canonical_when_only_canonical_attached() {
27291        let state = state();
27292        state
27293            .attached_agents
27294            .lock()
27295            .await
27296            .insert("parslee-core".to_string(), "client-1".to_string());
27297
27298        assert_eq!(
27299            resolve_assistant_agent_alias("car-assistant".to_string(), &state).await,
27300            "parslee-core"
27301        );
27302    }
27303
27304    #[tokio::test]
27305    async fn canonical_id_resolves_to_legacy_when_only_legacy_attached() {
27306        let state = state();
27307        state
27308            .attached_agents
27309            .lock()
27310            .await
27311            .insert("car-assistant".to_string(), "client-1".to_string());
27312
27313        assert_eq!(
27314            resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
27315            "car-assistant"
27316        );
27317    }
27318
27319    #[tokio::test]
27320    async fn exact_match_is_never_rewritten() {
27321        let state = state();
27322        state
27323            .attached_agents
27324            .lock()
27325            .await
27326            .insert("parslee-core".to_string(), "client-1".to_string());
27327
27328        assert_eq!(
27329            resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
27330            "parslee-core"
27331        );
27332    }
27333
27334    #[tokio::test]
27335    async fn non_assistant_id_is_left_untouched_even_if_unattached() {
27336        let state = state();
27337        assert_eq!(
27338            resolve_assistant_agent_alias("some-other-agent".to_string(), &state).await,
27339            "some-other-agent"
27340        );
27341    }
27342
27343    #[tokio::test]
27344    async fn assistant_id_is_left_untouched_when_neither_spelling_attached() {
27345        let state = state();
27346        assert_eq!(
27347            resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
27348            "parslee-core"
27349        );
27350    }
27351}
27352
27353#[cfg(test)]
27354mod chat_approve_surface {
27355    //! `agents.chat.approve` (#483): resolve an inline chat-turn approval by
27356    //! reverse-requesting the agent's `agent.chat.approve`. The full reply
27357    //! round-trip needs a live agent connection (exercised end-to-end by the
27358    //! `assistant::chat` tests + the CLI); here we lock down the param
27359    //! validation and session-routing preconditions the handler enforces before
27360    //! it ever touches an agent channel.
27361    use super::{handle_agents_chat_approve, JsonRpcMessage};
27362    use crate::session::{ChatSession, ServerState};
27363    use serde_json::{json, Value};
27364    use std::sync::Arc;
27365
27366    fn req(params: Value) -> JsonRpcMessage {
27367        JsonRpcMessage {
27368            jsonrpc: "2.0".into(),
27369            method: None,
27370            params,
27371            id: json!(1),
27372            result: None,
27373            error: None,
27374        }
27375    }
27376
27377    #[tokio::test]
27378    async fn requires_session_and_approval_ids() {
27379        let tmp = tempfile::TempDir::new().unwrap();
27380        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27381
27382        // Missing session_id.
27383        let err =
27384            handle_agents_chat_approve(&req(json!({"approval_id": "a1"})), &state, "host-1", false)
27385                .await
27386                .unwrap_err();
27387        assert!(err.contains("session_id"), "{err}");
27388
27389        // Missing approval_id.
27390        let err =
27391            handle_agents_chat_approve(&req(json!({"session_id": "s1"})), &state, "host-1", false)
27392                .await
27393                .unwrap_err();
27394        assert!(err.contains("approval_id"), "{err}");
27395    }
27396
27397    #[tokio::test]
27398    async fn unknown_session_is_rejected() {
27399        let tmp = tempfile::TempDir::new().unwrap();
27400        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27401        let err = handle_agents_chat_approve(
27402            &req(json!({"session_id": "ghost", "approval_id": "a1", "decision": true})),
27403            &state,
27404            "host-1",
27405            false,
27406        )
27407        .await
27408        .unwrap_err();
27409        assert!(err.contains("unknown or already-finished"), "{err}");
27410    }
27411
27412    /// car#1295 / car#1296. A session that authenticated AS an agent is
27413    /// authoritative about who it is, so a caller-supplied id naming a
27414    /// different agent is a forgery attempt, not a request to honor.
27415    #[test]
27416    fn a_bound_session_may_act_only_as_itself() {
27417        assert!(super::require_own_agent(Some("a"), "m", "a").is_ok());
27418        assert!(super::require_own_agent(Some("a"), "m", "b").is_err());
27419        // Compared exactly: an agent id is a filename-safe key the supervisor
27420        // stores verbatim, not a display name to be folded or trimmed.
27421        assert!(super::require_own_agent(Some("a"), "m", "A").is_err());
27422        assert!(super::require_own_agent(Some("a"), "m", "a ").is_err());
27423    }
27424
27425    /// An UNBOUND session passes this identity-only helper for operator reads
27426    /// and lease/sync calls. Mutating lifecycle handlers add host-aware gates.
27427    #[test]
27428    fn an_unbound_session_is_not_restricted_by_this_rule() {
27429        assert!(super::require_own_agent(None, "m", "a").is_ok());
27430        assert!(super::require_own_agent(None, "m", "anything-at-all").is_ok());
27431    }
27432
27433    /// The refusal names the id the CALLER supplied, which is theirs already,
27434    /// and is returned before any lookup — so unlike `authorize_run_access`'s
27435    /// FIX 3 it cannot answer "does that agent exist?".
27436    #[test]
27437    fn the_refusal_says_which_call_was_refused_without_revealing_anything() {
27438        let err = super::require_own_agent(Some("a"), "agents.wait", "b").unwrap_err();
27439        assert!(err.contains("agents.wait"), "{err}");
27440        assert!(err.contains("may only act on itself"), "{err}");
27441        assert!(!err.contains("  "), "collapsed continuation: {err:?}");
27442        assert!(super::require_own_agent(Some("a"), "agents.wait", "a").is_ok());
27443        assert!(super::require_own_agent(None, "agents.wait", "b").is_ok());
27444    }
27445
27446    #[tokio::test]
27447    async fn caller_must_be_originating_host_or_host_management() {
27448        let tmp = tempfile::TempDir::new().unwrap();
27449        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27450        state.chat_sessions.lock().await.insert(
27451            "s1".to_string(),
27452            ChatSession {
27453                agent_id: "car-assistant".to_string(),
27454                host_client_id: "host-1".to_string(),
27455                created_at: 0,
27456                local_cancel: None,
27457            },
27458        );
27459
27460        let err = handle_agents_chat_approve(
27461            &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
27462            &state,
27463            "agent-client",
27464            false,
27465        )
27466        .await
27467        .unwrap_err();
27468        assert!(
27469            err.contains("originating host session or host-management role"),
27470            "{err}"
27471        );
27472        assert!(state.chat_sessions.lock().await.contains_key("s1"));
27473    }
27474
27475    #[tokio::test]
27476    async fn known_session_but_detached_agent_is_rejected() {
27477        let tmp = tempfile::TempDir::new().unwrap();
27478        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27479        // Register a chat session whose agent never attached (`attached_agents`
27480        // has no entry), so routing must fail cleanly rather than hang.
27481        state.chat_sessions.lock().await.insert(
27482            "s1".to_string(),
27483            ChatSession {
27484                agent_id: "car-assistant".to_string(),
27485                host_client_id: "host-1".to_string(),
27486                created_at: 0,
27487                local_cancel: None,
27488            },
27489        );
27490        let err = handle_agents_chat_approve(
27491            &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
27492            &state,
27493            "host-1",
27494            false,
27495        )
27496        .await
27497        .unwrap_err();
27498        assert!(err.contains("not attached"), "{err}");
27499        // The session is NOT dropped by a failed approve (unlike cancel).
27500        assert!(state.chat_sessions.lock().await.contains_key("s1"));
27501    }
27502
27503    #[tokio::test]
27504    async fn host_management_may_resolve_any_chat_approval() {
27505        let tmp = tempfile::TempDir::new().unwrap();
27506        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27507        state.chat_sessions.lock().await.insert(
27508            "s1".to_string(),
27509            ChatSession {
27510                agent_id: "car-assistant".to_string(),
27511                host_client_id: "host-1".to_string(),
27512                created_at: 0,
27513                local_cancel: None,
27514            },
27515        );
27516
27517        let err = handle_agents_chat_approve(
27518            &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
27519            &state,
27520            "host-admin",
27521            true,
27522        )
27523        .await
27524        .unwrap_err();
27525        assert!(
27526            err.contains("not attached"),
27527            "host-management should pass authorization and fail only at routing: {err}"
27528        );
27529    }
27530}
27531
27532#[cfg(test)]
27533mod sync_lease_surface {
27534    //! The `sync.*` / `lease.*` daemon dispatch surface (B6). Drives the
27535    //! handlers directly against a standalone `ServerState` (rooted at a tmp dir,
27536    //! so the sync subsystem opens under `<tmp>/sync/`), proving the WS wiring,
27537    //! transcript resume, the dispatch fence, and cross-call lease visibility on
27538    //! the one daemon-held subsystem. The two-device convergence + fencing
27539    //! mechanics themselves are unit-tested in `crate::sync`.
27540    use super::{
27541        acquire_lease, check_sync_fence, handle_lease_status, handle_sync_append,
27542        handle_sync_record_turn, handle_sync_resume, handle_sync_status, record_sync_intent,
27543        release_lease, JsonRpcMessage,
27544    };
27545    use crate::session::ServerState;
27546    use serde_json::{json, Value};
27547    use std::sync::Arc;
27548
27549    fn req(params: Value) -> JsonRpcMessage {
27550        JsonRpcMessage {
27551            jsonrpc: "2.0".into(),
27552            method: None,
27553            params,
27554            id: json!(1),
27555            result: None,
27556            error: None,
27557        }
27558    }
27559
27560    #[tokio::test]
27561    async fn sync_and_lease_dispatch_roundtrip() {
27562        let tmp = tempfile::TempDir::new().unwrap();
27563        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27564
27565        // sync.status lazily opens the subsystem and reports a real device id.
27566        let status = handle_sync_status(&state).await.unwrap();
27567        assert!(status["device_id"].as_str().unwrap().starts_with("device-"));
27568
27569        // Record a knowledge op + a conversation turn through the oplog.
27570        handle_sync_append(
27571            &req(json!({"surface": "knowledge", "payload": {"id": "f1", "body": "sky"}})),
27572            &state,
27573        )
27574        .await
27575        .unwrap();
27576        handle_sync_record_turn(
27577            &req(json!({"conversation_id": "c1", "role": "user", "content": "hi", "timestamp": 1})),
27578            &state,
27579        )
27580        .await
27581        .unwrap();
27582
27583        // sync.resume is real: the turn comes back as a provider-valid message.
27584        let resume = handle_sync_resume(&req(json!({"conversation_id": "c1"})), &state)
27585            .await
27586            .unwrap();
27587        let msgs = resume.as_array().unwrap();
27588        assert_eq!(msgs.len(), 1);
27589        assert_eq!(msgs[0]["role"], json!("user"));
27590
27591        // lease.acquire → status reflects the same daemon-held holder. These
27592        // operation cores are called after the production handler's session
27593        // authorization; the real WS boundary is covered by
27594        // `bound_agent_identity_ws`.
27595        let lease = acquire_lease(
27596            &req(json!({"agent_id": "milo", "ttl_ms": 1_000_000})),
27597            &state,
27598            "milo",
27599        )
27600        .await
27601        .unwrap();
27602        assert_eq!(lease["epoch"], json!(1));
27603        let ls = handle_lease_status(&req(json!({"agent_id": "milo"})), &state, Some("milo"))
27604            .await
27605            .unwrap();
27606        assert_eq!(ls["lease"]["holder"], lease["holder"]);
27607
27608        // The holder may dispatch run r1 (no prior commit).
27609        let fence = check_sync_fence(
27610            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
27611            &state,
27612            "milo",
27613        )
27614        .await
27615        .unwrap();
27616        assert_eq!(fence["may_dispatch"], json!(true));
27617
27618        // Record r1 committed; the fence now refuses re-dispatch (idempotency).
27619        record_sync_intent(
27620            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1, "status": "committed"})),
27621            &state,
27622            "milo",
27623        )
27624        .await
27625        .unwrap();
27626        let fence = check_sync_fence(
27627            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
27628            &state,
27629            "milo",
27630        )
27631        .await
27632        .unwrap();
27633        assert_eq!(fence["decision"]["decision"], json!("already_committed"));
27634        assert_eq!(fence["may_dispatch"], json!(false));
27635
27636        // Clean release; a bad-epoch renew/release surfaces as an error.
27637        release_lease(
27638            &req(json!({"agent_id": "milo", "epoch": 1})),
27639            &state,
27640            "milo",
27641        )
27642        .await
27643        .unwrap();
27644        assert!(release_lease(
27645            &req(json!({"agent_id": "milo", "epoch": 1})),
27646            &state,
27647            "milo"
27648        )
27649        .await
27650        .is_err());
27651    }
27652}
27653
27654#[cfg(test)]
27655mod merged_knowledge_tests {
27656    use super::merge_knowledge;
27657    use serde_json::json;
27658
27659    #[test]
27660    fn org_none_leaves_personal_knowledge_unchanged() {
27661        // The org-scope-OFF read invariant: no org subsystem → personal only.
27662        let personal = vec![json!({"subject": "a", "body": "1"})];
27663        assert_eq!(merge_knowledge(personal.clone(), None), personal);
27664    }
27665
27666    /// Replicate the recall path's POSITIONAL newest-per-subject reduce
27667    /// (`car-cli reduce_newest_per_subject`): last occurrence per subject wins,
27668    /// trusting ascending order. This is what a `sync.knowledge` consumer applies,
27669    /// so it is the real semantic to assert against.
27670    fn reduce_last_wins(
27671        entries: &[serde_json::Value],
27672    ) -> std::collections::HashMap<String, String> {
27673        let mut out = std::collections::HashMap::new();
27674        for e in entries {
27675            let subject = e["subject"].as_str().unwrap().to_string();
27676            let body = e["body"].as_str().unwrap().to_string();
27677            out.insert(subject, body); // last-wins
27678        }
27679        out
27680    }
27681
27682    #[test]
27683    fn shared_subject_resolves_to_personal_not_org() {
27684        // The precedence bug linus caught: a subject in BOTH scopes must resolve to
27685        // the PERSONAL body (Option-B personal-authoritative), NOT the org one.
27686        // merge_knowledge puts personal LAST so the positional last-wins reducer
27687        // keeps it.
27688        let personal = vec![json!({"subject": "shared", "body": "personal-newer"})];
27689        let org = vec![
27690            json!({"subject": "org-only", "body": "from-org"}),
27691            json!({"subject": "shared", "body": "org-staler"}),
27692        ];
27693        let reduced = reduce_last_wins(&merge_knowledge(personal, Some(org)));
27694        assert_eq!(
27695            reduced.get("shared").map(String::as_str),
27696            Some("personal-newer"),
27697            "personal must win a shared subject — a staler org fact must NOT shadow it"
27698        );
27699        assert_eq!(
27700            reduced.get("org-only").map(String::as_str),
27701            Some("from-org"),
27702            "an org-only subject is still surfaced"
27703        );
27704    }
27705
27706    #[test]
27707    fn within_scope_newest_is_preserved() {
27708        // Ordering within a scope is unchanged: the later entry for a subject wins.
27709        let personal = vec![
27710            json!({"subject": "a", "body": "old"}),
27711            json!({"subject": "a", "body": "new"}),
27712        ];
27713        let reduced = reduce_last_wins(&merge_knowledge(personal, None));
27714        assert_eq!(reduced.get("a").map(String::as_str), Some("new"));
27715    }
27716}
27717
27718/// `skill.adopt_pack`'s provenance resolution — the security-relevant half of
27719/// governed pack adoption, kept pure so its precedence is testable without a
27720/// daemon. Signature trust is *derived* against the operator keyring, never
27721/// asserted by the caller (arXiv 2602.12430 "Agent Skills";
27722/// `docs/proposals/skill-trust-governance.md`).
27723#[cfg(test)]
27724mod skill_adopt_pack_tests {
27725    use super::resolve_adopt_provenance;
27726    use car_bundle::{sign_manifest, AgentIdentity, AgentManifest, PublisherInfo, TransportSpec};
27727    use ed25519_dalek::SigningKey;
27728    use serde_json::json;
27729
27730    /// A genuinely ed25519-signed manifest standing in for a skill bundle, plus
27731    /// the base64 `key_id` it was signed with (so a test can choose to trust
27732    /// it). Mirrors `car-memgine/tests/skill_trust_lifecycle.rs::signed_manifest`;
27733    /// the key is deterministic here because the test needs no entropy.
27734    fn signed_manifest(id: &str) -> (AgentManifest, String) {
27735        let mut m = AgentManifest {
27736            agent: AgentIdentity {
27737                id: id.into(),
27738                name: id.into(),
27739                namespace: Some("parslee".into()),
27740                version: Some("1.0.0".into()),
27741                description: None,
27742                license: None,
27743                homepage: None,
27744            },
27745            publisher: None,
27746            runtime: None,
27747            lifecycle: None,
27748            transport: TransportSpec::PureData,
27749            capabilities: None,
27750        };
27751        let key = SigningKey::from_bytes(&[7u8; 32]);
27752        sign_manifest(&mut m, &key).expect("sign");
27753        let key_id = m
27754            .publisher
27755            .as_ref()
27756            .and_then(|p: &PublisherInfo| p.key_id.clone())
27757            .expect("key_id present after signing");
27758        (m, key_id)
27759    }
27760
27761    #[test]
27762    fn no_manifest_no_provenance_defaults_to_unsigned_and_untrusted() {
27763        let params = json!({ "scanned": true, "vulnerabilities": 2, "source": "community" });
27764        let p = resolve_adopt_provenance(&params, &[]).expect("resolves");
27765        assert!(!p.signed, "nothing signed it, so it is not signed");
27766        assert!(!p.signer_trusted);
27767        // The caller's own scan/source hints DO survive — only the signature
27768        // half is off-limits to the caller.
27769        assert!(p.scanned);
27770        assert_eq!(p.vulnerabilities, 2);
27771        assert_eq!(p.source, car_policy::skill_trust::SkillSource::Community);
27772    }
27773
27774    #[test]
27775    fn manifest_signed_by_a_keyring_signer_is_signed_and_trusted() {
27776        let (m, key_id) = signed_manifest("pack-agent");
27777        let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
27778        let p = resolve_adopt_provenance(&params, &[key_id]).expect("resolves");
27779        assert!(p.signed);
27780        assert!(p.signer_trusted, "key_id is in the operator keyring");
27781    }
27782
27783    #[test]
27784    fn manifest_signed_by_an_unknown_signer_is_signed_but_not_trusted() {
27785        let (m, _key_id) = signed_manifest("pack-agent");
27786        let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
27787        let p = resolve_adopt_provenance(&params, &[]).expect("resolves");
27788        assert!(p.signed, "the signature itself still verifies");
27789        assert!(
27790            !p.signer_trusted,
27791            "an empty keyring trusts no signer, which costs this pack Official \
27792             (and only Official) — signed + scanned still reaches Verified"
27793        );
27794    }
27795
27796    #[test]
27797    fn manifest_and_provenance_together_is_an_error_not_a_precedence_choice() {
27798        let (m, _) = signed_manifest("pack-agent");
27799        let params = json!({
27800            "manifest": serde_json::to_value(&m).unwrap(),
27801            "provenance": { "signed": true, "signer_trusted": true },
27802        });
27803        let err = resolve_adopt_provenance(&params, &[]).expect_err("must refuse");
27804        assert!(err.contains("not both"), "unexpected message: {err}");
27805    }
27806
27807    #[test]
27808    fn unrecognised_source_is_rejected_rather_than_silently_defaulted() {
27809        let params = json!({ "source": "bogus" });
27810        let err = resolve_adopt_provenance(&params, &[]).expect_err("must refuse");
27811        assert!(err.contains("invalid source"), "unexpected message: {err}");
27812    }
27813}
27814
27815#[cfg(test)]
27816mod approval_requester_tests {
27817    use super::stamped_requester;
27818
27819    /// An agent-bound session IS that agent. Its binding was proven against the
27820    /// supervisor-minted per-agent token in `session.auth`, so a claim to be a
27821    /// different agent is a forgery and must not reach the row.
27822    #[test]
27823    fn a_bound_session_cannot_claim_to_be_another_agent() {
27824        assert_eq!(
27825            stamped_requester(Some("agent-7".into()), Some("agent-9".into())),
27826            Some("agent-7".into())
27827        );
27828    }
27829
27830    /// Nor can it launder the attribution by omitting the field — which was the
27831    /// cheaper evasion, because an absent `agent_id` never matched the
27832    /// requester-vs-resolver comparison at all.
27833    #[test]
27834    fn a_bound_session_cannot_launder_by_omitting_the_field() {
27835        assert_eq!(
27836            stamped_requester(Some("agent-7".into()), None),
27837            Some("agent-7".into())
27838        );
27839    }
27840
27841    /// A host client — CarHost, `car-host approve`, the CLI — has no binding and
27842    /// legitimately raises approvals on an agent's behalf, so its claim stands.
27843    #[test]
27844    fn an_unbound_host_client_keeps_its_claim() {
27845        assert_eq!(
27846            stamped_requester(None, Some("agent-7".into())),
27847            Some("agent-7".into())
27848        );
27849        assert_eq!(stamped_requester(None, None), None);
27850    }
27851}