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    "server.schema",
357    "session.auth",
358    "session.init",
359];
360const DEADLINE_FAST_SECS: u64 = 180;
361/// Transport grace added on top of a `proposal.submit`'s derived action budget,
362/// so the daemon's handler deadline fires strictly *after* the executor's own
363/// per-action waits.
364///
365/// What this guarantees unconditionally is the hop that matters: the daemon
366/// never abandons a submit the executor is still legitimately spending budget
367/// on. `car-engine`'s `execute_with_retry` reaps a single attempt at
368/// `timeout_ms`; this deadline reaps the whole submit no earlier than
369/// `Σ(budget × attempts) + 15s`.
370///
371/// Keep this value STRICTLY SMALLER than the client's 30s
372/// `car_daemon_client::proxy::PROPOSAL_TRANSPORT_GRACE_SECS`, so that wherever
373/// the two derivations both dominate their floors, the daemon answers first and
374/// the caller sees an explicit `-32004` rather than an opaque transport timeout.
375/// That full innermost-first ordering — executor → daemon handler → client —
376/// only holds once the derived term clears both floors (Σ above roughly 1785s,
377/// this default minus this grace). Below it the client's 900s floor is smaller
378/// than this 1800s default and the client gives up first. That inversion
379/// predates the derivation and harms nothing: the client's floor still strictly
380/// exceeds the executor's worst case there, so no in-budget work is killed —
381/// only a genuinely wedged handler surfaces as a transport timeout instead of
382/// `-32004`.
383const PROPOSAL_DEADLINE_GRACE_SECS: u64 = 15;
384/// `max_retries` assumed for a `failure_behavior: "retry"` action that does not
385/// pin one. Mirrors `car_ir`'s private `default_max_retries()` (3), pinned to it
386/// by `derived_retry_default_matches_car_ir` below. The executor runs
387/// `max_retries + 1` attempts for a retry action, so the default worst case is
388/// 4 attempts, not 3 — the same undercount #265 fixed on the client side.
389const DEFAULT_PROPOSAL_MAX_RETRIES: u64 = 3;
390/// JSON-RPC application error for a handler abandoned by the daemon's own
391/// deadline. Clients must distinguish this ambiguous outcome from a handler
392/// that replied with a terminal failure.
393const HANDLER_DEADLINE_ERROR_CODE: i32 = -32004;
394
395/// Protocol v2 made the Parslee browser-login lifecycle attempt-bound. An old
396/// host may understand `host.subscribe` and `auth.start` but not the required
397/// `auth.complete`/reconciliation contract, so those surfaces must stay hidden
398/// until this connection has proved exact wire compatibility.
399fn requires_protocol_handshake(method: &str) -> bool {
400    method == "host.subscribe"
401        || method.starts_with("auth.")
402        || method == "diagnostics.secret_store_activity"
403        || method == "session.clear_halt"
404        || method == "infer.cancel"
405        || method == "infer.deadline"
406        || method == "runs.cancel"
407        || matches!(
408            method,
409            "agent_permissions.set_tool"
410                | "agent_permissions.reset_tool"
411                | "agent_permissions.evaluate_tool"
412        )
413        // The feedback.* surface is protocol-v2-native (PAR-1): a legacy host
414        // has no consent UI for it, so it stays hidden pre-negotiation.
415        || method == "feedback.compose_preview"
416        || method == "feedback.submit"
417        || method == "feedback.status"
418        || method == "feedback.list"
419}
420
421fn session_has_capability(session: &crate::session::ClientSession, capability: &str) -> bool {
422    session
423        .negotiated_capabilities
424        .read()
425        .map(|capabilities| capabilities.contains(capability))
426        .unwrap_or(false)
427}
428
429/// The `feedback.*` surface is capability-gated like every sibling surface
430/// (`infer.cancel`, `runs.resume`, …): a session that completed the v3
431/// handshake WITHOUT negotiating `feedback.v1` cannot spool reports or read
432/// submission summaries. The refusal carries the standard mismatch prefix so
433/// bundled clients classify it the same way as the other gated methods.
434fn require_feedback_capability(session: &crate::session::ClientSession) -> Result<(), String> {
435    if session_has_capability(session, car_proto::FEEDBACK_CAPABILITY) {
436        Ok(())
437    } else {
438        Err(format!(
439            "{} negotiate `{}` as a required or optional capability before calling `feedback.*`",
440            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
441            car_proto::FEEDBACK_CAPABILITY
442        ))
443    }
444}
445
446fn require_agent_tool_override_capability(
447    session: &crate::session::ClientSession,
448) -> Result<(), String> {
449    if session_has_capability(session, car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY) {
450        Ok(())
451    } else {
452        Err(format!(
453            "this operation requires negotiated capability {}",
454            car_proto::AGENT_TOOL_OVERRIDES_CAPABILITY
455        ))
456    }
457}
458
459// ---- WebSocket keepalive (server-side ping/liveness) ----
460// tokio-tungstenite sends no automatic pings, so a silently-dead connection
461// (TCP half-open, no close frame) on a QUIET stream is never noticed and its
462// subscriber/registry state leaks until daemon restart. We ping periodically;
463// any inbound frame (incl. the pong) refreshes the liveness clock; no frame for
464// KEEPALIVE_DEAD_AFTER (or a failed ping write) → the connection is dead.
465const KEEPALIVE_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
466const KEEPALIVE_DEAD_AFTER: std::time::Duration = std::time::Duration::from_secs(90); // ~3 missed pings
467const KEEPALIVE_WRITE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); // matches drain task
468const DEFAULT_INFER_TOTAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
469/// Maximum time the daemon keeps a composed auth-state response waiter:
470/// 30s bounded in-process coordinator queue + 30s cross-process auth lock +
471/// one 15s authoritative read + one 15s reservation/claim/terminal publication
472/// + 5s scheduling margin. The state operation itself is daemon-owned;
473/// exceeding this response bound never drops its coordinator guard or blocking
474/// worker.
475const AUTH_STATE_RESPONSE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(95);
476const AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX: &str =
477    "auth coordination deadline before redemption; safe to retry auth.complete";
478const AUTH_START_RETRY_PREFIX: &str =
479    "auth.start coordination deadline before reservation; safe to retry auth.start serially";
480const AUTH_STATUS_RETRY_PREFIX: &str =
481    "auth.completion_status coordination deadline; retry the proof read serially";
482/// Native hosts reconcile an accepted or ambiguous completion for 480 seconds.
483/// This server-side mirror exists only to prove that the daemon's bounded
484/// retry/worker/proof/hydration composition continues to fit inside that
485/// external horizon.
486#[cfg(test)]
487const AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON: std::time::Duration =
488    std::time::Duration::from_secs(480);
489
490fn is_daemon_owned_auth_method(method: &str) -> bool {
491    DAEMON_OWNED_AUTH_METHODS.contains(&method)
492}
493
494fn server_deadline_for(method: &str, params: &Value) -> Option<std::time::Duration> {
495    if DEADLINE_EXEMPT_SUBSTRINGS
496        .iter()
497        .any(|s| method.contains(s))
498    {
499        return None;
500    }
501    let fast = DEADLINE_FAST_SUFFIXES.iter().any(|s| method.ends_with(s))
502        || DEADLINE_FAST_PREFIXES.iter().any(|p| method.starts_with(p))
503        || DEADLINE_FAST_EXACT.contains(&method);
504    let secs = if fast {
505        DEADLINE_FAST_SECS
506    } else if method == "proposal.submit" {
507        proposal_submit_deadline_secs(params)
508    } else {
509        handler_default_deadline_secs()
510    };
511    Some(std::time::Duration::from_secs(secs))
512}
513
514fn inference_total_timeout() -> std::time::Duration {
515    std::env::var("CAR_INFER_TIMEOUT_SECS")
516        .ok()
517        .and_then(|value| value.parse::<u64>().ok())
518        .map(std::time::Duration::from_secs)
519        .unwrap_or(DEFAULT_INFER_TOTAL_TIMEOUT)
520}
521
522/// Server-side deadline for `proposal.submit`, derived from the budgets the
523/// submitted proposal actually declares instead of a flat per-method constant.
524///
525/// `proposal.submit` blocks on the whole DAG — every action's tool callback,
526/// every retry attempt. #265 taught the client to size its read deadline from
527/// those same budgets, which deleted the premise the flat generous default here
528/// rested on ("the client itself caps proposal.submit at ~900s"). Left flat,
529/// this deadline abandoned a legitimately in-budget proposal with `-32004`
530/// while `car-engine`'s `execute_with_retry` was still on an attempt and the
531/// client was still patiently reading — the #259/#265 reap class relocated one
532/// layer inward. One retried action with a 10-minute budget is already 2400s,
533/// past the 1800s default.
534///
535/// The derivation ONLY RAISES: the result is
536/// `max(handler_default_deadline_secs(), Σ + grace)`, so a short proposal, or
537/// params carrying no parseable proposal, keeps exactly the generous default it
538/// had before. `CAR_HANDLER_TIMEOUT` keeps its meaning as that default/floor —
539/// it is deliberately NOT a ceiling here, because capping the daemon below the
540/// executor's own budget is the bug this fixes.
541fn proposal_submit_deadline_secs(params: &Value) -> u64 {
542    let default_secs = handler_default_deadline_secs();
543    match derive_proposal_budget_secs(params) {
544        0 => default_secs,
545        derived => default_secs.max(derived.saturating_add(PROPOSAL_DEADLINE_GRACE_SECS)),
546    }
547}
548
549/// Cumulative daemon-side budget of a `proposal.submit`, in seconds, derived
550/// from `proposal.actions[*].timeout_ms`. Returns 0 when no proposal/actions
551/// are parseable, which the caller reads as "keep the generous default".
552///
553/// Mirrors `car_daemon_client::proxy::derive_proposal_budget_secs` so the two
554/// layers agree on what "in budget" means. Independent same-level actions run
555/// concurrently, so a level's true cost is the `max` of its actions rather than
556/// the sum — but the DAG is not reconstructed here. The conservative
557/// `Σ(budget × attempts)` upper bound is taken instead: over-waiting only delays
558/// reaping a genuinely wedged handler, while under-waiting kills real work.
559///
560/// One term neither this nor the client's copy counts: the executor's
561/// inter-attempt backoff sleeps. At the built-in defaults
562/// (`RETRY_BASE_DELAY_MS` 100, factor 2) a 4-attempt action sleeps 0.7s total,
563/// far inside the grace. Only a harness-config `retry_backoff_ms` override
564/// (`None` by default, so unreachable on any default path) could push the sum
565/// past it — if that knob ever gets a live call site, fold the backoff series
566/// into this derivation rather than widening the grace.
567fn derive_proposal_budget_secs(params: &Value) -> u64 {
568    let Some(actions) = params
569        .get("proposal")
570        .and_then(|p| p.get("actions"))
571        .and_then(|a| a.as_array())
572    else {
573        return 0;
574    };
575    if actions.is_empty() {
576        return 0;
577    }
578
579    // Saturating throughout so a pathological `timeout_ms` can't wrap the
580    // accumulator into a *shorter* deadline than the flat default.
581    let mut total_ms: u64 = 0;
582    for action in actions {
583        let budget_ms = action
584            .get("timeout_ms")
585            .and_then(|v| v.as_u64())
586            .unwrap_or(crate::session::DEFAULT_TOOL_TIMEOUT_MS);
587        total_ms =
588            total_ms.saturating_add(budget_ms.saturating_mul(proposal_action_attempts(action)));
589    }
590
591    // ms → secs rounding UP, so a sub-second remainder never truncates the
592    // derived deadline below what the executor is allowed to spend.
593    total_ms.div_ceil(1000)
594}
595
596/// Worst-case attempt count the executor will run for one action.
597///
598/// Mirrors `car_engine::executor::execute_with_retry` (and the client's
599/// `car_daemon_client::proxy::action_attempts`): a `failure_behavior: "retry"`
600/// action runs `max_retries + 1` attempts — the initial try plus up to
601/// `max_retries` retries, so 4 at the default, NOT 3 — and every other behavior
602/// (`abort`, `skip`, or unset → `abort`) runs exactly once. Always `>= 1`.
603fn proposal_action_attempts(action: &Value) -> u64 {
604    let is_retry = action
605        .get("failure_behavior")
606        .and_then(|v| v.as_str())
607        .map(|s| s.eq_ignore_ascii_case("retry"))
608        .unwrap_or(false);
609
610    if is_retry {
611        action
612            .get("max_retries")
613            .and_then(|v| v.as_u64())
614            .unwrap_or(DEFAULT_PROPOSAL_MAX_RETRIES)
615            .saturating_add(1)
616    } else {
617        1
618    }
619}
620
621#[derive(Debug)]
622enum HandlerFailure {
623    Dispatch(String),
624    MethodNotFound(String),
625    InvalidParams(String),
626    Deadline(String),
627    /// Something in front of the model declined the request's content. Carries
628    /// its own JSON-RPC code so a consumer can tell a policy ruling from a
629    /// crash — see [`car_proto::CONTENT_REFUSED_ERROR_CODE`].
630    ContentRefused(String),
631    ProtocolCapability(String),
632    CatalogPrecondition(String),
633    RunOwnershipConflict(String),
634    RunTraceCorruption(String),
635}
636
637impl HandlerFailure {
638    /// Classify a handler's dispatch error string.
639    ///
640    /// This is the READER half of a writer/reader pair — the writer is
641    /// [`inference_dispatch_error`], which tags a typed
642    /// `InferenceError::ContentRefused` with
643    /// [`car_proto::CONTENT_REFUSED_MESSAGE_PREFIX`] on the way out of the
644    /// handler. The two must agree on that prefix, the same way
645    /// `car_inference::stream::error_tags` pairs its writer and reader. The
646    /// decode happens in exactly ONE place (here) so there is a single line to
647    /// keep in sync, and the typed classification is still done where the typed
648    /// error actually exists rather than by sniffing text.
649    fn from_dispatch(message: String) -> Self {
650        if message.starts_with("unknown method:") {
651            Self::MethodNotFound(message)
652        } else if message.starts_with("invalid params:") {
653            Self::InvalidParams(message)
654        } else if message.starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX) {
655            Self::ContentRefused(message)
656        } else if message.starts_with(car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX) {
657            Self::ProtocolCapability(message)
658        } else if message.starts_with(car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX) {
659            Self::CatalogPrecondition(message)
660        } else if message.starts_with(car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX) {
661            Self::RunOwnershipConflict(message)
662        } else if message.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
663            Self::RunTraceCorruption(message)
664        } else {
665            Self::Dispatch(message)
666        }
667    }
668
669    fn json_rpc_code(&self) -> i32 {
670        match self {
671            Self::Dispatch(_) => -32603,
672            Self::MethodNotFound(_) => -32601,
673            Self::InvalidParams(_) => -32602,
674            Self::Deadline(_) => HANDLER_DEADLINE_ERROR_CODE,
675            Self::ContentRefused(_) => car_proto::CONTENT_REFUSED_ERROR_CODE,
676            Self::ProtocolCapability(_) => car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
677            Self::CatalogPrecondition(_) => car_proto::CATALOG_PRECONDITION_MISMATCH_ERROR_CODE,
678            Self::RunOwnershipConflict(_) => car_proto::RUN_OWNERSHIP_CONFLICT_ERROR_CODE,
679            Self::RunTraceCorruption(_) => car_proto::RUN_TRACE_CORRUPTION_ERROR_CODE,
680        }
681    }
682
683    fn message(&self) -> &str {
684        match self {
685            Self::Dispatch(message)
686            | Self::MethodNotFound(message)
687            | Self::InvalidParams(message)
688            | Self::Deadline(message)
689            | Self::ContentRefused(message)
690            | Self::ProtocolCapability(message)
691            | Self::CatalogPrecondition(message)
692            | Self::RunOwnershipConflict(message)
693            | Self::RunTraceCorruption(message) => message,
694        }
695    }
696}
697
698fn json_rpc_response_for_handler_result(
699    id: Value,
700    result: Result<Value, HandlerFailure>,
701) -> JsonRpcResponse {
702    match result {
703        Ok(value) => JsonRpcResponse::success(id, value),
704        Err(error) => JsonRpcResponse::error(id, error.json_rpc_code(), error.message()),
705    }
706}
707
708async fn dispatch_with_server_deadline<F>(
709    method: &str,
710    params: &Value,
711    dispatch: F,
712) -> Result<Value, HandlerFailure>
713where
714    F: std::future::Future<Output = Result<Value, String>>,
715{
716    match server_deadline_for(method, params) {
717        Some(deadline) => match tokio::time::timeout(deadline, dispatch).await {
718            Ok(result) => result.map_err(HandlerFailure::from_dispatch),
719            Err(_) => Err(HandlerFailure::Deadline(format!(
720                "handler '{}' exceeded the server-side deadline of {}s and was abandoned",
721                method,
722                deadline.as_secs()
723            ))),
724        },
725        None => dispatch.await.map_err(HandlerFailure::from_dispatch),
726    }
727}
728
729async fn await_daemon_owned_auth_result(
730    method: &str,
731    result_rx: tokio::sync::oneshot::Receiver<Result<Value, HandlerFailure>>,
732) -> Result<Value, HandlerFailure> {
733    let receive = async {
734        match result_rx.await {
735            Ok(result) => result,
736            Err(_) => Err(HandlerFailure::Dispatch(format!(
737                "daemon-owned auth operation `{method}` stopped before publishing a result"
738            ))),
739        }
740    };
741    if !matches!(
742        method,
743        "auth.start" | "auth.complete" | "auth.completion_status"
744    ) {
745        return receive.await;
746    }
747
748    tokio::time::timeout(AUTH_STATE_RESPONSE_DEADLINE, receive)
749        .await
750        .unwrap_or_else(|_| match method {
751            "auth.complete" => Err(HandlerFailure::Deadline(format!(
752                "auth.complete durable claim outcome is ambiguous after {}s; do not replay the \
753                 authorization code and reconcile the exact attempt through auth.completion_status",
754                AUTH_STATE_RESPONSE_DEADLINE.as_secs()
755            ))),
756            "auth.start" => Err(HandlerFailure::Deadline(format!(
757                "auth.start reservation outcome is ambiguous after {}s; the daemon-owned \
758                 reservation continues, so do not overlap another auth.start",
759                AUTH_STATE_RESPONSE_DEADLINE.as_secs()
760            ))),
761            _ => Err(HandlerFailure::Deadline(format!(
762                "handler 'auth.completion_status' exceeded its {}s response deadline; \
763                 daemon-owned reconciliation continues and a later proof read is safe",
764                AUTH_STATE_RESPONSE_DEADLINE.as_secs()
765            ))),
766        })
767}
768
769fn classified_auth_failure(method: &str, error: car_auth::AuthOperationError) -> HandlerFailure {
770    match error {
771        car_auth::AuthOperationError::CoordinationDeadline(detail) => {
772            let message = match method {
773                "auth.complete" => format!("{AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX}: {detail}"),
774                "auth.start" => format!("{AUTH_START_RETRY_PREFIX}: {detail}"),
775                "auth.completion_status" => format!("{AUTH_STATUS_RETRY_PREFIX}: {detail}"),
776                _ => format!(
777                    "{method} coordination deadline before state work; retry the request safely: \
778                     {detail}"
779                ),
780            };
781            HandlerFailure::Deadline(message)
782        }
783        car_auth::AuthOperationError::Terminal(message) => HandlerFailure::from_dispatch(message),
784    }
785}
786
787#[cfg(test)]
788mod assistant_identity_tests {
789    use super::{
790        assistant_identity_wire, handle_assistant_identity_set, JsonRpcMessage, ServerState,
791    };
792    use car_identity::{AssistantIdentity, FocusArea, IdentityStore};
793    use car_memgine::{MemKind, MemgineEngine};
794    use serde_json::json;
795    use std::sync::Arc;
796
797    fn identity_value(engine: &MemgineEngine) -> Option<String> {
798        engine
799            .graph
800            .inner
801            .node_indices()
802            .filter_map(|nix| engine.graph.inner.node_weight(nix))
803            .find(|node| node.kind == MemKind::Identity && node.key == "identity")
804            .map(|node| node.value.clone())
805    }
806
807    #[test]
808    fn the_wire_shape_carries_everything_a_host_needs_to_address_the_assistant() {
809        // Hosts match wake phrases LOCALLY (their matcher has to work before
810        // the daemon answers), so the daemon has to hand them the derived alias
811        // list rather than only the name. Sending just the name is how Swift,
812        // Kotlin, and Rust drifted into three different lists in the first
813        // place.
814        let identity = AssistantIdentity::new("Jarvis")
815            .expect("valid name")
816            .with_spellings(vec!["jervis".into()])
817            .expect("valid spellings");
818        let wire = assistant_identity_wire(&identity);
819
820        assert_eq!(wire["name"], "Jarvis");
821        assert_eq!(wire["spellings"][0], "jervis");
822        assert_eq!(wire["brand"], car_identity::BRAND_NAME);
823        assert!(wire["role"].is_null());
824        assert_eq!(wire["focus_areas"], json!([]));
825        assert_eq!(wire["apps"], json!([]));
826
827        let aliases: Vec<String> =
828            serde_json::from_value(wire["aliases"].clone()).expect("aliases");
829        for expected in ["jarvis", "hey jarvis", "jervis", "hey jervis"] {
830            assert!(
831                aliases.contains(&expected.to_string()),
832                "{expected} missing from {aliases:?}"
833            );
834        }
835    }
836
837    #[tokio::test]
838    async fn profile_rpc_persists_prompts_and_rehydrates_the_identity_memory_node() {
839        let tmp = tempfile::TempDir::new().expect("scratch root");
840        let store = IdentityStore::with_base_dir(tmp.path());
841        let engine = Arc::new(tokio::sync::Mutex::new(MemgineEngine::new(None)));
842        let config = crate::session::ServerStateConfig::new(tmp.path().join("journals"))
843            .with_shared_memgine(engine.clone())
844            .with_identity_store(store.clone())
845            .with_approval_journal(tmp.path().join("approvals.jsonl"))
846            .with_trajectory_dir(tmp.path().join("trajectories"));
847        let state = Arc::new(ServerState::with_config_and_isolated_feedback_diagnostics(
848            config,
849            tmp.path().join("models"),
850            tmp.path().join("huggingface"),
851        ));
852        let session = state
853            .create_session(
854                "identity-profile-test",
855                Arc::new(crate::session::WsChannel::test_stub()),
856            )
857            .await
858            .expect("session");
859        let request = JsonRpcMessage {
860            jsonrpc: "2.0".into(),
861            method: Some("assistant.identity.set".into()),
862            params: json!({
863                "user_name": "Dana",
864                "role": "Customer success lead",
865                "focus_areas": ["email", "research"],
866                "apps": ["Outlook", "Notion"]
867            }),
868            id: json!(1),
869            result: None,
870            error: None,
871        };
872
873        let response = handle_assistant_identity_set(&request, &session, &state)
874            .await
875            .expect("profile write through daemon handler");
876        assert_eq!(response["role"], "Customer success lead");
877        assert_eq!(response["focus_areas"], json!(["email", "research"]));
878        let persisted = std::fs::read_to_string(store.path()).expect("identity.json");
879        assert!(persisted.contains("\"role\": \"Customer success lead\""));
880        assert!(persisted.contains("\"focus_areas\""));
881        assert!(persisted.contains("\"apps\""));
882        let live_engine = engine.lock().await;
883        let live_identity = identity_value(&live_engine).expect("live identity node");
884        drop(live_engine);
885        assert!(live_identity.contains("Role: Customer success lead"));
886
887        // Simulate daemon restart: a new state and empty engine load only the
888        // persisted identity.json. Identity is excluded from generic memory
889        // snapshots, so this startup mirror is the restart contract.
890        drop(state);
891        let restarted_engine = Arc::new(tokio::sync::Mutex::new(MemgineEngine::new(None)));
892        let restarted_config =
893            crate::session::ServerStateConfig::new(tmp.path().join("journals-restarted"))
894                .with_shared_memgine(restarted_engine.clone())
895                .with_identity_store(store.clone())
896                .with_approval_journal(tmp.path().join("approvals-restarted.jsonl"))
897                .with_trajectory_dir(tmp.path().join("trajectories-restarted"));
898        let _restarted = ServerState::with_config_and_isolated_feedback_diagnostics(
899            restarted_config,
900            tmp.path().join("models-restarted"),
901            tmp.path().join("huggingface-restarted"),
902        );
903        let restarted_guard = restarted_engine.lock().await;
904        let reloaded_identity =
905            identity_value(&restarted_guard).expect("identity node after restart");
906        assert!(reloaded_identity.contains("Role: Customer success lead"));
907        assert!(reloaded_identity.contains("Focus areas: email, research"));
908
909        let loaded = store.load().expect("persisted identity reload");
910        assert_eq!(
911            loaded.focus_areas,
912            vec![FocusArea::Email, FocusArea::Research]
913        );
914        let prompt = crate::assistant::prompt::chat_prompt(&loaded, "local host", &[]);
915        assert!(prompt.contains("- Role: Customer success lead"));
916        assert!(prompt.contains("- What they want help with: email, research"));
917    }
918
919    #[test]
920    fn the_brand_travels_alongside_the_name_never_instead_of_it() {
921        // Store copy is contractually "Parslee Core" (docs/mobile-app-store-release.md).
922        // A host that renders brand copy needs both values, not a merged one.
923        let wire = assistant_identity_wire(&AssistantIdentity::new("Friday").unwrap());
924        assert_eq!(wire["name"], "Friday");
925        assert_eq!(wire["brand"], "Parslee Core");
926    }
927}
928
929#[cfg(test)]
930mod deadline_policy_tests {
931    use super::{
932        agent_scope_implicitly_allows, auth_completion_network_with_deadline,
933        auth_completion_value, await_daemon_owned_auth_result, classified_auth_failure,
934        dispatch_with_server_deadline, handler_default_deadline_secs, inference_dispatch_error,
935        is_daemon_owned_auth_method, json_rpc_response_for_handler_result,
936        requires_protocol_handshake, select_auth_start_api_base, server_deadline_for,
937        stream_dispatch_error, HandlerFailure, AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX,
938        AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON, AUTH_START_RETRY_PREFIX,
939        AUTH_STATE_RESPONSE_DEADLINE, AUTH_STATUS_RETRY_PREFIX, DAEMON_OWNED_AUTH_METHODS,
940        DEADLINE_FAST_SECS, DEFAULT_PROPOSAL_MAX_RETRIES, HANDLER_DEADLINE_ERROR_CODE,
941        PROPOSAL_DEADLINE_GRACE_SECS,
942    };
943    use serde_json::{json, Value};
944    use std::time::Duration;
945
946    #[test]
947    fn auth_start_base_precedence_is_passive_and_does_not_read_secrets() {
948        const SENTINEL: &str = "CAR_AUTH_START_BASE_CONTRACT_CHILD";
949        if std::env::var_os(SENTINEL).is_none() {
950            let status = std::process::Command::new(std::env::current_exe().unwrap())
951                .arg("--exact")
952                .arg(
953                    "handler::deadline_policy_tests::auth_start_base_precedence_is_passive_and_does_not_read_secrets",
954                )
955                .arg("--nocapture")
956                .env(SENTINEL, "1")
957                .status()
958                .expect("spawn isolated auth.start base contract");
959            assert!(status.success(), "isolated auth.start contract failed");
960            return;
961        }
962
963        let before = car_secrets::secret_store_activity();
964
965        assert_eq!(
966            select_auth_start_api_base(
967                &json!({ "api_base": " https://explicit.example.test/ " }),
968                Some("https://environment.example.test/"),
969            ),
970            "https://explicit.example.test"
971        );
972        assert_eq!(
973            select_auth_start_api_base(
974                &json!({ "api_base": "   " }),
975                Some(" https://environment.example.test/ "),
976            ),
977            "https://environment.example.test"
978        );
979        assert_eq!(
980            select_auth_start_api_base(&json!({}), Some("  ")),
981            car_auth::DEFAULT_API_BASE
982        );
983        assert_eq!(car_secrets::secret_store_activity(), before);
984    }
985
986    /// One `tool_call` action in the wire shape `proposal.submit` receives.
987    fn action(timeout_ms: u64, failure_behavior: &str) -> Value {
988        json!({
989            "id": "a1",
990            "type": "tool_call",
991            "tool": "shell",
992            "parameters": {},
993            "failure_behavior": failure_behavior,
994            "timeout_ms": timeout_ms,
995        })
996    }
997
998    fn submit_params(actions: Vec<Value>) -> Value {
999        json!({ "proposal": { "id": "p1", "goal": "g", "actions": actions } })
1000    }
1001
1002    /// A single retried action with a 10-minute budget runs four attempts
1003    /// (`max_retries` 3 + the initial try) — 2400s of daemon-side work that the
1004    /// executor and, since #265, the client both permit. Keying the handler
1005    /// deadline on the method name alone abandoned it at the flat 1800s
1006    /// default with `-32004` while the work was still legitimately in budget.
1007    #[test]
1008    fn proposal_submit_deadline_covers_a_retried_actions_full_budget() {
1009        let params = submit_params(vec![action(600_000, "retry")]);
1010        let deadline =
1011            server_deadline_for("proposal.submit", &params).expect("proposal.submit is bounded");
1012
1013        // 4 attempts × 600s.
1014        assert!(
1015            deadline > Duration::from_secs(2400),
1016            "must outlast the executor's own 2400s worst case, got {deadline:?}"
1017        );
1018        assert!(
1019            deadline > Duration::from_secs(1800),
1020            "must RAISE the flat 1800s generous default, got {deadline:?}"
1021        );
1022        assert_eq!(
1023            deadline,
1024            Duration::from_secs(2400 + PROPOSAL_DEADLINE_GRACE_SECS)
1025                .max(Duration::from_secs(handler_default_deadline_secs())),
1026            "derived deadline is the action budget plus the server transport grace"
1027        );
1028    }
1029
1030    /// Chained actions add up: the derivation is the conservative
1031    /// `Σ(budget × attempts)` upper bound, so a multi-step proposal whose sum
1032    /// passes the default raises the deadline past that sum too.
1033    #[test]
1034    fn proposal_submit_deadline_covers_chained_action_budgets() {
1035        let params = submit_params(vec![
1036            action(900_000, "abort"),
1037            action(900_000, "abort"),
1038            action(300_000, "skip"),
1039        ]);
1040        let sum = Duration::from_secs(900 + 900 + 300);
1041        let deadline =
1042            server_deadline_for("proposal.submit", &params).expect("proposal.submit is bounded");
1043
1044        assert!(
1045            deadline > sum,
1046            "chained budgets summing to {sum:?} must not be reaped, got {deadline:?}"
1047        );
1048        assert!(
1049            deadline > Duration::from_secs(1800),
1050            "a 2100s chain must RAISE the flat 1800s default, got {deadline:?}"
1051        );
1052        assert_eq!(
1053            deadline,
1054            (sum + Duration::from_secs(PROPOSAL_DEADLINE_GRACE_SECS))
1055                .max(Duration::from_secs(handler_default_deadline_secs()))
1056        );
1057    }
1058
1059    /// The derivation only ever RAISES: a short proposal keeps exactly the
1060    /// generous default it had before, so no method gets a tighter deadline
1061    /// than it had.
1062    #[test]
1063    fn a_short_proposal_keeps_exactly_the_generous_default() {
1064        let params = submit_params(vec![action(5_000, "abort")]);
1065        assert_eq!(
1066            server_deadline_for("proposal.submit", &params),
1067            Some(Duration::from_secs(handler_default_deadline_secs())),
1068            "5s of budget must not lower the deadline below the generous default"
1069        );
1070    }
1071
1072    /// An action that declares no `timeout_ms` is budgeted at the daemon's own
1073    /// `tool_callback_timeout(None)` bound, so the derivation tracks what the
1074    /// executor will actually wait.
1075    #[test]
1076    fn actions_without_an_explicit_budget_use_the_daemon_tool_default() {
1077        let params = json!({
1078            "proposal": {
1079                "actions": [
1080                    { "id": "a1", "type": "tool_call", "failure_behavior": "retry" },
1081                    { "id": "a2", "type": "tool_call", "failure_behavior": "retry" },
1082                ]
1083            }
1084        });
1085        // 2 actions × 4 attempts × 300s = 2400s.
1086        let expected = 2 * 4 * (crate::session::DEFAULT_TOOL_TIMEOUT_MS / 1000);
1087        assert_eq!(
1088            server_deadline_for("proposal.submit", &params),
1089            Some(
1090                Duration::from_secs(expected + PROPOSAL_DEADLINE_GRACE_SECS)
1091                    .max(Duration::from_secs(handler_default_deadline_secs()))
1092            )
1093        );
1094    }
1095
1096    /// The reap ordering is executor → daemon handler → client, innermost
1097    /// first. This server grace must therefore stay strictly below the client's
1098    /// `car_daemon_client::proxy::PROPOSAL_TRANSPORT_GRACE_SECS` (30s) — named
1099    /// here rather than imported, because car-server-core must not take a
1100    /// dependency on the daemon client to assert an ordering invariant.
1101    #[test]
1102    fn server_grace_is_strictly_inside_the_client_transport_grace() {
1103        assert!(
1104            PROPOSAL_DEADLINE_GRACE_SECS < 30,
1105            "server grace must fire before the client's 30s PROPOSAL_TRANSPORT_GRACE_SECS"
1106        );
1107    }
1108
1109    /// Pins the locally-mirrored retry default to `car_ir`'s own serde default,
1110    /// which is private — deserializing a minimal action is how we read it.
1111    #[test]
1112    fn derived_retry_default_matches_car_ir() {
1113        let action: car_ir::Action = serde_json::from_value(json!({
1114            "id": "a1",
1115            "type": "tool_call",
1116            "tool": "shell",
1117        }))
1118        .expect("minimal action should deserialize");
1119        assert_eq!(u64::from(action.max_retries), DEFAULT_PROPOSAL_MAX_RETRIES);
1120    }
1121
1122    #[test]
1123    fn schema_discovery_is_available_before_protocol_negotiation() {
1124        assert!(!requires_protocol_handshake("server.schema"));
1125        assert!(agent_scope_implicitly_allows("server.schema"));
1126    }
1127
1128    #[test]
1129    fn classifies_methods_into_expected_buckets() {
1130        let default = Duration::from_secs(handler_default_deadline_secs());
1131        let fast = Duration::from_secs(DEADLINE_FAST_SECS);
1132
1133        // Unbounded → exempt (no deadline).
1134        for m in [
1135            "infer_stream",
1136            "runs.subscribe",
1137            "scheduler.run_loop",
1138            "voice.dispatch_turn",
1139            "voice.tts_stream.start",
1140        ] {
1141            assert_eq!(
1142                server_deadline_for(m, &Value::Null),
1143                None,
1144                "{m} should be exempt"
1145            );
1146        }
1147        for method in DAEMON_OWNED_AUTH_METHODS {
1148            assert!(
1149                is_daemon_owned_auth_method(method),
1150                "{method} must bypass connection-owned dispatch"
1151            );
1152        }
1153        // Bounded, cancellation-safe reads → fast.
1154        for m in [
1155            "memory.query",
1156            "memory.evaluate",
1157            "memory.intervene",
1158            "agents.list",
1159            "meeting.get",
1160            "runs.list",
1161            "agents.health",
1162            "server.handshake",
1163            "server.schema",
1164        ] {
1165            assert_eq!(
1166                server_deadline_for(m, &Value::Null),
1167                Some(fast),
1168                "{m} should be fast"
1169            );
1170        }
1171        // Mutating / heavy / agentic → generous default (NOT the tight bucket).
1172        // `proposal.submit` is in this list with `Value::Null` params: a
1173        // proposal whose budget is not parseable still takes the generous
1174        // default, exactly as before the budget derivation landed.
1175        for m in [
1176            "memory.persist",
1177            "memory.add_fact",
1178            "memory.update_status",
1179            "memory.maintain",
1180            "memory.save_knowledge",
1181            "memory.save_procedural",
1182            "memory.delete",
1183            "memory.consolidate",
1184            "proposal.submit",
1185            "workflow.run",
1186            "multi.swarm",
1187        ] {
1188            assert_eq!(
1189                server_deadline_for(m, &Value::Null),
1190                Some(default),
1191                "{m} should take the generous default"
1192            );
1193        }
1194    }
1195
1196    #[tokio::test(start_paused = true)]
1197    async fn bounded_handler_deadline_has_a_distinct_json_rpc_code() {
1198        let task = tokio::spawn(async {
1199            dispatch_with_server_deadline(
1200                "memory.query",
1201                &Value::Null,
1202                std::future::pending::<Result<Value, String>>(),
1203            )
1204            .await
1205        });
1206
1207        tokio::time::advance(Duration::from_secs(DEADLINE_FAST_SECS)).await;
1208        let failure = task
1209            .await
1210            .expect("deadline task should join")
1211            .expect_err("pending handler should exceed its deadline");
1212
1213        assert!(matches!(failure, HandlerFailure::Deadline(_)));
1214        assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1215    }
1216
1217    #[test]
1218    fn auth_state_response_bound_exceeds_every_serial_storage_component() {
1219        let component_total = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1220            + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1221            + car_auth::AUTH_STATE_OPERATION_BUDGET
1222            + car_auth::AUTH_STATE_OPERATION_BUDGET;
1223        assert!(
1224            AUTH_STATE_RESPONSE_DEADLINE > component_total,
1225            "auth state response bound must include scheduling margin"
1226        );
1227    }
1228
1229    #[test]
1230    fn one_safe_preclaim_retry_and_proof_fit_the_host_reconciliation_horizon() {
1231        // One explicit safe retry may consume the first coordinator queue
1232        // timeout before a second request reaches the claim. The worker lease
1233        // clock starts after the authoritative read and includes claim
1234        // publication plus every redemption/commit phase.
1235        let first_safe_retry_budget =
1236            car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT + car_auth::AUTH_PROCESS_LOCK_TIMEOUT;
1237        let pre_lease_claim_budget = car_auth::AUTH_COORDINATOR_QUEUE_TIMEOUT
1238            + car_auth::AUTH_PROCESS_LOCK_TIMEOUT
1239            + car_auth::AUTH_STATE_OPERATION_BUDGET;
1240        let hydration_reserve = Duration::from_secs(30);
1241        let total_budget = first_safe_retry_budget
1242            + pre_lease_claim_budget
1243            + car_auth::LOGIN_ATTEMPT_WORKER_TTL
1244            + AUTH_STATE_RESPONSE_DEADLINE
1245            + hydration_reserve;
1246
1247        assert!(
1248            total_budget < AUTH_COMPLETION_HOST_RECONCILIATION_HORIZON,
1249            "one safe preclaim retry must still expire, prove, and hydrate before the host stops"
1250        );
1251    }
1252
1253    #[tokio::test(start_paused = true)]
1254    async fn proof_response_timeout_reports_that_daemon_reconciliation_continues() {
1255        let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1256        let task = tokio::spawn(await_daemon_owned_auth_result(
1257            "auth.completion_status",
1258            result_rx,
1259        ));
1260
1261        tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1262        let failure = task
1263            .await
1264            .expect("proof response task should join")
1265            .expect_err("an unpublished proof must hit its explicit response bound");
1266
1267        assert!(matches!(failure, HandlerFailure::Deadline(_)));
1268        assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1269        assert!(failure.message().contains("reconciliation continues"));
1270    }
1271
1272    #[tokio::test(start_paused = true)]
1273    async fn auth_start_and_complete_have_explicit_state_response_bounds() {
1274        for method in ["auth.start", "auth.complete"] {
1275            let (_result_tx, result_rx) = tokio::sync::oneshot::channel();
1276            let task = tokio::spawn(await_daemon_owned_auth_result(method, result_rx));
1277
1278            tokio::time::advance(AUTH_STATE_RESPONSE_DEADLINE).await;
1279            let failure = task
1280                .await
1281                .expect("auth response task should join")
1282                .expect_err("unpublished auth state response must hit its explicit bound");
1283
1284            assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1285            assert!(failure.message().contains("95s"), "{method}: {failure:?}");
1286        }
1287    }
1288
1289    #[test]
1290    fn coordination_deadlines_are_retryable_but_terminal_state_errors_are_not() {
1291        for (method, prefix) in [
1292            ("auth.start", AUTH_START_RETRY_PREFIX),
1293            ("auth.complete", AUTH_COMPLETE_PRE_REDEMPTION_RETRY_PREFIX),
1294            ("auth.completion_status", AUTH_STATUS_RETRY_PREFIX),
1295        ] {
1296            let failure = classified_auth_failure(
1297                method,
1298                car_auth::AuthOperationError::CoordinationDeadline("contended".into()),
1299            );
1300            assert_eq!(failure.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1301            assert!(failure.message().starts_with(prefix), "{failure:?}");
1302        }
1303
1304        let failure = classified_auth_failure(
1305            "auth.completion_status",
1306            car_auth::AuthOperationError::Terminal("corrupt state".into()),
1307        );
1308        assert_eq!(failure.json_rpc_code(), -32603);
1309        assert_eq!(failure.message(), "corrupt state");
1310    }
1311
1312    #[tokio::test(start_paused = true)]
1313    async fn auth_completion_network_phase_has_an_injectable_total_deadline() {
1314        let task = tokio::spawn(auth_completion_network_with_deadline(
1315            Duration::from_secs(1),
1316            std::future::pending::<Result<(), String>>(),
1317        ));
1318
1319        tokio::time::advance(Duration::from_secs(1)).await;
1320        let error = task
1321            .await
1322            .expect("network deadline task should join")
1323            .expect_err("stalled auth network work must time out");
1324
1325        assert_eq!(
1326            error,
1327            "Parslee login network phase timed out after 1s; no credentials were saved"
1328        );
1329    }
1330
1331    #[test]
1332    fn protocol_routing_failures_use_standard_json_rpc_codes() {
1333        let unknown =
1334            HandlerFailure::from_dispatch("unknown method: future.namespace.call".to_string());
1335        assert!(matches!(unknown, HandlerFailure::MethodNotFound(_)));
1336        assert_eq!(unknown.json_rpc_code(), -32601);
1337
1338        let invalid = HandlerFailure::from_dispatch("invalid params: key is required".to_string());
1339        assert!(matches!(invalid, HandlerFailure::InvalidParams(_)));
1340        assert_eq!(invalid.json_rpc_code(), -32602);
1341
1342        let wire =
1343            serde_json::to_value(json_rpc_response_for_handler_result(json!(7), Err(invalid)))
1344                .expect("serialize response");
1345        assert_eq!(wire["error"]["code"], -32602);
1346        assert_eq!(wire["error"]["message"], "invalid params: key is required");
1347    }
1348
1349    #[tokio::test]
1350    async fn ordinary_handler_failure_remains_internal_error() {
1351        let failure = dispatch_with_server_deadline(
1352            "auth.complete",
1353            &Value::Null,
1354            std::future::ready(Err("token exchange failed".to_string())),
1355        )
1356        .await
1357        .expect_err("handler failure should propagate");
1358
1359        assert!(matches!(failure, HandlerFailure::Dispatch(_)));
1360        assert_eq!(failure.json_rpc_code(), -32603);
1361    }
1362
1363    #[test]
1364    fn a_gateway_content_refusal_survives_the_trip_to_its_own_error_code() {
1365        let refusal = car_inference::InferenceError::ContentRefused {
1366            provider: "parslee".to_string(),
1367            kind: Some("invalid_request_error".to_string()),
1368            code: Some("content_policy_violation".to_string()),
1369            message: "The response was filtered due to the prompt triggering the content \
1370                      management policy"
1371                .to_string(),
1372        };
1373
1374        // Writer (handler) → reader (dispatcher). This round trip is what stops
1375        // the two halves of the prefix contract from drifting apart.
1376        let failure = HandlerFailure::from_dispatch(inference_dispatch_error(&refusal));
1377
1378        assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1379        assert_eq!(
1380            failure.json_rpc_code(),
1381            car_proto::CONTENT_REFUSED_ERROR_CODE
1382        );
1383        // The gateway's own classification must still be readable by an
1384        // operator — tagging adds a prefix, it does not replace the detail.
1385        assert!(
1386            failure.message().contains("code=content_policy_violation"),
1387            "{failure:?}"
1388        );
1389        assert!(
1390            failure
1391                .message()
1392                .starts_with(car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
1393            "{failure:?}"
1394        );
1395    }
1396
1397    #[test]
1398    fn ordinary_inference_failures_are_not_classified_as_content_refusals() {
1399        // Over-classification is the dangerous direction: a crash mislabelled as
1400        // a policy refusal tells a benchmark to score it and a retry loop to
1401        // give up on work that would have succeeded. The first string is the
1402        // literal #796 symptom the fix was reported against.
1403        let generic: Vec<car_inference::InferenceError> = vec![
1404            car_inference::InferenceError::InferenceFailed("managed inference failed".to_string()),
1405            car_inference::InferenceError::InferenceFailed(
1406                "content refused by the operator's local policy".to_string(),
1407            ),
1408            car_inference::InferenceError::ModelNotFound("gpt-5.6-sol".to_string()),
1409            car_inference::InferenceError::TokenizationError("bad utf-8 boundary".to_string()),
1410        ];
1411
1412        // The exact string #796 was reported against must still be an internal
1413        // error, not a refusal.
1414        assert_eq!(
1415            generic[0].to_string(),
1416            "inference failed: managed inference failed"
1417        );
1418
1419        for error in &generic {
1420            let failure = HandlerFailure::from_dispatch(inference_dispatch_error(error));
1421            assert!(
1422                matches!(failure, HandlerFailure::Dispatch(_)),
1423                "must stay a generic dispatch failure: {failure:?}"
1424            );
1425            assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1426        }
1427    }
1428
1429    #[test]
1430    fn a_refusal_that_lands_mid_stream_reaches_the_same_error_code() {
1431        // What `infer_stream` actually has at this point: not a typed error, but
1432        // the flattened text `car_inference::stream` wrote, tags and all.
1433        let flattened = "The response was filtered due to the prompt triggering the content \
1434                         management policy (type=invalid_request_error, \
1435                         code=content_policy_violation)";
1436
1437        let failure = HandlerFailure::from_dispatch(stream_dispatch_error(flattened.to_string()));
1438
1439        assert!(matches!(failure, HandlerFailure::ContentRefused(_)));
1440        assert_eq!(
1441            failure.json_rpc_code(),
1442            car_proto::CONTENT_REFUSED_ERROR_CODE
1443        );
1444        // Tagging prefixes; it must not eat the gateway's own text.
1445        assert!(failure.message().contains(flattened), "{failure:?}");
1446    }
1447
1448    #[test]
1449    fn an_untagged_stream_failure_is_not_promoted_to_a_refusal() {
1450        // The safe direction: no classification tags means no verdict. A crash
1451        // mislabelled as a policy ruling tells a harness to score it and a retry
1452        // loop to give up on work that would have succeeded.
1453        for plain in [
1454            "managed inference failed",
1455            "upstream connection reset",
1456            // Prose that merely TALKS about content policy is not a tag.
1457            "the model discussed content policy at length",
1458            // A tag block that carries no refusal classification.
1459            "boom (type=server_error, code=internal)",
1460        ] {
1461            let failure = HandlerFailure::from_dispatch(stream_dispatch_error(plain.to_string()));
1462            assert!(
1463                matches!(failure, HandlerFailure::Dispatch(_)),
1464                "must stay generic: {failure:?}"
1465            );
1466            assert_eq!(failure.json_rpc_code(), -32603, "{failure:?}");
1467        }
1468    }
1469
1470    #[test]
1471    fn a_content_refusal_is_distinguishable_from_a_handler_deadline() {
1472        let refusal = HandlerFailure::from_dispatch(inference_dispatch_error(
1473            &car_inference::InferenceError::ContentRefused {
1474                provider: "parslee".to_string(),
1475                kind: None,
1476                code: None,
1477                message: "declined".to_string(),
1478            },
1479        ));
1480        let deadline = HandlerFailure::Deadline("handler deadline".to_string());
1481
1482        assert_eq!(
1483            refusal.json_rpc_code(),
1484            car_proto::CONTENT_REFUSED_ERROR_CODE
1485        );
1486        assert_eq!(deadline.json_rpc_code(), HANDLER_DEADLINE_ERROR_CODE);
1487        assert_ne!(refusal.json_rpc_code(), deadline.json_rpc_code());
1488        assert_ne!(refusal.json_rpc_code(), -32603);
1489    }
1490
1491    #[test]
1492    fn deadline_failure_serializes_as_the_actual_json_rpc_envelope() {
1493        let response = json_rpc_response_for_handler_result(
1494            json!(42),
1495            Err(HandlerFailure::Deadline("handler deadline".to_string())),
1496        );
1497        let wire = serde_json::to_value(response).expect("serialize response");
1498
1499        assert_eq!(
1500            wire,
1501            json!({
1502                "jsonrpc": "2.0",
1503                "error": {
1504                    "code": HANDLER_DEADLINE_ERROR_CODE,
1505                    "message": "handler deadline",
1506                },
1507                "id": 42,
1508            })
1509        );
1510    }
1511
1512    #[test]
1513    fn attempt_completion_wire_value_carries_the_generation_and_session() {
1514        let record = car_auth::AuthCompletionRecord {
1515            attempt_id: "attempt-7".to_string(),
1516            generation: 19,
1517            account_id: Some("account-b".to_string()),
1518            session: Some(r#"{"Account":{"Id":"account-b"}}"#.to_string()),
1519        };
1520
1521        assert_eq!(
1522            auth_completion_value(&record),
1523            json!({
1524                "state": "complete",
1525                "attempt_id": "attempt-7",
1526                "generation": 19,
1527                "account_id": "account-b",
1528                "session": {"Account": {"Id": "account-b"}},
1529            })
1530        );
1531    }
1532}
1533
1534/// Transport-neutral entry point: drives the JSON-RPC dispatch loop
1535/// against an already-handshake-completed split WebSocket. Generic
1536/// over the read half (any `Stream<Item = Result<Message, WsError>>`)
1537/// and the write half (a [`WsSink`](crate::session::WsSink) — type-erased so this function
1538/// doesn't templatize every downstream consumer of `WsChannel`).
1539///
1540/// `peer` is a free-form string ("127.0.0.1:1234" for TCP,
1541/// "uds:/path/sock" for UDS, "axum:..." for embedders) — used only
1542/// for tracing fields, never for dispatch logic.
1543#[instrument(
1544    name = "ws.dispatch",
1545    skip_all,
1546    fields(client_id = tracing::field::Empty, peer = %peer),
1547)]
1548pub async fn run_dispatch<R>(
1549    mut read: R,
1550    write: crate::session::WsSink,
1551    peer: String,
1552    state: Arc<ServerState>,
1553) -> Result<(), Box<dyn std::error::Error>>
1554where
1555    R: futures::Stream<Item = Result<Message, tokio_tungstenite::tungstenite::Error>>
1556        + Unpin
1557        + Send,
1558{
1559    let client_id = uuid::Uuid::new_v4().simple().to_string()[..12].to_string();
1560    tracing::Span::current().record("client_id", client_id.as_str());
1561
1562    info!("New connection from {}", peer);
1563
1564    let channel = Arc::new(WsChannel {
1565        write: Mutex::new(write),
1566        pending: Mutex::new(HashMap::new()),
1567        active_actions: Mutex::new(HashMap::new()),
1568        next_id: AtomicU64::new(1),
1569    });
1570
1571    // Refusing the connection is the intended outcome of a malformed
1572    // `~/.car/policies/*.toml` (see `session::apply_project_policies`): a
1573    // client that connects anyway would be running under a rule set the
1574    // operator wrote and the daemon silently did not apply.
1575    let session = match state.create_session(&client_id, channel.clone()).await {
1576        Ok(session) => session,
1577        Err(e) => {
1578            error!("refusing connection from {}: {}", peer, e);
1579            return Err(e.into());
1580        }
1581    };
1582
1583    // car#209: per-request handlers are spawned detached (below) and
1584    // each clones `Arc<ClientSession>` → `Arc<WsChannel>`. A bare
1585    // `tokio::spawn` outlives the connection, so a slow/hung handler
1586    // pins the split sink and the inbound socket lingers in CLOSED
1587    // until the daemon hits EMFILE. Own them in a per-connection
1588    // `JoinSet` so every in-flight handler is aborted the instant the
1589    // WS drops (`abort_all` in the cleanup block; `JoinSet`'s Drop
1590    // also aborts on any early return), releasing the FD immediately.
1591    let mut conn_tasks: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1592
1593    // Every authenticated client receives the same non-secret OpenRouter
1594    // authority stream. This is deliberately connection-wide rather than
1595    // flow-scoped: a mutation on client B must promptly supersede a delayed
1596    // response on client A. Hosts compare `authority_generation` before flow
1597    // correlation, so ordering remains deterministic across WebSockets.
1598    let mut openrouter_updates = crate::openrouter_auth::subscribe_authority_updates();
1599    let openrouter_session = session.clone();
1600    let openrouter_auth_required = state.auth_token.get().is_some();
1601    conn_tasks.spawn(async move {
1602        loop {
1603            if openrouter_updates.changed().await.is_err() {
1604                break;
1605            }
1606            if openrouter_auth_required
1607                && !openrouter_session
1608                    .authenticated
1609                    .load(std::sync::atomic::Ordering::Acquire)
1610            {
1611                continue;
1612            }
1613            let Some(status) = openrouter_updates.borrow_and_update().clone() else {
1614                continue;
1615            };
1616            let notification = serde_json::json!({
1617                "jsonrpc": "2.0",
1618                "method": "openrouter.auth.event",
1619                "params": status,
1620            });
1621            let Ok(text) = serde_json::to_string(&notification) else {
1622                continue;
1623            };
1624            if openrouter_session
1625                .channel
1626                .write
1627                .lock()
1628                .await
1629                .send(Message::Text(text.into()))
1630                .await
1631                .is_err()
1632            {
1633                break;
1634            }
1635        }
1636    });
1637
1638    // Parslee credential reads are process-owned and may be initiated by a
1639    // different host connection or by request-time routing. Fan their
1640    // secret-free generation/status stream only to a connection that has
1641    // completed both transport authentication and host-management
1642    // authorization. A managed non-host must learn nothing from this channel.
1643    let credential_session = session.clone();
1644    let credential_transport_auth_required = state.auth_token.get().is_some();
1645    let credential_host_role_required = state.host_token.get().is_some();
1646    let (credential_activation_tx, mut credential_activation_rx) =
1647        tokio::sync::watch::channel(false);
1648    let (credential_ready_tx, credential_ready_rx) = tokio::sync::watch::channel(false);
1649    let (credential_lag_tx, mut credential_lag_rx) = tokio::sync::oneshot::channel();
1650    conn_tasks.spawn(async move {
1651        pause_credential_fanout_for_test().await;
1652        let mut credential_lag_tx = Some(credential_lag_tx);
1653
1654        // Do not register an ordered event subscriber until this connection is
1655        // authorized to receive the stream. This makes pre-auth/non-host
1656        // traffic incapable of filling a queue whose later close could be
1657        // mistaken for an eligible-host overflow. Authentication and protocol
1658        // negotiation only move forward during a connection, and the task is
1659        // aborted by connection cleanup if eligibility is never reached.
1660        while !credential_event_is_eligible(
1661            &credential_session,
1662            credential_transport_auth_required,
1663            credential_host_role_required,
1664        ) {
1665            if credential_activation_rx.changed().await.is_err() {
1666                return;
1667            }
1668        }
1669
1670        // Atomically capture the retained process snapshot and register the
1671        // lossless stream. The credential layer linearizes every publication
1672        // as either snapshot state from before registration or queued state
1673        // from after it. Generation/state deduplication below still rejects a
1674        // duplicate or terminal-to-pending regression defensively.
1675        let car_auth::CredentialReadEventHandoff {
1676            snapshot,
1677            events: mut credential_events,
1678        } = car_auth::subscribe_credential_read_event_handoff();
1679        pause_credential_handoff_for_test().await;
1680        let mut reconciled = snapshot.into_iter();
1681        let mut last_status = None;
1682        let _ = credential_ready_tx.send(true);
1683        loop {
1684            let status = match reconciled.next() {
1685                Some(status) => status,
1686                None => {
1687                    let closed = credential_events.closed();
1688                    tokio::select! {
1689                        biased;
1690                        reason = closed => {
1691                            if let Some(sender) = credential_lag_tx.take() {
1692                                let _ = sender.send(reason);
1693                            }
1694                            break;
1695                        }
1696                        event = credential_events.recv() => match event {
1697                            Ok(status) => status,
1698                            Err(reason) => {
1699                                if let Some(sender) = credential_lag_tx.take() {
1700                                    let _ = sender.send(reason);
1701                                }
1702                                break;
1703                            }
1704                        }
1705                    }
1706                }
1707            };
1708            if !credential_status_advances(last_status, status) {
1709                continue;
1710            }
1711            let notification = serde_json::json!({
1712                "jsonrpc": "2.0",
1713                "method": "auth.credential.event",
1714                "params": status,
1715            });
1716            let Ok(text) = serde_json::to_string(&notification) else {
1717                continue;
1718            };
1719            let closed = credential_events.closed();
1720            tokio::select! {
1721                biased;
1722                reason = closed => {
1723                    if let Some(sender) = credential_lag_tx.take() {
1724                        let _ = sender.send(reason);
1725                    }
1726                    break;
1727                }
1728                result = async {
1729                    credential_session
1730                        .channel
1731                        .write
1732                        .lock()
1733                        .await
1734                        .send(Message::Text(text.into()))
1735                        .await
1736                } => {
1737                    if result.is_err() {
1738                        break;
1739                    }
1740                    last_status = Some(status);
1741                }
1742            }
1743        }
1744    });
1745
1746    // Server-side WebSocket keepalive. See KEEPALIVE_* consts: tokio-tungstenite
1747    // emits no automatic pings, so a SILENTLY dead connection (TCP half-open, no
1748    // close frame) on a QUIET stream is never noticed — its drain task parks on
1749    // recv() and its registry entries leak until the daemon restarts. We ping on
1750    // an interval; a live client (even a quiet one) answers with a pong, which —
1751    // like any inbound frame — refreshes `last_inbound`. No frame for
1752    // KEEPALIVE_DEAD_AFTER, or a failed/timed-out ping write, means the peer is
1753    // gone → break into the SINGLE cleanup path below (deregisters subscribers,
1754    // aborts in-flight handlers). This is the "ping timeout" case the cleanup
1755    // comment anticipates.
1756    let mut keepalive = tokio::time::interval(KEEPALIVE_PING_INTERVAL);
1757    keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1758    // `interval`'s first tick is immediate; consume it so the first ping fires a
1759    // full interval after connect (never racing a client's opening request).
1760    keepalive.tick().await;
1761    let mut last_inbound = tokio::time::Instant::now();
1762    loop {
1763        // Reap finished handlers so a long-lived connection doesn't
1764        // retain their `JoinHandle`s unbounded (memory, not FDs).
1765        while conn_tasks.try_join_next().is_some() {}
1766
1767        // INVARIANT: every arm yields a `Message` or diverges (break/continue).
1768        // `read.next()` and `keepalive.tick()` are both cancellation-safe, so the
1769        // loser of each select is simply re-polled next iteration with no loss.
1770        let msg = tokio::select! {
1771            closed = &mut credential_lag_rx => {
1772                match closed {
1773                    Ok(reason) => info!(
1774                        client = %client_id,
1775                        ?reason,
1776                        "credential event subscriber closed; terminating connection"
1777                    ),
1778                    Err(_) => info!(
1779                        client = %client_id,
1780                        "credential event monitor ended; terminating connection"
1781                    ),
1782                }
1783                break;
1784            }
1785            maybe = read.next() => match maybe {
1786                // Any inbound frame — text, binary, ping, or the pong our
1787                // keepalive provokes — proves the peer is alive.
1788                Some(Ok(m)) => {
1789                    last_inbound = tokio::time::Instant::now();
1790                    m
1791                }
1792                // car#209: on an abrupt transport error (peer reset / network
1793                // drop — the trader crash-loop case) `read.next()` yields
1794                // `Some(Err(_))`. The old `let msg = msg?;` propagated that
1795                // out of the function, *skipping the cleanup block below*, so
1796                // `state.sessions` (+ the host/a2ui/chat registries) kept the
1797                // session + channel — and thus the socket FD — forever. Break
1798                // instead: every disconnect, clean or not, runs the single
1799                // cleanup path. `None` = the stream ended cleanly.
1800                Some(Err(e)) => {
1801                    info!("read error from {}: {}; closing", client_id, e);
1802                    break;
1803                }
1804                None => break,
1805            },
1806            _ = keepalive.tick() => {
1807                if last_inbound.elapsed() >= KEEPALIVE_DEAD_AFTER {
1808                    info!(
1809                        "keepalive: no frames from {} for {}s; closing",
1810                        client_id,
1811                        last_inbound.elapsed().as_secs()
1812                    );
1813                    break;
1814                }
1815                // Provoke a pong from a quiet-but-alive client. The timeout wraps
1816                // the lock acquisition AND the send: `channel.write` is shared
1817                // with the dispatch handlers' `send_response` (an unbounded
1818                // write), so a handler wedged on a full TCP buffer can hold the
1819                // lock until its own 180s deadline — bounding only the send would
1820                // let the read loop park on `.lock().await` and defeat the 90s
1821                // liveness contract. Elapsed here covers a stuck lock or a stuck
1822                // socket; either way the peer is treated as gone.
1823                let sent = tokio::time::timeout(KEEPALIVE_WRITE_TIMEOUT, async {
1824                    let mut guard = channel.write.lock().await;
1825                    guard.send(Message::Ping(Vec::new().into())).await
1826                })
1827                .await;
1828                if !matches!(sent, Ok(Ok(()))) {
1829                    info!("keepalive: ping to {} failed/timed out; closing", client_id);
1830                    break;
1831                }
1832                continue;
1833            }
1834        };
1835        if msg.is_text() {
1836            let text = match msg.to_text() {
1837                Ok(t) => t,
1838                Err(e) => {
1839                    info!("non-text frame from {}: {}; closing", client_id, e);
1840                    break;
1841                }
1842            };
1843            let parsed: JsonRpcMessage = match serde_json::from_str(text) {
1844                Ok(m) => m,
1845                Err(e) => {
1846                    send_response(
1847                        &session.channel,
1848                        JsonRpcResponse::error(Value::Null, -32700, &format!("Parse error: {}", e)),
1849                    )
1850                    .await
1851                    .ok();
1852                    continue;
1853                }
1854            };
1855
1856            // Is this a response to a pending tool callback?
1857            if parsed.method.is_none() && (parsed.result.is_some() || parsed.error.is_some()) {
1858                if let Some(id_str) = parsed.id.as_str() {
1859                    let mut pending = session.channel.pending.lock().await;
1860                    if let Some(tx) = pending.remove(id_str) {
1861                        let tool_resp = if let Some(result) = parsed.result {
1862                            ToolExecuteResponse {
1863                                action_id: id_str.to_string(),
1864                                output: Some(result),
1865                                error: None,
1866                                terminal: false,
1867                            }
1868                        } else {
1869                            let err_msg = parsed
1870                                .error
1871                                .as_ref()
1872                                .and_then(|e| e.get("message"))
1873                                .and_then(|m| m.as_str())
1874                                .unwrap_or("unknown error")
1875                                .to_string();
1876                            // Terminality is an additive typed bit in error.data,
1877                            // never a word inferred from the human message.
1878                            let terminal = parsed
1879                                .error
1880                                .as_ref()
1881                                .and_then(|error| error.get("data"))
1882                                .and_then(|data| data.get("terminal"))
1883                                .and_then(Value::as_bool)
1884                                .unwrap_or(false);
1885                            ToolExecuteResponse {
1886                                action_id: id_str.to_string(),
1887                                output: None,
1888                                error: Some(err_msg),
1889                                terminal,
1890                            }
1891                        };
1892                        let _ = tx.send(tool_resp);
1893                        continue;
1894                    }
1895                }
1896            }
1897
1898            // Per-agent daemon-method scope. This is the ONE admission point:
1899            // it runs before notification interceptors, the handshake-gated
1900            // dispatch, host-management admission, approval admission, and the
1901            // method handler table. A denied request therefore cannot trigger
1902            // any method-specific side effect. Notifications are dropped
1903            // without a response, as JSON-RPC requires.
1904            if let Some(method) = parsed.method.as_deref() {
1905                if !agent_method_scope_allows(&session, method) {
1906                    if !parsed.id.is_null() {
1907                        let response = JsonRpcResponse::error(
1908                            parsed.id.clone(),
1909                            AGENT_METHOD_NOT_ALLOWED_ERROR_CODE,
1910                            &format!("{AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX}`{method}`"),
1911                        );
1912                        let _ = send_response(&session.channel, response).await;
1913                    }
1914                    info!(
1915                        client = %client_id,
1916                        method = %method,
1917                        "agent_method_not_allowed: scoped supervised-agent session denied before dispatch"
1918                    );
1919                    continue;
1920                }
1921            }
1922
1923            // Agent → host chat-event interceptor. `agent.chat.event`
1924            // notifications coming up the WS from a connected agent
1925            // are forwarded to the originating host's channel as
1926            // `agents.chat.event`. Lives ahead of the regular method
1927            // dispatch so the dispatcher doesn't reply with
1928            // "method-not-found" on what is a fire-and-forget
1929            // notification (no id). See
1930            // `docs/proposals/agent-chat-surface.md`.
1931            if try_forward_agent_chat_event(&parsed, &state).await {
1932                continue;
1933            }
1934
1935            // Agent → drawer browser pushes (`browser.producer.presentation`,
1936            // `browser.producer.frame`). Notifications too, for the same
1937            // reason: they carry no id, so they must be consumed here rather
1938            // than answered with a method-not-found nobody is listening for.
1939            if crate::browser_relay::try_handle_producer_push(&parsed, &state, &session).await {
1940                continue;
1941            }
1942
1943            // Otherwise it's a client request
1944            if let Some(method) = &parsed.method {
1945                info!(method = %method, "dispatching JSON-RPC method");
1946
1947                // Auth gate (Parslee-ai/car-releases#32). When the
1948                // server has an auth token installed, every method
1949                // other than `session.auth` is rejected on
1950                // unauthenticated sessions and the connection is
1951                // closed after the error response goes out. When no
1952                // token is installed (default), this branch never
1953                // fires — preserves pre-#32 behaviour.
1954                if state.auth_token.get().is_some()
1955                    && !session
1956                        .authenticated
1957                        .load(std::sync::atomic::Ordering::Acquire)
1958                    && method != "session.auth"
1959                {
1960                    let resp =
1961                        JsonRpcResponse::error(parsed.id.clone(), -32001, &auth_required_message());
1962                    let _ = send_response(&session.channel, resp).await;
1963                    info!(client = %client_id, method = %method,
1964                        "rejecting non-auth method on unauthenticated session; closing");
1965                    break;
1966                }
1967
1968                // Supervised-agent auth binds a token-specific method scope.
1969                // Run that form inline so a second already-buffered frame
1970                // cannot race the scope write on an auth-disabled daemon and
1971                // observe the legacy unrestricted `None`. Generic daemon-token
1972                // and host-token auth keep the existing spawned path below.
1973                if method == "session.auth"
1974                    && parsed
1975                        .params
1976                        .get("agent_id")
1977                        .and_then(Value::as_str)
1978                        .is_some()
1979                {
1980                    let result = dispatch_with_server_deadline(
1981                        method,
1982                        &parsed.params,
1983                        handle_session_auth(&parsed, &session, &state),
1984                    )
1985                    .await;
1986                    if result.is_ok()
1987                        && credential_event_is_eligible(
1988                            &session,
1989                            credential_transport_auth_required,
1990                            credential_host_role_required,
1991                        )
1992                    {
1993                        let _ = credential_activation_tx.send(true);
1994                        let mut ready = credential_ready_rx.clone();
1995                        while !*ready.borrow_and_update() {
1996                            if ready.changed().await.is_err() {
1997                                break;
1998                            }
1999                        }
2000                    }
2001                    let response = json_rpc_response_for_handler_result(parsed.id, result);
2002                    let _ = send_response(&session.channel, response).await;
2003                    continue;
2004                }
2005
2006                // Protocol negotiation is connection-scoped and follows
2007                // transport auth: an auth-enabled daemon still requires
2008                // `session.auth` as frame #1, then `server.handshake`, then
2009                // host/auth application calls. Handle negotiation inline so
2010                // the version state is committed before its success response
2011                // is observable; the client may safely send `host.subscribe`
2012                // as soon as it receives that response.
2013                if method == "server.handshake" {
2014                    let (response, handshake_succeeded) =
2015                        match handle_server_handshake(&parsed, &session) {
2016                            Ok(result) => {
2017                                (JsonRpcResponse::success(parsed.id.clone(), result), true)
2018                            }
2019                            Err(error) => (
2020                                JsonRpcResponse::error(
2021                                    parsed.id.clone(),
2022                                    error.code,
2023                                    &error.message,
2024                                ),
2025                                false,
2026                            ),
2027                        };
2028                    if handshake_succeeded
2029                        && credential_event_is_eligible(
2030                            &session,
2031                            credential_transport_auth_required,
2032                            credential_host_role_required,
2033                        )
2034                    {
2035                        let _ = credential_activation_tx.send(true);
2036                        let mut ready = credential_ready_rx.clone();
2037                        while !*ready.borrow_and_update() {
2038                            if ready.changed().await.is_err() {
2039                                break;
2040                            }
2041                        }
2042                    }
2043                    let _ = send_response(&session.channel, response).await;
2044                    continue;
2045                }
2046
2047                if requires_protocol_handshake(method)
2048                    && session
2049                        .negotiated_protocol_version
2050                        .load(std::sync::atomic::Ordering::Acquire)
2051                        != car_proto::PROTOCOL_VERSION
2052                {
2053                    let message = format!(
2054                        "{} call `server.handshake` with {{\"protocol_version\":{}}} \
2055                         and wait for an exact-version success before `{}`",
2056                        car_proto::PROTOCOL_HANDSHAKE_REQUIRED_MESSAGE_PREFIX,
2057                        car_proto::PROTOCOL_VERSION,
2058                        method,
2059                    );
2060                    let response = JsonRpcResponse::error(
2061                        parsed.id.clone(),
2062                        car_proto::PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE,
2063                        &message,
2064                    );
2065                    let _ = send_response(&session.channel, response).await;
2066                    info!(
2067                        client = %client_id,
2068                        method = %method,
2069                        "rejecting handshake-gated method before negotiation"
2070                    );
2071                    continue;
2072                }
2073
2074                // Apply the exported host-management set at the dispatcher
2075                // boundary before any handler can run. Individual handlers
2076                // retain their local authority checks as defense in depth; the
2077                // capability generator derives the set from those real call
2078                // sites and tests it against this shared client/daemon list.
2079                if crate::HOST_MANAGEMENT_METHODS.contains(&method.as_str()) {
2080                    if let Err(error) = require_approval_authority(&session, &state) {
2081                        let response = json_rpc_response_for_handler_result(
2082                            parsed.id.clone(),
2083                            Err(HandlerFailure::Dispatch(error)),
2084                        );
2085                        let _ = send_response(&session.channel, response).await;
2086                        continue;
2087                    }
2088                }
2089
2090                // Approval gate (audit 2026-05). High-risk methods —
2091                // anything that drives macOS automation or sends
2092                // messages on the user's behalf — must be acked by
2093                // the user via `host.resolve_approval` before they
2094                // dispatch. The gate raises an `approval.requested`
2095                // host event the local UI can render approve/deny on,
2096                // then parks until resolved or the configured
2097                // timeout fires. Returns a JSON-RPC error and
2098                // continues the dispatch loop on deny / timeout —
2099                // no connection close, since the caller may want to
2100                // retry with revised parameters.
2101                if state.approval_gate.requires_approval(method.as_str()) {
2102                    match gate_high_risk_method(method.as_str(), &parsed.params, &state).await {
2103                        Ok(()) => {}
2104                        Err(reason) => {
2105                            let resp = JsonRpcResponse::error(parsed.id.clone(), -32003, &reason);
2106                            let _ = send_response(&session.channel, resp).await;
2107                            info!(
2108                                client = %client_id,
2109                                method = %method,
2110                                reason = %reason,
2111                                "approval gate blocked dispatch"
2112                            );
2113                            continue;
2114                        }
2115                    }
2116                }
2117
2118                // Credential-backed auth operations own durable coordinator /
2119                // keychain state, so the connection must never own their core
2120                // futures. Authorize while this session is present, then move
2121                // only owned request/state data into the ServerState task set.
2122                // The oneshot waiter (when there is one) is connection-owned and
2123                // is aborted on disconnect, so no late response can retain or
2124                // leak into a later WebSocket.
2125                if is_daemon_owned_auth_method(method) {
2126                    let response_id = parsed.id.clone();
2127                    if let Err(error) = require_approval_authority(&session, &state) {
2128                        let response = json_rpc_response_for_handler_result(
2129                            response_id,
2130                            Err(HandlerFailure::Dispatch(error)),
2131                        );
2132                        let _ = send_response(&session.channel, response).await;
2133                        continue;
2134                    }
2135
2136                    let method_owned = method.clone();
2137                    let method_for_waiter = method_owned.clone();
2138                    let parsed_owned = parsed;
2139                    let state_owned = state.clone();
2140                    let result_rx = state
2141                        .spawn_durable_operation(method_owned.clone(), async move {
2142                            dispatch_daemon_owned_auth(
2143                                method_owned.as_str(),
2144                                &parsed_owned,
2145                                &state_owned,
2146                            )
2147                            .await
2148                        })
2149                        .await;
2150                    let response_channel = session.channel.clone();
2151                    conn_tasks.spawn(async move {
2152                        let result =
2153                            await_daemon_owned_auth_result(method_for_waiter.as_str(), result_rx)
2154                                .await;
2155                        let response = json_rpc_response_for_handler_result(response_id, result);
2156                        let _ = send_response(&response_channel, response).await;
2157                    });
2158                    continue;
2159                }
2160
2161                // Spawn the per-method dispatch in a task so the read
2162                // loop keeps reading frames. Without this, methods
2163                // that trigger server-initiated `tools.execute`
2164                // callbacks (`proposal.submit`, `workflow.run`,
2165                // `multi.*` paths that fire a registered tool)
2166                // deadlock the connection: the handler awaits the
2167                // callback response on a oneshot, but the response is
2168                // another frame on this same read half — which the
2169                // synchronous `.await` here would prevent the loop
2170                // from ever picking up. Surfaced by the
2171                // `executeProposal: echo tool via JS callback` smoke
2172                // (#173). Response ordering becomes id-keyed (the
2173                // JSON-RPC demuxing contract) rather than
2174                // arrival-ordered.
2175                let session_task = session.clone();
2176                let state_task = state.clone();
2177                let method_owned = method.clone();
2178                let parsed_task = parsed;
2179                let credential_activation_tx_task = credential_activation_tx.clone();
2180                let credential_ready_rx_task = credential_ready_rx.clone();
2181                // car#209: owned by the per-connection JoinSet so it's
2182                // aborted on disconnect instead of leaking the channel.
2183                conn_tasks.spawn(async move {
2184                    let session = session_task;
2185                    let state = state_task;
2186                    let parsed = parsed_task;
2187                    // The identity this connection authenticated as, resolved
2188                    // ONCE per frame. Read here rather than inside each arm for
2189                    // two reasons: a `session.agent_id.lock().await` in argument
2190                    // position keeps the guard alive for the whole arm body —
2191                    // including the handler's own `await`, which for
2192                    // `agents.wait` is caller-controlled — and one read per
2193                    // frame cannot disagree with itself the way thirteen
2194                    // independent acquisitions can. The six mutating
2195                    // `agents.*` handlers deliberately receive the session and
2196                    // clone this identity again inside their short authority
2197                    // check: that keeps the host/agent dependency explicit at
2198                    // the handler call site and drops the guard before any
2199                    // supervisor await.
2200                    let bound_agent = session.agent_id.lock().await.clone();
2201                    let bound_agent = bound_agent.as_deref();
2202                    // INVARIANT: arms must be pure tail expressions — no `?`,
2203                    // `return`, or `continue` — since they now live inside this
2204                    // `async` block, not the surrounding loop/fn.
2205                    let dispatch = async {
2206                        match method_owned.as_str() {
2207                            "session.auth" => handle_session_auth(&parsed, &session, &state).await,
2208                            "capabilities.list" => Ok(handle_capabilities_list(
2209                                bound_agent,
2210                                session.is_host.load(std::sync::atomic::Ordering::Acquire),
2211                            )),
2212                            "parslee.auth" => handle_parslee_auth(&state).await,
2213                            "parslee.capabilities" => crate::parslee_capabilities::discover().await,
2214                            "parslee.m365.generate_document" => {
2215                                crate::parslee_m365::generate_document(&parsed.params).await
2216                            }
2217                            "openrouter.status" => handle_openrouter_status(&session, &state),
2218                            "openrouter.auth_start" => {
2219                                handle_openrouter_auth_start(&parsed, &session, &state).await
2220                            }
2221                            "openrouter.auth_cancel" => {
2222                                handle_openrouter_auth_cancel(&parsed, &session, &state)
2223                            }
2224                            "openrouter.disconnect" => {
2225                                handle_openrouter_disconnect(&session, &state).await
2226                            }
2227                            "session.init" => handle_session_init(&parsed, &session).await,
2228                            "session.bindSandbox" => {
2229                                handle_session_bind_sandbox(&parsed, &session, &state).await
2230                            }
2231                            "session.bindSubstrate" => {
2232                                handle_session_bind_substrate(&parsed, &session, &state).await
2233                            }
2234                            "session.clear_halt" => handle_session_clear_halt(&session).await,
2235                            "server.schema" => handle_server_schema(),
2236                            "host.subscribe" => handle_host_subscribe(&session, &state).await,
2237                            "supervision.subscribe" => {
2238                                handle_supervision_subscribe(&parsed, &session, &state).await
2239                            }
2240                            "supervision.unsubscribe" => {
2241                                handle_supervision_unsubscribe(&session, &state).await
2242                            }
2243                            "supervision.pending" => handle_supervision_pending(&state).await,
2244                            "supervision.decide" => {
2245                                handle_supervision_decide(&parsed, &state).await
2246                            }
2247                            "host.agents" => handle_host_agents(&session, &state).await,
2248                            "host.events" => handle_host_events(&parsed, &session).await,
2249                            "host.approvals" => handle_host_approvals(&session).await,
2250                            "host.register_agent" => {
2251                                handle_host_register_agent(&parsed, &session).await
2252                            }
2253                            "host.unregister_agent" => {
2254                                handle_host_unregister_agent(&parsed, &session).await
2255                            }
2256                            "host.set_status" => handle_host_set_status(&parsed, &session).await,
2257                            "host.register_device" => {
2258                                handle_host_register_device(&parsed, &session).await
2259                            }
2260                            "host.update_device" => {
2261                                handle_host_update_device(&parsed, &session).await
2262                            }
2263                            "host.devices" => handle_host_devices(&session).await,
2264                            "mobile.runtime" => handle_mobile_runtime(&state).await,
2265                            "host.notify" => handle_host_notify(&parsed, &session).await,
2266                            "host.request_approval" => {
2267                                handle_host_request_approval(&parsed, &session).await
2268                            }
2269                            "host.resolve_approval" => {
2270                                handle_host_resolve_approval(&parsed, &session).await
2271                            }
2272                            "tools.register" => handle_tools_register(&parsed, &session).await,
2273                            "tools.list" => handle_tools_list(&session).await,
2274                            "tools.unregister" => handle_tools_unregister(&parsed, &session).await,
2275                            "tools.poll" => handle_tools_poll(&parsed, &session).await,
2276                            "tools.cancel" => handle_tools_cancel(&parsed, &session).await,
2277                            "tools.stream.subscribe" => {
2278                                handle_tools_stream_subscribe(&session).await
2279                            }
2280                            "proposal.submit" => {
2281                                handle_proposal_submit(&parsed, &session, &state).await
2282                            }
2283                            "policy.register" => handle_policy_register(&parsed, &session).await,
2284                            "policy.unregister" => {
2285                                handle_policy_unregister(&parsed, &session).await
2286                            }
2287                            "policy.list" => handle_policy_list(&parsed, &session).await,
2288                            "session.policy.open" => handle_session_policy_open(&session).await,
2289                            "session.policy.close" => {
2290                                handle_session_policy_close(&parsed, &session).await
2291                            }
2292                            "verify" => handle_verify(&parsed, &session).await,
2293                            "verify.monte_carlo" => {
2294                                handle_verify_monte_carlo(&parsed, &session).await
2295                            }
2296                            "permission.get_tier" => handle_permission_get_tier(&session).await,
2297                            "permission.set_tier" => {
2298                                handle_permission_set_tier(&parsed, &session, &state).await
2299                            }
2300                            "permission.classify" => {
2301                                handle_permission_classify(&parsed, &session).await
2302                            }
2303                            "permission.evaluate" => {
2304                                handle_permission_evaluate(&parsed, &session, &state).await
2305                            }
2306                            "permission.pending" => {
2307                                handle_permission_pending(&parsed, &session, &state).await
2308                            }
2309                            "permission.approve" => {
2310                                handle_permission_decision(&parsed, &session, &state, true).await
2311                            }
2312                            "permission.reject" => {
2313                                handle_permission_decision(&parsed, &session, &state, false).await
2314                            }
2315                            "agent_permissions.get" => {
2316                                crate::agent_permissions::handle_get(&parsed)
2317                            }
2318                            "agent_permissions.set" => {
2319                                require_agent_permissions_authority(&session, &state).await?;
2320                                crate::agent_permissions::handle_set(&parsed)
2321                            }
2322                            "agent_permissions.set_default" => {
2323                                require_agent_permissions_authority(&session, &state).await?;
2324                                crate::agent_permissions::handle_set_default(&parsed)
2325                            }
2326                            "agent_permissions.reset" => {
2327                                require_agent_permissions_authority(&session, &state).await?;
2328                                crate::agent_permissions::handle_reset(&parsed)
2329                            }
2330                            "agent_permissions.evaluate" => {
2331                                crate::agent_permissions::handle_evaluate(&parsed)
2332                            }
2333                            "agent_permissions.set_tool" => {
2334                                require_agent_permissions_authority(&session, &state).await?;
2335                                require_agent_tool_override_capability(&session)?;
2336                                handle_agent_permission_set_tool(&parsed, &state).await
2337                            }
2338                            "agent_permissions.reset_tool" => {
2339                                require_agent_permissions_authority(&session, &state).await?;
2340                                require_agent_tool_override_capability(&session)?;
2341                                crate::agent_permissions::handle_reset_tool(&parsed)
2342                            }
2343                            "agent_permissions.evaluate_tool" => {
2344                                require_agent_permissions_authority(&session, &state).await?;
2345                                require_agent_tool_override_capability(&session)?;
2346                                handle_agent_permission_evaluate_tool(&parsed, &state).await
2347                            }
2348                            // Multi-device sync + execution lease (B6).
2349                            "sync.status" => handle_sync_status(&state).await,
2350                            "sync.append" => handle_sync_append(&parsed, &state).await,
2351                            "sync.record_turn" => handle_sync_record_turn(&parsed, &state).await,
2352                            "sync.record_intent" => {
2353                                handle_sync_record_intent(&parsed, &state, &session).await
2354                            }
2355                            "sync.pump" => handle_sync_pump(&state).await,
2356                            "sync.knowledge" => handle_sync_knowledge(&parsed, &state).await,
2357                            "sync.checkpoint" => handle_sync_checkpoint(&state).await,
2358                            "sync.rebase" => handle_sync_rebase(&state).await,
2359                            "sync.transcript" => handle_sync_transcript(&parsed, &state).await,
2360                            "sync.resume" => handle_sync_resume(&parsed, &state).await,
2361                            "sync.assistant_checkpoint.put" => {
2362                                handle_sync_assistant_checkpoint_put(&parsed, &state).await
2363                            }
2364                            "sync.assistant_checkpoint.get" => {
2365                                handle_sync_assistant_checkpoint_get(&parsed, &state).await
2366                            }
2367                            "sync.assistant_action.put" => {
2368                                handle_sync_assistant_action_put(&parsed, &state).await
2369                            }
2370                            "sync.assistant_action.get" => {
2371                                handle_sync_assistant_action_get(&parsed, &state).await
2372                            }
2373                            "sync.fence_check" => {
2374                                handle_sync_fence_check(&parsed, &state, &session).await
2375                            }
2376                            "lease.acquire" => {
2377                                handle_lease_acquire(&parsed, &state, &session).await
2378                            }
2379                            "lease.renew" => handle_lease_renew(&parsed, &state, &session).await,
2380                            "lease.release" => {
2381                                handle_lease_release(&parsed, &state, &session).await
2382                            }
2383                            "lease.status" => {
2384                                handle_lease_status(&parsed, &state, bound_agent).await
2385                            }
2386                            "messaging.config.get" => {
2387                                handle_messaging_config_get(&parsed, &session, &state).await
2388                            }
2389                            "messaging.config.set" => {
2390                                handle_messaging_config_set(&parsed, &session, &state).await
2391                            }
2392                            "messaging.pairing.start" => {
2393                                handle_messaging_pairing_start(&parsed, &session, &state).await
2394                            }
2395                            "messaging.pairing.status" => {
2396                                handle_messaging_pairing_status(&parsed, &session, &state).await
2397                            }
2398                            "messaging.status" => {
2399                                handle_messaging_status(&parsed, &session, &state).await
2400                            }
2401                            "messaging.test_send" => {
2402                                handle_messaging_test_send(&parsed, &session, &state).await
2403                            }
2404                            "state.get" => handle_state_get(&parsed, &session).await,
2405                            "state.set" => handle_state_set(&parsed, &session).await,
2406                            "state.exists" => handle_state_exists(&parsed, &session).await,
2407                            "state.keys" => handle_state_keys(&parsed, &session).await,
2408                            "state.snapshot" => handle_state_snapshot(&parsed, &session).await,
2409                            "memory.add_fact" => handle_memory_add_fact(&parsed, &session).await,
2410                            "memory.set_admission_table" => {
2411                                handle_memory_set_admission_table(&parsed, &session).await
2412                            }
2413                            "memory.admission_table" => {
2414                                handle_memory_admission_table(&parsed, &session).await
2415                            }
2416                            "memory.update_status" => {
2417                                handle_memory_update_status(&parsed, &session).await
2418                            }
2419                            "memory.maintain" => handle_memory_maintain(&parsed, &session).await,
2420                            "memory.save_knowledge" => {
2421                                handle_memory_save_knowledge(&parsed, &session).await
2422                            }
2423                            "memory.save_procedural" => {
2424                                handle_memory_save_procedural(&parsed, &session).await
2425                            }
2426                            "memory.delete" => handle_memory_delete(&parsed, &session).await,
2427                            "memory.query" => handle_memory_query(&parsed, &session).await,
2428                            "memory.evaluate" => handle_memory_evaluate(&parsed, &session).await,
2429                            "memory.intervene" => handle_memory_intervene(&parsed, &session).await,
2430                            "memory.build_context" => {
2431                                handle_memory_build_context(&parsed, &session).await
2432                            }
2433                            "memory.build_context_fast" => {
2434                                handle_memory_build_context_fast(&parsed, &session).await
2435                            }
2436                            "memory.consolidate" => handle_memory_consolidate(&session).await,
2437                            "memory.utility_get" => handle_memory_utility_get(&session).await,
2438                            "memory.utility_set" => {
2439                                handle_memory_utility_set(&parsed, &session).await
2440                            }
2441                            "cascade.run" => handle_cascade_run(&parsed, &session, &state).await,
2442                            "evolution.plan" => {
2443                                handle_evolution_plan(&parsed, &session, &state).await
2444                            }
2445                            "evolution.run" => {
2446                                handle_evolution_run(&parsed, &session, &state).await
2447                            }
2448                            "selfheal.status" => handle_selfheal_status(&state).await,
2449                            "selfheal.detections" => {
2450                                handle_selfheal_detections(&parsed, &state).await
2451                            }
2452                            "selfheal.dismiss" => handle_selfheal_dismiss(&parsed, &state).await,
2453                            "selfheal.fix" => handle_selfheal_fix(&parsed, &state).await,
2454                            "selfheal.run" => handle_selfheal_run(&state).await,
2455                            "heal.status" => handle_heal_status(&state).await,
2456                            "heal.run" => handle_heal_run(&state).await,
2457                            "memory.fact_count" => handle_memory_fact_count(&session).await,
2458                            "memory.persist" => handle_memory_persist(&parsed, &session).await,
2459                            "memory.load" => handle_memory_load(&parsed, &session).await,
2460                            "skill.ingest" => handle_skill_ingest(&parsed, &session).await,
2461                            "skill.find" => handle_skill_find(&parsed, &session).await,
2462                            "skill.report" => handle_skill_report(&parsed, &session).await,
2463                            "skill.gate_deployment" => {
2464                                handle_skill_gate_deployment(&parsed, &session).await
2465                            }
2466                            "skill.enforce_deployment" => {
2467                                handle_skill_enforce_deployment(&parsed, &session, &state).await
2468                            }
2469                            "skill.ingest_governed" => {
2470                                handle_skill_ingest_governed(&parsed, &session, &state).await
2471                            }
2472                            "skill.adopt_pack" => {
2473                                handle_skill_adopt_pack(&parsed, &session, &state).await
2474                            }
2475                            "skill.repair" => handle_skill_repair(&parsed, &session).await,
2476                            "skills.ingest_distilled" => {
2477                                handle_skills_ingest_distilled(&parsed, &session).await
2478                            }
2479                            "skills.evolve" => handle_skills_evolve(&parsed, &session).await,
2480                            "skills.domains_needing_evolution" => {
2481                                handle_skills_domains_needing_evolution(&parsed, &session).await
2482                            }
2483                            "skills.ingest_provisional" => {
2484                                handle_skills_ingest_provisional(&parsed, &session).await
2485                            }
2486                            "skills.gate" => handle_skills_gate(&parsed, &session).await,
2487                            "skill.meta" => handle_skill_meta(&parsed, &session).await,
2488                            "skill.export" => handle_skill_export(&parsed, &session).await,
2489                            "skill.import" => handle_skill_import(&parsed, &session).await,
2490                            "multi.swarm" => handle_multi_swarm(&parsed, &session).await,
2491                            "multi.pipeline" => handle_multi_pipeline(&parsed, &session).await,
2492                            "multi.supervisor" => handle_multi_supervisor(&parsed, &session).await,
2493                            "multi.map_reduce" => handle_multi_map_reduce(&parsed, &session).await,
2494                            "multi.vote" => handle_multi_vote(&parsed, &session).await,
2495                            "multi.tournament" => handle_multi_tournament(&parsed, &session).await,
2496                            "multi.subtask" => handle_multi_subtask(&parsed, &session).await,
2497                            "schedule.suggest" => handle_schedule_suggest(&parsed),
2498                            "scheduler.create" => handle_scheduler_create(&parsed),
2499                            "scheduler.run" => handle_scheduler_run(&parsed, &session).await,
2500                            "scheduler.run_loop" => {
2501                                handle_scheduler_run_loop(&parsed, &session).await
2502                            }
2503                            "scheduler.os_render" => handle_scheduler_os_render(&parsed),
2504                            "scheduler.os_install" => handle_scheduler_os_install(&parsed),
2505                            "scheduler.os_uninstall" => handle_scheduler_os_uninstall(&parsed),
2506                            "scheduler.os_list" => handle_scheduler_os_list(&parsed),
2507                            "scheduler.os_reconcile" => handle_scheduler_os_reconcile(&parsed),
2508                            "tasks.schedule" => handle_tasks_schedule(&parsed, &session, &state),
2509                            "tasks.list" => handle_tasks_list(&parsed),
2510                            "tasks.unschedule" => {
2511                                handle_tasks_unschedule(&parsed, &session, &state)
2512                            }
2513                            "diagnostics.secret_store_activity" => {
2514                                handle_secret_store_activity(&session, &state)
2515                            }
2516                            // In-app feedback (PR A wave 2). compose_preview
2517                            // and submit share ONE compose path (PREV-2);
2518                            // submit is the only spool writer (PRIV-2). Every
2519                            // arm is gated on the negotiated `feedback.v1`
2520                            // capability — protocol version alone is not
2521                            // consent to this surface.
2522                            "feedback.compose_preview" => {
2523                                require_feedback_capability(&session)?;
2524                                crate::feedback::handle_compose_preview(&parsed, &state, &session)
2525                                    .await
2526                            }
2527                            "feedback.submit" => {
2528                                require_feedback_capability(&session)?;
2529                                crate::feedback::handle_submit(&parsed, &state, &session).await
2530                            }
2531                            "feedback.status" => {
2532                                require_feedback_capability(&session)?;
2533                                crate::feedback::handle_status(&parsed, &state).await
2534                            }
2535                            "feedback.list" => {
2536                                require_feedback_capability(&session)?;
2537                                crate::feedback::handle_list(&parsed, &state).await
2538                            }
2539                            "infer" => handle_infer(&parsed, state.clone(), session.clone()).await,
2540                            "infer.cancel" => handle_infer_cancel(&parsed, &session).await,
2541                            "infer.deadline" => handle_infer_deadline(&parsed, &session).await,
2542                            "image.generate" => handle_image_generate(&parsed, &state).await,
2543                            "video.generate" => handle_video_generate(&parsed, &state).await,
2544                            "embed" => handle_embed(&parsed, &state).await,
2545                            "classify" => handle_classify(&parsed, &state).await,
2546                            "tokenize" => handle_tokenize(&parsed, &state).await,
2547                            "detokenize" => handle_detokenize(&parsed, &state).await,
2548                            "rerank" => handle_rerank(&parsed, &state).await,
2549                            "transcribe" => handle_transcribe(&parsed, &state).await,
2550                            "synthesize" => handle_synthesize(&parsed, &state).await,
2551                            "search" => handle_search(&parsed, &state).await,
2552                            "web_fetch" => handle_web_fetch(&parsed, &state).await,
2553                            "infer_stream" => handle_infer_stream(&parsed, &session, &state).await,
2554                            "speech.prepare" => handle_speech_prepare(&state).await,
2555                            "models.route" => handle_models_route(&parsed, &state).await,
2556                            "models.stats" => handle_models_stats(&state).await,
2557                            "outcomes.scoreboard" => handle_outcomes_scoreboard(&state).await,
2558                            "outcomes.resolve_pending" => {
2559                                handle_outcomes_resolve_pending(&parsed, &state).await
2560                            }
2561                            "events.count" => handle_events_count(&session).await,
2562                            "events.stats" => handle_events_stats(&session).await,
2563                            "events.truncate" => handle_events_truncate(&parsed, &session).await,
2564                            "events.clear" => handle_events_clear(&session).await,
2565                            "events.query" => handle_events_query(&parsed, &session).await,
2566                            "events.retention" => handle_events_retention(&parsed, &session).await,
2567                            "events.cost_by_agent" => handle_events_cost_by_agent(&session).await,
2568                            "events.chain.enable" => handle_events_chain_enable(&session).await,
2569                            "events.chain.verify" => handle_events_chain_verify(&session).await,
2570                            "metrics.summary" => handle_metrics_summary(&session).await,
2571                            "metrics.alerts" => handle_metrics_alerts(&parsed, &session).await,
2572                            "nlp.identify_language" => handle_nlp(&parsed, NlpOp::IdentifyLanguage),
2573                            "nlp.tokenize" => handle_nlp(&parsed, NlpOp::Tokenize),
2574                            "nlp.extract_entities" => handle_nlp(&parsed, NlpOp::ExtractEntities),
2575                            // Agent run tracing, U1 — run lifecycle bracket.
2576                            // `runs.start` mints a run_id and tags the
2577                            // session's current run BEFORE responding (KTD3);
2578                            // `runs.complete` records the terminal outcome.
2579                            // The per-turn recorder (U2) and the read/
2580                            // subscribe RPCs (U4/U5) build on these.
2581                            "runs.start" => handle_runs_start(&parsed, &session, &state).await,
2582                            "runs.resume" => handle_runs_resume(&parsed, &session, &state).await,
2583                            "runs.complete" => {
2584                                handle_runs_complete(&parsed, &session, &state).await
2585                            }
2586                            "runs.cancel" => handle_runs_cancel(&parsed, &session, &state).await,
2587                            // Client-narrated turn append (feedback-agent
2588                            // A2UI/runs plan, U1). WS-only (no FFI method, like
2589                            // runs.subscribe) — an out-of-pipeline agent whose
2590                            // work happens inside its own subprocess pushes full
2591                            // RunTurns it built itself. Authorized to the run's
2592                            // OWNING agent only (host-token/unbound writers are
2593                            // rejected as forgery); appends through the shared
2594                            // `record_run_turns` (index re-stamp, persist, fanout).
2595                            "runs.record_turns" => {
2596                                handle_runs_record_turns(&parsed, &session, &state).await
2597                            }
2598                            // Live run-trace subscribe/unsubscribe (U4). WS-only
2599                            // (no FFI method) — CarHost consumes the
2600                            // `runs.trace.event` notification. Authorization
2601                            // gated per-run (R16/KTD10).
2602                            "runs.subscribe" => {
2603                                handle_runs_subscribe(&parsed, &session, &state).await
2604                            }
2605                            "runs.unsubscribe" => {
2606                                handle_runs_unsubscribe(&parsed, &session, &state).await
2607                            }
2608                            // Replay reads (U5). WS-only (no FFI method, like
2609                            // `runs.subscribe`) — CarHost lists an agent's runs
2610                            // and fetches a completed run's full trace from the
2611                            // disk store (works across restart / client_id
2612                            // churn). Authorization gated per-agent/run (R16).
2613                            "runs.list" => handle_runs_list(&parsed, &session, &state).await,
2614                            "runs.get_trace" => {
2615                                handle_runs_get_trace(&parsed, &session, &state).await
2616                            }
2617                            "replan.set_config" => {
2618                                handle_replan_set_config(&parsed, &session).await
2619                            }
2620                            "models.list" => handle_models_list(&state),
2621                            "models.register" => handle_models_register(&parsed, &state).await,
2622                            "models.unregister" => handle_models_unregister(&parsed, &state).await,
2623                            "models.list_unified" => {
2624                                handle_models_list_unified(&parsed, &state).await
2625                            }
2626                            "models.catalog_snapshot" => {
2627                                handle_models_catalog_snapshot(&session, &state)
2628                            }
2629                            "models.resource_policy.get" => {
2630                                handle_models_resource_policy_get(&parsed, &state)
2631                            }
2632                            "models.resource_policy.set" => {
2633                                handle_models_resource_policy_set(&parsed, &session, &state)
2634                            }
2635                            "models.preflight" => handle_models_preflight(&parsed, &state),
2636                            "models.storage_roots" => {
2637                                handle_models_storage_roots(&parsed, &session, &state)
2638                            }
2639                            "models.remove" => {
2640                                handle_models_remove(&parsed, &session, &state).await
2641                            }
2642                            "models.adopt" => handle_models_adopt(&parsed, &session, &state).await,
2643                            "models.route_provenance" => {
2644                                handle_models_route_provenance(&parsed, &state).await
2645                            }
2646                            "models.search" => handle_models_search(&parsed, &state).await,
2647                            "models.recommend" => handle_models_recommend(&parsed, &state),
2648                            "models.setup_plan" => handle_models_setup_plan(&parsed, &state),
2649                            "models.upgrades" => handle_models_upgrades(&state),
2650                            "models.detect_upgrades" => handle_models_detect_upgrades(&state).await,
2651                            "models.check_upgrade_nudge" => {
2652                                handle_models_check_upgrade_nudge(&parsed, &state).await
2653                            }
2654                            "models.dismiss_upgrade" => {
2655                                handle_models_dismiss_upgrade(&parsed, &state)
2656                            }
2657                            "models.check_concierge" => {
2658                                handle_models_check_concierge(&parsed, &state).await
2659                            }
2660                            "models.dismiss_suggestion" => {
2661                                handle_models_dismiss_suggestion(&parsed, &state)
2662                            }
2663                            // Concierge (Phase C1) — ambient status + labeled
2664                            // dismissal. WS-only; CarHost's Model Health pane +
2665                            // friction card consume these.
2666                            "concierge.status" => handle_concierge_status(&parsed, &state).await,
2667                            "concierge.dismiss" => handle_concierge_dismiss(&parsed, &state),
2668                            "concierge.defaults" => handle_concierge_defaults(&state),
2669                            "concierge.set_default" => {
2670                                handle_concierge_set_default(&parsed, &state).await
2671                            }
2672                            "concierge.clear_default" => {
2673                                handle_concierge_clear_default(&parsed, &state).await
2674                            }
2675                            "concierge.apply" => handle_concierge_apply(&parsed, &state).await,
2676                            "concierge.rollback" => {
2677                                handle_concierge_rollback(&parsed, &state).await
2678                            }
2679                            "concierge.actions" => handle_concierge_actions(&parsed, &state),
2680                            "concierge.refresh_catalog" => {
2681                                handle_concierge_refresh_catalog(&state).await
2682                            }
2683                            "concierge.ask" => handle_concierge_ask(&parsed, &state).await,
2684                            "models.update_prefs_get" => handle_models_update_prefs_get(&state),
2685                            "models.update_prefs_set" => {
2686                                handle_models_update_prefs_set(&parsed, &state)
2687                            }
2688                            "models.pull" | "models.install" => {
2689                                handle_models_pull(&parsed, &session, &state).await
2690                            }
2691                            "skills.distill" => handle_skills_distill(&parsed, &state).await,
2692                            "skills.list" => handle_skills_list(&parsed, &session).await,
2693                            "browser.run" => handle_browser_run(&parsed, &session).await,
2694                            "browser.close" => handle_browser_close(&session).await,
2695                            // The browser DRAWER surface (`browser.view.*`).
2696                            // Separate from `browser.run` above, which is the
2697                            // per-connection scripted browser and is untouched:
2698                            // this one watches and drives the ASSISTANT's
2699                            // browser (or the shared standing session) for a
2700                            // human at the Command Deck. Host-management client
2701                            // only — see `crate::browser_view`'s module docs for
2702                            // why that is stricter than `runs.subscribe`.
2703                            "browser.view.subscribe" => {
2704                                crate::browser_view::handle_subscribe(&parsed, &session, &state)
2705                                    .await
2706                            }
2707                            "browser.view.unsubscribe" => {
2708                                crate::browser_view::handle_unsubscribe(&parsed, &session, &state)
2709                                    .await
2710                            }
2711                            "browser.view.take_control" => {
2712                                crate::browser_view::handle_take_control(&parsed, &session, &state)
2713                                    .await
2714                            }
2715                            "browser.view.hand_back" => {
2716                                crate::browser_view::handle_hand_back(&parsed, &session, &state)
2717                                    .await
2718                            }
2719                            "browser.view.navigate" => {
2720                                crate::browser_view::handle_input(
2721                                    crate::browser_view::InputOp::Navigate,
2722                                    &parsed,
2723                                    &session,
2724                                    &state,
2725                                )
2726                                .await
2727                            }
2728                            "browser.view.click" => {
2729                                crate::browser_view::handle_input(
2730                                    crate::browser_view::InputOp::Click,
2731                                    &parsed,
2732                                    &session,
2733                                    &state,
2734                                )
2735                                .await
2736                            }
2737                            "browser.view.type" => {
2738                                crate::browser_view::handle_input(
2739                                    crate::browser_view::InputOp::Type,
2740                                    &parsed,
2741                                    &session,
2742                                    &state,
2743                                )
2744                                .await
2745                            }
2746                            "browser.view.keypress" => {
2747                                crate::browser_view::handle_input(
2748                                    crate::browser_view::InputOp::Keypress,
2749                                    &parsed,
2750                                    &session,
2751                                    &state,
2752                                )
2753                                .await
2754                            }
2755                            "browser.view.scroll" => {
2756                                crate::browser_view::handle_input(
2757                                    crate::browser_view::InputOp::Scroll,
2758                                    &parsed,
2759                                    &session,
2760                                    &state,
2761                                )
2762                                .await
2763                            }
2764                            // Paste carries the TEXT: the clipboard belongs
2765                            // to the host's OS, and CDP's injected key events
2766                            // cannot reach one, so a synthesised Cmd+V would
2767                            // deliver a key event and nothing would arrive.
2768                            "browser.view.paste" => {
2769                                crate::browser_view::handle_input(
2770                                    crate::browser_view::InputOp::Paste,
2771                                    &parsed,
2772                                    &session,
2773                                    &state,
2774                                )
2775                                .await
2776                            }
2777                            // The nav bar's history buttons. No params of
2778                            // their own — which page they act on is the active
2779                            // tab's own history.
2780                            "browser.view.back" => {
2781                                crate::browser_view::handle_input(
2782                                    crate::browser_view::InputOp::Back,
2783                                    &parsed,
2784                                    &session,
2785                                    &state,
2786                                )
2787                                .await
2788                            }
2789                            "browser.view.forward" => {
2790                                crate::browser_view::handle_input(
2791                                    crate::browser_view::InputOp::Forward,
2792                                    &parsed,
2793                                    &session,
2794                                    &state,
2795                                )
2796                                .await
2797                            }
2798                            "browser.view.reload" => {
2799                                crate::browser_view::handle_input(
2800                                    crate::browser_view::InputOp::Reload,
2801                                    &parsed,
2802                                    &session,
2803                                    &state,
2804                                )
2805                                .await
2806                            }
2807                            "browser.view.tab_open" => {
2808                                crate::browser_view::handle_input(
2809                                    crate::browser_view::InputOp::TabOpen,
2810                                    &parsed,
2811                                    &session,
2812                                    &state,
2813                                )
2814                                .await
2815                            }
2816                            "browser.view.tab_close" => {
2817                                crate::browser_view::handle_input(
2818                                    crate::browser_view::InputOp::TabClose,
2819                                    &parsed,
2820                                    &session,
2821                                    &state,
2822                                )
2823                                .await
2824                            }
2825                            "browser.view.tab_switch" => {
2826                                crate::browser_view::handle_input(
2827                                    crate::browser_view::InputOp::TabSwitch,
2828                                    &parsed,
2829                                    &session,
2830                                    &state,
2831                                )
2832                                .await
2833                            }
2834                            // The AGENT side of the drawer (`browser.producer.*`):
2835                            // a supervised agent process publishing ITS OWN
2836                            // browser so `browser.view.*` above can serve it.
2837                            // Agent sessions only — see `crate::browser_relay`.
2838                            "browser.producer.register" => {
2839                                crate::browser_relay::handle_producer_register(
2840                                    &parsed, &session, &state,
2841                                )
2842                                .await
2843                            }
2844                            "secret.put" => handle_secret_put(&parsed).await,
2845                            "secret.get" => handle_secret_get(&parsed, &session, &state).await,
2846                            "secret.delete" => handle_secret_delete(&parsed).await,
2847                            "secret.status" => handle_secret_status(&parsed),
2848                            "secret.available" => Ok(car_ffi_common::secrets::is_available()),
2849                            "secret.list" => car_ffi_common::secrets::list(),
2850                            "permissions.status" => handle_perm_status(&parsed),
2851                            "permissions.request" => handle_perm_request(&parsed),
2852                            "permissions.explain" => handle_perm_explain(&parsed),
2853                            "permissions.domains" => Ok(car_ffi_common::permissions::domains()),
2854                            "accounts.list" => car_ffi_common::accounts::list(),
2855                            "accounts.open" => {
2856                                #[derive(serde::Deserialize, Default)]
2857                                struct OpenParams {
2858                                    #[serde(default)]
2859                                    account_id: Option<String>,
2860                                }
2861                                let p: OpenParams = serde_json::from_value(parsed.params.clone())
2862                                    .unwrap_or_default();
2863                                car_ffi_common::accounts::open_settings(p.account_id.as_deref())
2864                            }
2865                            "calendar.list" => car_ffi_common::integrations::calendar_list(),
2866                            "calendar.events" => handle_calendar_events(&parsed),
2867                            "calendar.create_event" => handle_calendar_create_event(&parsed),
2868                            "calendar.update_event" => handle_calendar_update_event(&parsed),
2869                            "calendar.delete_event" => handle_calendar_delete_event(&parsed),
2870                            "contacts.containers" => {
2871                                car_ffi_common::integrations::contacts_containers()
2872                            }
2873                            "contacts.find" => handle_contacts_find(&parsed),
2874                            "mail.accounts" => car_ffi_common::integrations::mail_accounts(),
2875                            "mail.inbox" => handle_mail_inbox(&parsed),
2876                            "mail.mailboxes" => handle_mail_mailboxes(&parsed),
2877                            "mail.messages" => handle_mail_messages(&parsed),
2878                            "mail.message_body" => handle_mail_message_body(&parsed),
2879                            "mail.send" => handle_mail_send(&parsed),
2880                            "messages.services" => {
2881                                car_ffi_common::integrations::messages_services()
2882                            }
2883                            "messages.chats" => handle_messages_chats(&parsed),
2884                            "messages.read" => handle_messages_read(&parsed),
2885                            "messages.send" => handle_messages_send(&parsed),
2886                            "notes.accounts" => car_ffi_common::integrations::notes_accounts(),
2887                            "notes.find" => handle_notes_find(&parsed),
2888                            "reminders.lists" => car_ffi_common::integrations::reminders_lists(),
2889                            "reminders.items" => handle_reminders_items(&parsed),
2890                            "photos.albums" => car_ffi_common::integrations::photos_albums(),
2891                            "bookmarks.list" => handle_bookmarks_list(&parsed),
2892                            "files.locations" => car_ffi_common::integrations::files_locations(),
2893                            "keychain.status" => car_ffi_common::integrations::keychain_status(),
2894                            "health.status" => car_ffi_common::health::status(),
2895                            "health.sleep" => handle_health_sleep(&parsed),
2896                            "health.workouts" => handle_health_workouts(&parsed),
2897                            "health.activity" => handle_health_activity(&parsed),
2898                            "voice.transcribe_stream.start" => {
2899                                handle_voice_transcribe_stream_start(&parsed, &state, &session)
2900                                    .await
2901                            }
2902                            "voice.transcribe_stream.stop" => {
2903                                handle_voice_transcribe_stream_stop(&parsed, &state).await
2904                            }
2905                            "voice.transcribe_stream.push" => {
2906                                handle_voice_transcribe_stream_push(&parsed, &state).await
2907                            }
2908                            "voice.tts_stream.start" => {
2909                                handle_voice_tts_stream_start(&parsed, &session).await
2910                            }
2911                            "voice.tts_stream.cancel" => {
2912                                handle_voice_tts_stream_cancel(&parsed).await
2913                            }
2914                            "voice.tts_stream.list" => Ok(handle_voice_tts_stream_list()),
2915                            "voice.sessions.list" => Ok(handle_voice_sessions_list(&state)),
2916                            "voice.dispatch_turn" => {
2917                                handle_voice_dispatch_turn(&parsed, &state, &session).await
2918                            }
2919                            "voice.cancel_turn" => handle_voice_cancel_turn().await,
2920                            "voice.prewarm_turn" => handle_voice_prewarm_turn(&state).await,
2921                            "inference.register_runner" => {
2922                                handle_inference_register_runner(&session).await
2923                            }
2924                            "inference.runner.event" => {
2925                                handle_inference_runner_event(&parsed).await
2926                            }
2927                            "inference.runner.complete" => {
2928                                handle_inference_runner_complete(&parsed).await
2929                            }
2930                            "inference.runner.fail" => handle_inference_runner_fail(&parsed).await,
2931                            "voice.providers.list" => {
2932                                // Stateless: enumerates STT/TTS providers compiled into
2933                                // this build. Runtime readiness (API key, permission,
2934                                // model download) is reported via per-provider errors.
2935                                serde_json::from_str::<serde_json::Value>(
2936                                    &car_voice::list_voice_providers_json(),
2937                                )
2938                                .map_err(|e| e.to_string())
2939                            }
2940                            "voice.prepare_parakeet" => car_ffi_common::voice::prepare_parakeet()
2941                                .await
2942                                .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2943                            "voice.prepare_diarizer" => car_ffi_common::voice::prepare_diarizer()
2944                                .await
2945                                .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2946                            "voice.enroll_speaker" => handle_enroll_speaker(&parsed).await,
2947                            "voice.list_enrollments" => car_ffi_common::voice::list_enrollments()
2948                                .and_then(|j| serde_json::from_str(&j).map_err(|e| e.to_string())),
2949                            "voice.remove_enrollment" => handle_remove_enrollment(&parsed),
2950                            "workflow.run" => handle_workflow_run(&parsed, &session).await,
2951                            "workflow.chain" => handle_workflow_chain(&parsed, &session).await,
2952                            "workflow.resume" => handle_workflow_resume(&parsed, &session).await,
2953                            "workflow.list_paused" => handle_workflow_list_paused().await,
2954                            "builder.build" => {
2955                                handle_builder_build(&parsed, &state, &session).await
2956                            }
2957                            "workflow.verify" => handle_workflow_verify(&parsed),
2958                            "workflow.build_automation" => {
2959                                handle_workflow_build_automation(&parsed)
2960                            }
2961                            "meeting.start" => {
2962                                handle_meeting_start(&parsed, &state, &session).await
2963                            }
2964                            "meeting.stop" => handle_meeting_stop(&parsed, &state, &session).await,
2965                            "meeting.list" => handle_meeting_list(&parsed),
2966                            "meeting.get" => handle_meeting_get(&parsed),
2967                            "registry.register" => handle_registry_register(&parsed),
2968                            "registry.heartbeat" => handle_registry_heartbeat(&parsed),
2969                            "registry.unregister" => handle_registry_unregister(&parsed),
2970                            "registry.list" => handle_registry_list(&parsed),
2971                            "registry.reap" => handle_registry_reap(&parsed),
2972                            "admission.status" => handle_admission_status(&state),
2973                            "a2a.start" => handle_a2a_start(&parsed, &state, &session).await,
2974                            "a2a.stop" => handle_a2a_stop(),
2975                            "a2a.status" => handle_a2a_status(),
2976                            "a2a.send" => handle_a2a_send(&parsed, &state).await,
2977                            "a2a.peers.add" => handle_a2a_peers_add(&parsed),
2978                            "a2a.peers.list" => handle_a2a_peers_list(),
2979                            "a2a.peers.remove" => handle_a2a_peers_remove(&parsed),
2980                            "a2ui.apply" => handle_a2ui_apply(&parsed, &state).await,
2981                            "a2ui.ingest" => handle_a2ui_ingest(&parsed, &state).await,
2982                            "a2ui.capabilities" => handle_a2ui_capabilities(&state),
2983                            "a2ui.reap" => handle_a2ui_reap(&state).await,
2984                            "a2ui.surfaces" => handle_a2ui_surfaces(&state).await,
2985                            "a2ui.get" => handle_a2ui_get(&parsed, &state).await,
2986                            "a2ui.action" => handle_a2ui_action(&parsed, &state).await,
2987                            "a2ui.render_report" => {
2988                                handle_a2ui_render_report(&parsed, &state).await
2989                            }
2990                            "a2ui/subscribe" => handle_a2ui_subscribe(&session, &state).await,
2991                            "a2ui/unsubscribe" => handle_a2ui_unsubscribe(&session, &state).await,
2992                            "a2ui/replay" => handle_a2ui_replay(&parsed, &state).await,
2993                            "automation.run_applescript" => handle_run_applescript(&parsed).await,
2994                            "automation.run_powershell" => handle_run_powershell(&parsed).await,
2995                            "automation.shortcuts.list" => handle_list_shortcuts(&parsed).await,
2996                            "automation.shortcuts.run" => handle_run_shortcut(&parsed).await,
2997                            "notifications.local" => handle_local_notification(&parsed).await,
2998                            "vision.ocr" => handle_vision_ocr(&parsed).await,
2999                            "coder.start" => {
3000                                crate::coder::rpc::handle_coder_start(&parsed, &state, &session)
3001                                    .await
3002                            }
3003                            "coder.projects.list" => {
3004                                crate::coder::rpc::handle_coder_projects_list(&state).await
3005                            }
3006                            "coder.projects.create" => {
3007                                crate::coder::rpc::handle_coder_projects_create(&parsed, &state)
3008                                    .await
3009                            }
3010                            "coder.projects.get" => {
3011                                crate::coder::rpc::handle_coder_projects_get(&parsed, &state).await
3012                            }
3013                            "coder.confirm_contract" => {
3014                                crate::coder::rpc::handle_coder_confirm_contract(&parsed, &state)
3015                                    .await
3016                            }
3017                            "coder.list" => crate::coder::rpc::handle_coder_list(&state).await,
3018                            // Multiplayer development: Build → Improve → Polish by
3019                            // different developers, each an ordinary coder session.
3020                            // docs/proposals/multiplayer-development.md
3021                            "multiplayer.publish" => {
3022                                crate::coder::multiplayer::handle_publish(&parsed, &session).await
3023                            }
3024                            "multiplayer.start_stage" => {
3025                                crate::coder::multiplayer::handle_start_stage(
3026                                    &parsed, &state, &session,
3027                                )
3028                                .await
3029                            }
3030                            "multiplayer.submit_stage" => {
3031                                crate::coder::multiplayer::handle_submit_stage(&parsed, &session)
3032                                    .await
3033                            }
3034                            "multiplayer.list" => {
3035                                crate::coder::multiplayer::handle_list(&parsed, &session).await
3036                            }
3037                            "multiplayer.get" => {
3038                                crate::coder::multiplayer::handle_get(&parsed, &session).await
3039                            }
3040                            "multiplayer.merge_check" => {
3041                                crate::coder::multiplayer::handle_merge_check(&parsed, &session)
3042                                    .await
3043                            }
3044                            "coder.get" => {
3045                                crate::coder::rpc::handle_coder_get(&parsed, &state).await
3046                            }
3047                            "coder.subscribe" => {
3048                                crate::coder::rpc::handle_coder_subscribe(&parsed, &state, &session)
3049                                    .await
3050                            }
3051                            "coder.unsubscribe" => {
3052                                crate::coder::rpc::handle_coder_unsubscribe(
3053                                    &parsed, &state, &session,
3054                                )
3055                                .await
3056                            }
3057                            "coder.respond" => {
3058                                crate::coder::rpc::handle_coder_respond(&parsed, &state).await
3059                            }
3060                            "coder.watch" => {
3061                                crate::coder::rpc::handle_coder_watch(&parsed, &state, &session)
3062                                    .await
3063                            }
3064                            "coder.unwatch" => {
3065                                crate::coder::rpc::handle_coder_unwatch(&state, &session).await
3066                            }
3067                            "coder.revise_contract" => {
3068                                crate::coder::rpc::handle_coder_revise_contract(&parsed, &state)
3069                                    .await
3070                            }
3071                            "coder.approve_merge" => {
3072                                crate::coder::rpc::handle_coder_approve_merge(&parsed, &state).await
3073                            }
3074                            "coder.cancel" => {
3075                                crate::coder::rpc::handle_coder_cancel(&parsed, &state).await
3076                            }
3077                            "coder.discuss.start" => {
3078                                crate::coder::discuss::handle_discuss_start(
3079                                    &parsed, &state, &session,
3080                                )
3081                                .await
3082                            }
3083                            "coder.discuss.send" => {
3084                                crate::coder::discuss::handle_discuss_send(
3085                                    &parsed, &state, &session,
3086                                )
3087                                .await
3088                            }
3089                            "coder.discuss.subscribe" => {
3090                                crate::coder::discuss::handle_discuss_subscribe(
3091                                    &parsed, &state, &session,
3092                                )
3093                                .await
3094                            }
3095                            "coder.discuss.unsubscribe" => {
3096                                crate::coder::discuss::handle_discuss_unsubscribe(
3097                                    &parsed, &state, &session,
3098                                )
3099                                .await
3100                            }
3101                            "coder.discuss.promote" => {
3102                                crate::coder::discuss::handle_discuss_promote(
3103                                    &parsed, &state, &session,
3104                                )
3105                                .await
3106                            }
3107                            "coder.discuss.close" => {
3108                                crate::coder::discuss::handle_discuss_close(
3109                                    &parsed, &state, &session,
3110                                )
3111                                .await
3112                            }
3113                            "coder.discuss.list" => {
3114                                crate::coder::discuss::handle_discuss_list(&state, &session).await
3115                            }
3116                            "declagents.list" => {
3117                                crate::coder::rpc::handle_declagents_list(&state).await
3118                            }
3119                            "declagents.get" => {
3120                                crate::coder::rpc::handle_declagents_get(&parsed, &state).await
3121                            }
3122                            "declagents.remove" => {
3123                                require_host_lifecycle_authority(&session, &state).await?;
3124                                crate::coder::rpc::handle_declagents_remove(
3125                                    &parsed, &state, &session,
3126                                )
3127                                .await
3128                            }
3129                            "declagents.set_enabled" => {
3130                                require_host_lifecycle_authority(&session, &state).await?;
3131                                crate::coder::rpc::handle_declagents_set_enabled(
3132                                    &parsed, &state, &session,
3133                                )
3134                                .await
3135                            }
3136                            "declagents.invoke" => {
3137                                crate::coder::rpc::handle_declagents_invoke(
3138                                    &parsed, &state, &session,
3139                                )
3140                                .await
3141                            }
3142                            "declagents.route" => {
3143                                crate::coder::rpc::handle_declagents_route(
3144                                    &parsed, &state, &session,
3145                                )
3146                                .await
3147                            }
3148                            "declagents.route_split" => {
3149                                crate::coder::rpc::handle_declagents_route_split(
3150                                    &parsed, &state, &session,
3151                                )
3152                                .await
3153                            }
3154                            "declagents.routing_stats" => {
3155                                crate::coder::rpc::handle_declagents_routing_stats(&state).await
3156                            }
3157                            "discovery.resolve" => {
3158                                crate::coder::rpc::handle_discovery_resolve(&parsed, &state).await
3159                            }
3160                            "discovery.route_compose" => {
3161                                crate::coder::rpc::handle_discovery_route_compose(&parsed, &state)
3162                                    .await
3163                            }
3164                            "discovery.report" => {
3165                                crate::coder::rpc::handle_discovery_report(&parsed, &state).await
3166                            }
3167                            "agents.list" => handle_agents_list(&state).await,
3168                            "agents.health" => handle_agents_health(&state).await,
3169                            "agents.upsert" => {
3170                                handle_agents_upsert(&parsed, &state, &session).await
3171                            }
3172                            "agents.install" => {
3173                                handle_agents_install(&parsed, &state, &session).await
3174                            }
3175                            "agents.remove" => {
3176                                handle_agents_remove(&parsed, &state, &session).await
3177                            }
3178                            "agents.start" => handle_agents_start(&parsed, &state, &session).await,
3179                            "agents.stop" => handle_agents_stop(&parsed, &state, &session).await,
3180                            "agents.restart" => {
3181                                handle_agents_restart(&parsed, &state, &session).await
3182                            }
3183                            "agents.wait" => handle_agents_wait(&parsed, &state, bound_agent).await,
3184                            "agents.tail_log" => {
3185                                handle_agents_tail_log(&parsed, &state, bound_agent).await
3186                            }
3187                            "agents.list_external" => handle_agents_list_external(&parsed).await,
3188                            "agents.detect_external" => {
3189                                handle_agents_detect_external(&parsed).await
3190                            }
3191                            "agents.health_external" => {
3192                                handle_agents_health_external(&parsed).await
3193                            }
3194                            "assistant.identity.get" => handle_assistant_identity_get(&state),
3195                            "assistant.identity.set" => {
3196                                handle_assistant_identity_set(&parsed, &session, &state).await
3197                            }
3198                            "assistants.invoke" => {
3199                                handle_assistants_invoke(&parsed, &state, &session).await
3200                            }
3201                            "agents.invoke_external" => {
3202                                handle_agents_invoke_external(&parsed, &state, &session).await
3203                            }
3204                            "agents.chat" => handle_agents_chat(&parsed, &state, &session).await,
3205                            "agents.peers" => {
3206                                crate::peers::handle_agents_peers(&state, &session).await
3207                            }
3208                            "agents.message" => {
3209                                crate::peers::handle_agents_message(&parsed, &state, &session).await
3210                            }
3211                            "agents.message.pending" => {
3212                                crate::peers::handle_agents_message_pending(&state, &session).await
3213                            }
3214                            "agents.message.approve" => {
3215                                crate::peers::handle_agents_message_approve(
3216                                    &parsed, &state, &session,
3217                                )
3218                                .await
3219                            }
3220                            "agents.chat.cancel" => {
3221                                handle_agents_chat_cancel(
3222                                    &parsed,
3223                                    &state,
3224                                    &session.client_id,
3225                                    session.is_host.load(std::sync::atomic::Ordering::Acquire),
3226                                )
3227                                .await
3228                            }
3229                            "agents.chat.approve" => {
3230                                handle_agents_chat_approve(
3231                                    &parsed,
3232                                    &state,
3233                                    &session.client_id,
3234                                    session.is_host.load(std::sync::atomic::Ordering::Acquire),
3235                                )
3236                                .await
3237                            }
3238                            "goal.suggest" => handle_goal_suggest(&parsed, &state).await,
3239                            "goal.set" => handle_goal_set(&parsed, &state).await,
3240                            "goal.status" => handle_goal_status(&parsed, &state).await,
3241                            "goal.clear" => handle_goal_clear(&parsed, &state).await,
3242                            // Foreman: decompose a coding goal into a footprint-
3243                            // annotated, scheduled subtask plan (B6). Execution
3244                            // (foreman.run) farms the plan to external CLIs and gates
3245                            // the union — added once the run surface lands.
3246                            "foreman.plan" => handle_foreman_plan(&parsed, &state).await,
3247                            "foreman.run" => handle_foreman_run(&parsed, &state, &session).await,
3248                            // fleet.* — what every reachable CAR instance can do,
3249                            // and whether this one takes farmed-out work.
3250                            "fleet.inventory" => {
3251                                crate::fleet::handle_fleet_inventory(&state, &session).await
3252                            }
3253                            "fleet.composite" => {
3254                                crate::fleet::handle_fleet_composite(&parsed, &state, &session)
3255                                    .await
3256                            }
3257                            "fleet.worker.get" => crate::fleet::handle_fleet_worker_get().await,
3258                            "fleet.worker.set" => {
3259                                crate::fleet::handle_fleet_worker_set(&parsed, &session).await
3260                            }
3261                            // Remote MCP connectors (CAR as MCP client). WS-only
3262                            // (no FFI surface in Phase 1) — connector lifecycle is
3263                            // daemon-shared and interactive. See
3264                            // docs/proposals/remote-mcp-connectors.md.
3265                            "connectors.add" => handle_connectors_add(&parsed, &state).await,
3266                            "connectors.add_stdio" => {
3267                                handle_connectors_add_stdio(&parsed, &state).await
3268                            }
3269                            "connectors.authenticate" => {
3270                                handle_connectors_authenticate(&parsed, &state).await
3271                            }
3272                            "connectors.complete_authentication" => {
3273                                handle_connectors_complete_authentication(&parsed, &state).await
3274                            }
3275                            "connectors.list" => handle_connectors_list(&state).await,
3276                            "connectors.tools" => handle_connectors_tools(&parsed, &state).await,
3277                            "connectors.enable_tools" => {
3278                                handle_connectors_enable_tools(&parsed, &state).await
3279                            }
3280                            "connectors.disable_tools" => {
3281                                handle_connectors_disable_tools(&parsed, &state).await
3282                            }
3283                            "connectors.refresh" => {
3284                                handle_connectors_refresh(&parsed, &state).await
3285                            }
3286                            "connectors.remove" => handle_connectors_remove(&parsed, &state).await,
3287                            // A2A v1.0 (PascalCase) + v0.3 (slash form) — both
3288                            // alias to the same in-core dispatcher per
3289                            // Parslee-ai/car-releases#28. Embedders that need a
3290                            // custom AgentCardSource / TaskStore plug them in
3291                            // via ServerStateConfig::with_a2a_card_source /
3292                            // with_a2a_store before any handler runs.
3293                            "message/send"
3294                            | "SendMessage"
3295                            | "message/stream"
3296                            | "SendStreamingMessage"
3297                            | "tasks/get"
3298                            | "GetTask"
3299                            | "tasks/list"
3300                            | "ListTasks"
3301                            | "tasks/cancel"
3302                            | "CancelTask"
3303                            | "tasks/resubscribe"
3304                            | "SubscribeToTask"
3305                            | "tasks/pushNotificationConfig/set"
3306                            | "CreateTaskPushNotificationConfig"
3307                            | "tasks/pushNotificationConfig/get"
3308                            | "GetTaskPushNotificationConfig"
3309                            | "tasks/pushNotificationConfig/list"
3310                            | "ListTaskPushNotificationConfigs"
3311                            | "tasks/pushNotificationConfig/delete"
3312                            | "DeleteTaskPushNotificationConfig"
3313                            | "agent/getAuthenticatedExtendedCard"
3314                            | "GetExtendedAgentCard" => {
3315                                handle_a2a_dispatch(method_owned.as_str(), &parsed, &state).await
3316                            }
3317                            _ => Err(format!("unknown method: {}", method_owned)),
3318                        }
3319                    };
3320
3321                    // Per-request deadline so a wedged handler can't hang the
3322                    // client forever. None = exempt (unbounded streams/subs/loops).
3323                    // On timeout the handler future is dropped. That DOES now
3324                    // fire the tools.cancel host-abort and release the `pending`
3325                    // entry for any in-flight tool callback: the cleanup hangs
3326                    // off a `Drop` guard (`PendingToolCall` in session.rs), so
3327                    // it survives the future being dropped rather than needing
3328                    // code to run after the await (car#264).
3329                    // `parsed.params` rides along because `proposal.submit`'s
3330                    // deadline is derived from the submitted proposal's own
3331                    // action budgets, not from the method name alone (#265).
3332                    let result = dispatch_with_server_deadline(
3333                        method_owned.as_str(),
3334                        &parsed.params,
3335                        dispatch,
3336                    )
3337                    .await;
3338
3339                    if method_owned == "session.auth"
3340                        && result.is_ok()
3341                        && credential_event_is_eligible(
3342                            &session,
3343                            credential_transport_auth_required,
3344                            credential_host_role_required,
3345                        )
3346                    {
3347                        let _ = credential_activation_tx_task.send(true);
3348                        let mut ready = credential_ready_rx_task;
3349                        while !*ready.borrow_and_update() {
3350                            if ready.changed().await.is_err() {
3351                                break;
3352                            }
3353                        }
3354                    }
3355
3356                    let resp = json_rpc_response_for_handler_result(parsed.id, result);
3357                    let _ = send_response(&session.channel, resp).await;
3358                });
3359            }
3360        } else if msg.is_binary() {
3361            // CAR binary frame transport — see `car_ffi_common::voice::binary`
3362            // for the canonical header definition. 26-byte fixed header
3363            // followed by an opaque payload. Inbound type 0x01 carries
3364            // 16-bit signed LE PCM into a `pcm_push` session; other
3365            // types (0x02 TTS chunk, 0x03 final marker, 0x04 error)
3366            // are server-emitted and rejected here.
3367            let bytes = msg.into_data();
3368            let parsed = match car_ffi_common::voice::binary::parse_frame(&bytes) {
3369                Ok(p) => p,
3370                Err(e) => {
3371                    tracing::warn!("binary frame from {} rejected: {}", client_id, e);
3372                    continue;
3373                }
3374            };
3375            match parsed.frame_type {
3376                car_ffi_common::voice::binary::FRAME_TYPE_INBOUND_PCM => {
3377                    let registry = state.voice_sessions.clone();
3378                    let payload_owned = parsed.payload.to_vec();
3379                    let session_id_owned = parsed.session_id_hex.clone();
3380                    conn_tasks.spawn(async move {
3381                        if let Err(e) = car_ffi_common::voice::transcribe_stream_push(
3382                            &session_id_owned,
3383                            &payload_owned,
3384                            registry,
3385                        )
3386                        .await
3387                        {
3388                            tracing::warn!(
3389                                "binary PCM push to session {} failed: {}",
3390                                session_id_owned,
3391                                e
3392                            );
3393                        }
3394                    });
3395                }
3396                other => {
3397                    tracing::debug!(
3398                        "binary frame type {:#04x} from {} not accepted server-side",
3399                        other,
3400                        client_id
3401                    );
3402                }
3403            }
3404        } else if msg.is_close() {
3405            info!("Client {} disconnected", client_id);
3406            break;
3407        }
3408    }
3409
3410    // car#209: abort every in-flight handler for this connection
3411    // *first* — they each hold an `Arc<ClientSession>` → `Arc<WsChannel>`
3412    // clone; until they're gone the split sink (and the inbound socket
3413    // FD) can't drop, even after the registries below are cleared.
3414    conn_tasks.abort_all();
3415    // Inference backend wrappers outlive their response waiter after a
3416    // controlled terminal. Abort every retained active/orphan task when the
3417    // owning socket disconnects so no provider or delegated runner detaches.
3418    session.inference_control.abort_all();
3419
3420    session.host.unsubscribe(&client_id).await;
3421    // Drop this connection's supervisor registration too. Intents it left
3422    // parked are deliberately NOT released here — they run out their timeout
3423    // and fail closed, so a supervisor cannot convert a pending deny into an
3424    // allow by dropping the socket.
3425    state.supervision.unsubscribe(&client_id).await;
3426    // Auto-cancel this session's pending approvals so the queue stays
3427    // in sync with what's actually decidable — covers graceful
3428    // unregister+close, hard crash (TCP reset), and ping timeout in
3429    // one place. System-level gate approvals (client_id None) are not
3430    // touched. car-releases#48.
3431    session.host.reap_session_approvals(&client_id).await;
3432    state.a2ui_subscribers.lock().await.remove(&client_id);
3433
3434    // Fix for MULTI-4 / WS-3: drop the session from the registry and
3435    // drain any pending tool callbacks. Without this, every connection
3436    // we ever accepted keeps an `Arc<ClientSession>` alive in
3437    // `state.sessions`, and outstanding `oneshot::Sender`s in
3438    // `session.channel.pending` outlive the closed connection until
3439    // their per-call timeout (the action budget, or `DEFAULT_TOOL_TIMEOUT_MS`
3440    // — no longer a hardcoded 60s, see car#259). Dropping the senders here causes any
3441    // awaiting `recv()` in `WsToolExecutor::execute` to return
3442    // `RecvError` immediately, which the existing error-handler path
3443    // already maps to "callback channel closed" — same shape as the
3444    // timeout path, just faster.
3445    let _removed = state.remove_session(&client_id).await;
3446    {
3447        let mut pending = session.channel.pending.lock().await;
3448        pending.clear();
3449    }
3450
3451    Ok(())
3452}
3453
3454async fn send_response(
3455    channel: &WsChannel,
3456    resp: JsonRpcResponse,
3457) -> Result<(), Box<dyn std::error::Error>> {
3458    use futures::SinkExt;
3459    let json = serde_json::to_string(&resp)?;
3460    channel
3461        .write
3462        .lock()
3463        .await
3464        .send(Message::Text(json.into()))
3465        .await?;
3466    Ok(())
3467}
3468
3469// --- Request handlers ---
3470
3471async fn handle_host_subscribe(
3472    session: &crate::session::ClientSession,
3473    state: &Arc<ServerState>,
3474) -> Result<Value, String> {
3475    session
3476        .host
3477        .subscribe(&session.client_id, session.channel.clone())
3478        .await;
3479    // Capture the ordering boundary BEFORE reading browser state. If a live
3480    // sign-in event races this snapshot, it receives a larger sequence and a
3481    // client that already applied it can reject this older response.
3482    let event_sequence = session.host.event_sequence();
3483    let snapshot = serde_json::to_value(HostSnapshot {
3484        subscribed: true,
3485        agents: session.host.agents().await,
3486        devices: session.host.devices().await,
3487        approvals: session.host.approvals().await,
3488        events: session.host.events(50).await,
3489        pending_signins: state.browser_views.pending_signins().await,
3490        event_sequence,
3491        identity: Some(daemon_identity(state)),
3492    })
3493    .map_err(|e| e.to_string())?;
3494    Ok(snapshot)
3495}
3496
3497/// `supervision.subscribe` — register this connection as a supervisor.
3498///
3499/// `{ filter?: { tools?, sessions?, min_reversibility? } }` →
3500/// `{ subscribed, decision_timeout_ms, supervisors }`.
3501///
3502/// Re-subscribing replaces the filter, so a supervisor narrows or widens its
3503/// view without a disconnect. The connection's own channel is the delivery
3504/// path, exactly as `host.subscribe` works.
3505async fn handle_supervision_subscribe(
3506    request: &JsonRpcMessage,
3507    session: &crate::session::ClientSession,
3508    state: &Arc<ServerState>,
3509) -> Result<Value, String> {
3510    let filter: crate::supervision::SupervisionFilter = match request.params.get("filter") {
3511        Some(Value::Null) | None => Default::default(),
3512        Some(v) => serde_json::from_value(v.clone())
3513            .map_err(|e| format!("invalid supervision filter: {e}"))?,
3514    };
3515    state
3516        .supervision
3517        .subscribe(&session.client_id, filter, session.channel.clone())
3518        .await;
3519    Ok(serde_json::json!({
3520        "subscribed": true,
3521        "decision_timeout_ms": state.supervision.timeout().as_millis() as u64,
3522        "supervisors": state.supervision.subscriber_count().await,
3523    }))
3524}
3525
3526/// `supervision.unsubscribe` — stop supervising. `{}` → `{ subscribed: false,
3527/// was_subscribed }`.
3528async fn handle_supervision_unsubscribe(
3529    session: &crate::session::ClientSession,
3530    state: &Arc<ServerState>,
3531) -> Result<Value, String> {
3532    let was = state.supervision.unsubscribe(&session.client_id).await;
3533    Ok(serde_json::json!({ "subscribed": false, "was_subscribed": was }))
3534}
3535
3536/// `supervision.pending` — every intent currently parked on a verdict.
3537/// `{}` → `{ intents: [...] }`.
3538///
3539/// This is the batching half of the design: one model call can cover every
3540/// parked intent, which is what makes supervision affordable at all
3541/// (Shepherd Appendix E — batched and trimmed, not a cheap meta-model).
3542async fn handle_supervision_pending(state: &Arc<ServerState>) -> Result<Value, String> {
3543    Ok(serde_json::json!({ "intents": state.supervision.pending().await }))
3544}
3545
3546/// `supervision.decide` — answer one intent.
3547/// `{ intent_id, decision: { kind: "allow" | "deny" | "escalate", reason? } }`
3548/// → `{ decided: true }`.
3549///
3550/// Errors when the intent is unknown — already decided, already timed out, or
3551/// never existed. That is deliberate: a supervisor that believes it denied
3552/// something needs to hear when the denial did not land.
3553async fn handle_supervision_decide(
3554    request: &JsonRpcMessage,
3555    state: &Arc<ServerState>,
3556) -> Result<Value, String> {
3557    let intent_id = request
3558        .params
3559        .get("intent_id")
3560        .and_then(|v| v.as_str())
3561        .ok_or("supervision.decide requires 'intent_id'")?;
3562    let decision_value = request
3563        .params
3564        .get("decision")
3565        .ok_or("supervision.decide requires 'decision'")?;
3566    let decision: crate::supervision::SupervisionDecision =
3567        serde_json::from_value(decision_value.clone())
3568            .map_err(|e| format!("invalid supervision decision: {e}"))?;
3569    state.supervision.decide(intent_id, decision).await?;
3570    Ok(serde_json::json!({ "decided": true }))
3571}
3572
3573/// Snapshot the daemon-identity facts for a fresh subscriber.
3574/// Cheap: non-acquiring reads on `OnceLock`s + a single
3575/// `to_string_lossy` on the manifest path. Critically uses
3576/// [`ServerState::supervisor_if_installed`] — not the lazy-init
3577/// `supervisor()` — so a Heisenberg subscribe can't *cause* the
3578/// daemon to acquire the manifest lock just by asking whether it
3579/// owns one.
3580fn daemon_identity(state: &Arc<ServerState>) -> car_proto::HostIdentity {
3581    // Observer takes precedence: when both supervisor and observer
3582    // markers are set (currently unreachable through the standalone
3583    // binary, but an embedder could install both racily), the
3584    // observer marker is the authoritative role since the
3585    // supervisor handle is only installed when this daemon owns
3586    // the lock.
3587    let (manifest_path, manifest_role) = if let Some(p) = state.observer_manifest_path() {
3588        (
3589            Some(p.to_string_lossy().into_owned()),
3590            car_proto::HostManifestRole::Observer,
3591        )
3592    } else if let Some(s) = state.supervisor_if_installed() {
3593        (
3594            Some(s.manifest_path().to_string_lossy().into_owned()),
3595            car_proto::HostManifestRole::Owner,
3596        )
3597    } else {
3598        (None, car_proto::HostManifestRole::None)
3599    };
3600    car_proto::HostIdentity {
3601        version: env!("CARGO_PKG_VERSION").to_string(),
3602        pid: std::process::id(),
3603        manifest_path,
3604        manifest_role,
3605        parslee: state
3606            .parslee_session
3607            .get()
3608            .map(|session| session.identity.clone()),
3609    }
3610}
3611
3612/// Return the Parslee cloud credential for an authenticated CAR
3613/// connection. Managed agents already receive `CAR_AUTH_TOKEN` and
3614/// authenticate to the local daemon with `session.auth`; this method is
3615/// their supported bridge from local CAR auth to the user's Parslee
3616/// backend auth.
3617///
3618/// The bearer token is intentionally not injected into every managed
3619/// child process environment. Agents ask for it only when they need it,
3620/// through the same local auth gate that protects the rest of the
3621/// daemon.
3622async fn handle_parslee_auth(state: &ServerState) -> Result<Value, String> {
3623    let (session, api_base) = crate::parslee_auth::load_or_refresh_with_authority()
3624        .await?
3625        .ok_or_else(|| "Parslee account not authenticated; run `car auth login`".to_string())?;
3626    activate_parslee_session(state, session.clone(), api_base);
3627    Ok(serde_json::json!({
3628        "authenticated": true,
3629        "token_type": "Bearer",
3630        "access_token": session.access_token,
3631        "authorization_header": format!("Bearer {}", session.access_token),
3632        "identity": session.identity,
3633    }))
3634}
3635
3636// --- auth.* : GUI-driven Parslee sign-in (CAR Host.app).
3637// Shares one implementation with `car auth login` via the car-auth
3638// crate. The trusted in-process GUI holds the PKCE verifier + state;
3639// the daemon serializes process-global operations and persists only
3640// an attempt-bound completion proof beside the credentials so a lost
3641// reply can be reconciled without replaying the one-time code.
3642// --- auth.* : host-gated Parslee login management (car#661) ---
3643//
3644// Same trust root as `openrouter.*` (car#650) and `messaging.*`. These methods
3645// own the daemon's Parslee identity: `logout` clears the active login's tokens,
3646// `switch_org`/`switch_account` silently repoint which identity subsequent
3647// inference runs and bills against, and `remove_account` drops a stored login.
3648// They took only the request params, so they could not check the caller's role
3649// and did not — any authenticated local connection (a registered supervised
3650// agent, or any process that read the auth token from `GET /auth-token`) could
3651// sign the user out of every Parslee-routed model with one call.
3652//
3653// The reads are gated too: `status`/`accounts` enumerate which identities exist
3654// and which is active; `snapshot`/`completion_status` expose authentication and
3655// attempt state.
3656//
3657// Gating does NOT cost the CLI anything, which is the thing worth checking
3658// before copying this pattern: `car auth login`/`logout`/`orgs`/`switch-org`/
3659// `accounts` never touch this surface — they call `car_auth::` in-process
3660// (main.rs: `exchange_code`/`store_tokens`/`clear_tokens`). The only WS consumer
3661// is CarHost.app's `ParsleeAccount`, which drives `HostEventsClient` and already
3662// presents `session.auth { host_token }`.
3663//
3664// DEGRADED-MODE CAVEAT (inherited, not specific to this surface):
3665// `require_approval_authority` is a no-op when no host token is configured
3666// (pure-dev / `--no-auth`), where the connection is the authority. Identical to
3667// `permission.*`, `messaging.*`, and `openrouter.*`.
3668
3669async fn dispatch_daemon_owned_auth(
3670    method: &str,
3671    req: &JsonRpcMessage,
3672    state: &ServerState,
3673) -> Result<Value, HandlerFailure> {
3674    match method {
3675        "auth.authority_hint" => serde_json::to_value(car_auth::credential_authority_hint())
3676            .map_err(|error| HandlerFailure::Dispatch(error.to_string())),
3677        "auth.start" => handle_auth_start(req)
3678            .await
3679            .map_err(|error| classified_auth_failure(method, error)),
3680        "auth.complete" => accept_auth_completion(req, state)
3681            .await
3682            .map_err(|error| classified_auth_failure(method, error)),
3683        "auth.completion_status" => handle_auth_completion_status(req, state)
3684            .await
3685            .map_err(|error| classified_auth_failure(method, error)),
3686        "auth.snapshot" => handle_auth_snapshot()
3687            .await
3688            .map_err(HandlerFailure::from_dispatch),
3689        "auth.status" => handle_auth_status(req, state)
3690            .await
3691            .map_err(HandlerFailure::from_dispatch),
3692        "auth.switch_org" => handle_auth_switch_org(req)
3693            .await
3694            .map_err(HandlerFailure::from_dispatch),
3695        "auth.accounts" => handle_auth_accounts(req)
3696            .await
3697            .map_err(HandlerFailure::from_dispatch),
3698        "auth.switch_account" => handle_auth_switch_account(req)
3699            .await
3700            .map_err(HandlerFailure::from_dispatch),
3701        "auth.remove_account" => handle_auth_remove_account(req)
3702            .await
3703            .map_err(HandlerFailure::from_dispatch),
3704        "auth.logout" => handle_auth_logout()
3705            .await
3706            .map_err(HandlerFailure::from_dispatch),
3707        _ => Err(HandlerFailure::Dispatch(format!(
3708            "unknown daemon-owned auth method: {method}"
3709        ))),
3710    }
3711}
3712
3713async fn handle_auth_start(req: &JsonRpcMessage) -> Result<Value, car_auth::AuthOperationError> {
3714    // Choosing the browser endpoint must never inspect existing credentials.
3715    // The durable attempt reservation below is a separate state mutation.
3716    let environment_api_base = std::env::var(car_auth::PARSLEE_API_BASE_KEY).ok();
3717    let api_base = select_auth_start_api_base(&req.params, environment_api_base.as_deref());
3718    let client_id = str_or(&req.params, "client_id", "parslee-car");
3719    let redirect_uri =
3720        require_str(&req.params, "redirect_uri").map_err(car_auth::AuthOperationError::Terminal)?;
3721    let provider = opt_str(&req.params, "provider");
3722    // `prompt=select_account` (sent by the add-account path) forces the account
3723    // chooser so a second Parslee login can be added alongside the current one.
3724    let prompt = opt_str(&req.params, "prompt");
3725    let state = car_auth::new_state();
3726    let attempt_id = uuid::Uuid::new_v4().simple().to_string();
3727    let verifier = car_auth::pkce_verifier();
3728    let challenge = car_auth::pkce_challenge(&verifier);
3729    let url = car_auth::authorize_url(
3730        &api_base,
3731        client_id,
3732        redirect_uri,
3733        &state,
3734        &challenge,
3735        provider,
3736        prompt,
3737    )
3738    .map_err(car_auth::AuthOperationError::Terminal)?;
3739    // The reservation is the durable ordering point. A later auth.start,
3740    // logout, or identity switch fences this attempt before auth.complete can
3741    // touch the one-time authorization code.
3742    let lease = car_auth::reserve_login_attempt_classified(&attempt_id).await?;
3743    Ok(serde_json::json!({
3744        "authorize_url": url,
3745        "state": state,
3746        "verifier": verifier,
3747        "attempt_id": attempt_id,
3748        "expires_at_unix_ms": lease.attempt_expires_at_unix_ms,
3749    }))
3750}
3751
3752fn select_auth_start_api_base(params: &Value, environment_api_base: Option<&str>) -> String {
3753    opt_str(params, "api_base")
3754        .map(str::trim)
3755        .filter(|value| !value.is_empty())
3756        .map(str::to_string)
3757        .or_else(|| {
3758            environment_api_base
3759                .map(str::trim)
3760                .filter(|value| !value.is_empty())
3761                .map(str::to_string)
3762        })
3763        .unwrap_or_else(|| car_auth::DEFAULT_API_BASE.to_string())
3764        .trim_end_matches('/')
3765        .to_string()
3766}
3767
3768fn auth_completion_value(record: &car_auth::AuthCompletionRecord) -> Value {
3769    let session = record
3770        .session
3771        .as_deref()
3772        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
3773    serde_json::json!({
3774        "state": "complete",
3775        "attempt_id": record.attempt_id,
3776        "generation": record.generation,
3777        "account_id": record.account_id,
3778        "session": session,
3779    })
3780}
3781
3782#[derive(Clone)]
3783struct AuthCompletionRequest {
3784    api_base: String,
3785    client_id: String,
3786    redirect_uri: String,
3787    code: String,
3788    verifier: String,
3789    attempt_id: String,
3790}
3791
3792fn parse_auth_completion_request(req: &JsonRpcMessage) -> Result<AuthCompletionRequest, String> {
3793    Ok(AuthCompletionRequest {
3794        api_base: car_auth::api_base(opt_str(&req.params, "api_base")),
3795        client_id: str_or(&req.params, "client_id", "parslee-car").to_string(),
3796        redirect_uri: require_str(&req.params, "redirect_uri")?.to_string(),
3797        code: require_str(&req.params, "code")?.to_string(),
3798        verifier: require_str(&req.params, "verifier")?.to_string(),
3799        attempt_id: require_str(&req.params, "attempt_id")?.to_string(),
3800    })
3801}
3802
3803async fn accept_auth_completion(
3804    req: &JsonRpcMessage,
3805    state: &ServerState,
3806) -> Result<Value, car_auth::AuthOperationError> {
3807    let request =
3808        parse_auth_completion_request(req).map_err(car_auth::AuthOperationError::Terminal)?;
3809    let attempt_id = request.attempt_id.clone();
3810    let task_attempt_id = attempt_id.clone();
3811    let lease =
3812        car_auth::claim_login_attempt_classified(&attempt_id, &state.auth_completion_owner_id)
3813            .await?;
3814    state
3815        .spawn_durable_task(format!("auth.complete:{attempt_id}"), async move {
3816            if let Err(error) = complete_auth_completion(request, lease).await {
3817                tracing::warn!(attempt_id = %task_attempt_id, error = %error, "Parslee login completion failed");
3818            }
3819        })
3820        .await;
3821    Ok(serde_json::json!({
3822        "state": "accepted",
3823        "attempt_id": attempt_id,
3824    }))
3825}
3826
3827async fn complete_auth_completion(
3828    request: AuthCompletionRequest,
3829    lease: car_auth::LoginAttemptLease,
3830) -> Result<Value, String> {
3831    // The direct RPC response is produced only after this lease was durably
3832    // claimed and this daemon-owned redemption task was spawned. A truthful
3833    // `accepted` receipt therefore always has a redeeming proof to reconcile.
3834    let worker_lease = lease.clone();
3835    let result = match tokio::spawn(complete_claimed_auth_completion(request, worker_lease)).await {
3836        Ok(result) => result,
3837        Err(error) => Err(format!(
3838            "Parslee login worker stopped unexpectedly: {error}"
3839        )),
3840    };
3841    if result.is_err() {
3842        match car_auth::fail_login_attempt(
3843            &lease,
3844            car_auth::AuthAttemptFailure::completion_failed(),
3845        )
3846        .await
3847        {
3848            Ok(true) => {}
3849            Ok(false) => {
3850                tracing::debug!(
3851                    attempt_id = %lease.attempt_id,
3852                    "stale auth worker exit could not replace a newer attempt"
3853                );
3854            }
3855            Err(error) => {
3856                tracing::warn!(
3857                    attempt_id = %lease.attempt_id,
3858                    error = %error,
3859                    "failed to persist terminal auth worker lifecycle"
3860                );
3861            }
3862        }
3863    }
3864    result
3865}
3866
3867async fn complete_claimed_auth_completion(
3868    request: AuthCompletionRequest,
3869    lease: car_auth::LoginAttemptLease,
3870) -> Result<Value, String> {
3871    let (token, completion_session) =
3872        auth_completion_network_with_deadline(car_auth::AUTH_COMPLETION_NETWORK_DEADLINE, async {
3873            let token = car_auth::exchange_code(
3874                &request.api_base,
3875                &request.client_id,
3876                &request.redirect_uri,
3877                &request.code,
3878                &request.verifier,
3879            )
3880            .await?;
3881            let session = fetch_completion_session(&request.api_base, &token.access_token).await?;
3882            Ok((token, session))
3883        })
3884        .await?;
3885
3886    // Identity is known and all network work is finished. This lease-checked
3887    // local publication deliberately sits outside the cancellable network cap.
3888    let record =
3889        car_auth::commit_login(&request.api_base, &token, &completion_session, Some(lease)).await?;
3890
3891    Ok(auth_completion_value(&record))
3892}
3893
3894async fn auth_completion_network_with_deadline<T, F>(
3895    deadline: std::time::Duration,
3896    operation: F,
3897) -> Result<T, String>
3898where
3899    F: std::future::Future<Output = Result<T, String>>,
3900{
3901    tokio::time::timeout(deadline, operation)
3902        .await
3903        .map_err(|_| {
3904            format!(
3905                "Parslee login network phase timed out after {}s; no credentials were saved",
3906                deadline.as_secs()
3907            )
3908        })?
3909}
3910
3911/// A successful token exchange consumes the authorization code, but the session
3912/// read is a safe bearer GET and occurs before any credential persistence. Give
3913/// one transient session failure a bounded retry without ever re-exchanging the
3914/// code or publishing an unattributed token set.
3915async fn fetch_completion_session(api_base: &str, access_token: &str) -> Result<String, String> {
3916    match car_auth::fetch_status_with_access(api_base, access_token).await {
3917        Ok(session) => Ok(session),
3918        Err(first_error) => {
3919            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
3920            car_auth::fetch_status_with_access(api_base, access_token)
3921                .await
3922                .map_err(|second_error| {
3923                    format!(
3924                        "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}"
3925                    )
3926                })
3927        }
3928    }
3929}
3930
3931/// Read the result of one exact browser attempt from the already-published V2
3932/// authority. It never refreshes, calls the network, or migrates legacy slots.
3933/// A later identity mutation advances the durable generation and supersedes the
3934/// proof.
3935async fn handle_auth_completion_status(
3936    req: &JsonRpcMessage,
3937    state: &ServerState,
3938) -> Result<Value, car_auth::AuthOperationError> {
3939    let attempt_id =
3940        require_str(&req.params, "attempt_id").map_err(car_auth::AuthOperationError::Terminal)?;
3941    let mut value = serde_json::to_value(
3942        car_auth::auth_completion_status_classified(attempt_id, &state.auth_completion_owner_id)
3943            .await?,
3944    )
3945    .map_err(|error| {
3946        car_auth::AuthOperationError::Terminal(format!("serialize auth completion status: {error}"))
3947    })?;
3948    if let Some(raw_session) = value.get("session").and_then(Value::as_str) {
3949        let decoded = serde_json::from_str::<Value>(raw_session).unwrap_or(Value::Null);
3950        value["session"] = decoded;
3951    }
3952    Ok(value)
3953}
3954
3955/// Local-only, non-refreshing pre-browser baseline. The first V2 read may
3956/// migrate attributable legacy state; ambiguous legacy state fails closed.
3957async fn handle_auth_snapshot() -> Result<Value, String> {
3958    serde_json::to_value(car_auth::local_auth_snapshot().await?).map_err(|e| e.to_string())
3959}
3960
3961async fn handle_auth_status(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
3962    let mode = if req
3963        .params
3964        .get("retry_keychain_access")
3965        .and_then(Value::as_bool)
3966        == Some(true)
3967    {
3968        car_auth::CredentialReadMode::Retry
3969    } else {
3970        car_auth::CredentialReadMode::Use
3971    };
3972    let Some(mut credential) = car_auth::resolve_credential(mode)
3973        .await
3974        .map_err(|error| error.to_string())?
3975    else {
3976        return Ok(serde_json::json!({ "authenticated": false }));
3977    };
3978
3979    // The endpoint and bearer are one coordinator-resolved authoritative
3980    // bundle. No request parameter can redirect this session check.
3981    let session_json =
3982        match car_auth::fetch_status_with_access(&credential.api_base, &credential.access_token)
3983            .await
3984        {
3985            Ok(session) => session,
3986            Err(error) if error.contains("HTTP 401") => {
3987                let Some(refreshed) = car_auth::refresh_credential()
3988                    .await
3989                    .map_err(|refresh_error| refresh_error.to_string())?
3990                else {
3991                    return Err(error);
3992                };
3993                let session = car_auth::fetch_status_with_access(
3994                    &refreshed.api_base,
3995                    &refreshed.access_token,
3996                )
3997                .await?;
3998                credential = refreshed;
3999                session
4000            }
4001            Err(error) => return Err(error),
4002        };
4003    if let Ok(session) =
4004        crate::parslee_auth::session_from_status(credential.access_token, &session_json)
4005    {
4006        activate_parslee_session(state, session, credential.api_base);
4007    }
4008    let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4009    Ok(serde_json::json!({ "authenticated": true, "session": session }))
4010}
4011
4012fn activate_parslee_session(
4013    state: &ServerState,
4014    session: crate::parslee_auth::ParsleeSession,
4015    api_base: String,
4016) {
4017    let email = session
4018        .identity
4019        .email
4020        .clone()
4021        .unwrap_or_else(|| "<unknown>".to_string());
4022    let org = session
4023        .identity
4024        .active_organization
4025        .clone()
4026        .unwrap_or_else(|| "<none>".to_string());
4027    let access_token = session.access_token.clone();
4028    if state.install_parslee_session(session).is_err() {
4029        return;
4030    }
4031
4032    info!(
4033        email = %email,
4034        active_org = %org,
4035        "Parslee account auth activated for this CAR daemon"
4036    );
4037    let Some(runtime_url) = state.mobile_registration_url.get().cloned() else {
4038        return;
4039    };
4040    let token = state.auth_token.get().cloned();
4041    tokio::spawn(async move {
4042        let registration =
4043            crate::mobile_runtime::MobileRuntimeRegistration::new(runtime_url, token);
4044        match crate::mobile_runtime::register(&api_base, &access_token, &registration).await {
4045            Ok(()) => info!("registered this CAR machine for Parslee mobile discovery"),
4046            Err(error) => tracing::warn!(
4047                error = %error,
4048                "failed to register this CAR machine for Parslee mobile discovery; \
4049                 local Parslee Core runtime remains available"
4050            ),
4051        }
4052    });
4053}
4054
4055async fn handle_auth_logout() -> Result<Value, String> {
4056    car_auth::logout().await?;
4057    Ok(serde_json::json!({ "ok": true }))
4058}
4059
4060// --- openrouter.* : host-gated OAuth connection management (car#650) ---
4061//
4062// The OpenRouter OAuth credential is deliberately daemon-private: the generic
4063// secret surface fail-closes on it (`secret.put`/`delete` reject the reserved
4064// slot), so `openrouter.disconnect` is the ONLY path that can delete it. That
4065// made it the one unguarded door in an otherwise closed design — any
4066// authenticated local connection (a supervised agent, or any process that
4067// fetched the auth token from `GET /auth-token`) could log the user out of
4068// OpenRouter, or cancel the host's in-progress connect.
4069//
4070// All four are gated on the same trust root as every other config-mutation
4071// surface (`require_approval_authority`), including the read: `status` carries
4072// credential-presence and pending-flow metadata for whichever principal owns
4073// the connection, which is not another client's business.
4074//
4075// DEGRADED-MODE CAVEAT (inherited, not specific to this surface):
4076// `require_approval_authority` is a no-op when no host token is configured
4077// (pure-dev / `--no-auth`) — the connection governs itself, the developer being
4078// the authority. Identical to `permission.*` and `messaging.*`; not diverged
4079// from here.
4080
4081// --- assistant.identity.* : the name the flagship agent answers to (car#1013) ---
4082//
4083// One record (`~/.car/identity.json`, via `car_identity`) feeds the system
4084// prompt, the voice wake gate, and every host's addressing copy. Before this,
4085// the same idea was four disagreeing string literals in three languages, and
4086// none of them was settable.
4087//
4088// The READ is deliberately ungated, unlike `openrouter.status` next door. A
4089// name is not a credential — it is what every connected surface has to render
4090// to address the assistant at all, and iPhone/Android hosts reach the daemon
4091// without approval authority. It also ships in the `server.handshake` reply, so
4092// gating the RPC would protect nothing while breaking mobile.
4093//
4094// The WRITE is gated on the same trust root as every other config mutation
4095// (`require_approval_authority`). A rename repoints the voice wake word: an
4096// ungated write would let any authenticated local connection make the
4097// assistant stop answering to the name its user knows.
4098//
4099// DEGRADED-MODE CAVEAT (inherited): `require_approval_authority` is a no-op
4100// when no host token is configured (pure-dev / `--no-auth`). Same as
4101// `permission.*`, `messaging.*`, and `openrouter.*`.
4102
4103/// `assistant.identity.get` → `{ name, spellings, aliases, user_name, role,
4104/// focus_areas, apps, brand }`.
4105///
4106/// A malformed record is an ERROR here, not a silent fall back to the default
4107/// name — this is the surface a user checks when the assistant stopped
4108/// answering to the name they set, and "everything is fine, it's called
4109/// Parslee" is the least useful possible answer.
4110fn handle_assistant_identity_get(state: &ServerState) -> Result<Value, String> {
4111    let identity = state.identity_store.load()?;
4112    Ok(assistant_identity_wire(&identity))
4113}
4114
4115/// `assistant.identity.set` `{ name?, spellings?, user_name?, role?,
4116/// focus_areas?, apps? }` → the new record.
4117///
4118/// Read-modify-write: every field is optional, so a host that only knows about
4119/// the name cannot wipe spellings a voice-settings pane wrote, and vice versa.
4120/// Broadcasts `host.event { kind: "assistant.identity.changed" }` so a live
4121/// rename reaches open sessions without a reconnect.
4122async fn handle_assistant_identity_set(
4123    req: &JsonRpcMessage,
4124    session: &crate::session::ClientSession,
4125    state: &Arc<ServerState>,
4126) -> Result<Value, String> {
4127    require_approval_authority(session, state)?;
4128
4129    let store = &state.identity_store;
4130    let mut identity = store.load()?;
4131
4132    // `set_name` drops spellings that belonged to the OLD name; an explicit
4133    // `spellings` below then sets the new one's. Without that, renaming Jarvis
4134    // to Friday would leave it waking on "jervis".
4135    if let Some(name) = req.params.get("name").and_then(Value::as_str) {
4136        identity.set_name(name)?;
4137    }
4138    if let Some(Value::Array(items)) = req.params.get("spellings") {
4139        identity.spellings = car_identity::validate_spellings(
4140            items
4141                .iter()
4142                .filter_map(|v| v.as_str().map(str::to_string))
4143                .collect(),
4144        )?;
4145    }
4146    match req.params.get("user_name") {
4147        Some(Value::Null) => identity = identity.with_user_name(None)?,
4148        Some(Value::String(user)) => {
4149            identity = identity.with_user_name(Some(user.clone()))?;
4150        }
4151        _ => {}
4152    }
4153    match req.params.get("role") {
4154        Some(Value::Null) => identity = identity.with_role(None),
4155        Some(Value::String(role)) => identity = identity.with_role(Some(role.clone())),
4156        Some(_) => return Err("assistant.identity.set role must be a string or null".into()),
4157        None => {}
4158    }
4159    match req.params.get("focus_areas") {
4160        Some(Value::Null) => identity = identity.with_focus_areas(Vec::new()),
4161        Some(value @ Value::Array(_)) => {
4162            let parsed = serde_json::from_value(value.clone());
4163            let focus_areas: Vec<car_identity::FocusArea> = parsed
4164                .map_err(|error| format!("invalid assistant.identity.set focus_areas: {error}"))?;
4165            identity = identity.with_focus_areas(focus_areas);
4166        }
4167        Some(_) => {
4168            return Err("assistant.identity.set focus_areas must be an array or null".into())
4169        }
4170        None => {}
4171    }
4172    match req.params.get("apps") {
4173        Some(Value::Null) => identity = identity.with_apps(Vec::new()),
4174        Some(value @ Value::Array(_)) => {
4175            let apps: Vec<String> = serde_json::from_value(value.clone())
4176                .map_err(|error| format!("invalid assistant.identity.set apps: {error}"))?;
4177            identity = identity.with_apps(apps);
4178        }
4179        Some(_) => return Err("assistant.identity.set apps must be an array or null".into()),
4180        None => {}
4181    }
4182    identity.touch();
4183    store.save(&identity)?;
4184
4185    // identity.json is authoritative. Identity nodes stay out of generic memory
4186    // snapshots and are rehydrated from this record at daemon/engine load, so
4187    // mirror the just-written value into the live effective engine now.
4188    let effective = session.effective_memgine().await;
4189    {
4190        let mut engine = effective.lock().await;
4191        crate::session::mirror_identity_into_memgine(&mut engine, &identity);
4192    }
4193    // A bound agent may have an agent-specific effective engine while the
4194    // daemon's shared graph still serves ordinary sessions and MCP. Keep both
4195    // current without locking the same Arc twice.
4196    if let Some(shared) = state.shared_memgine.as_ref() {
4197        if !Arc::ptr_eq(shared, &effective) {
4198            let mut engine = shared.lock().await;
4199            crate::session::mirror_identity_into_memgine(&mut engine, &identity);
4200        }
4201    }
4202
4203    let wire = assistant_identity_wire(&identity);
4204    session
4205        .host
4206        .record_event(
4207            "assistant.identity.changed",
4208            None,
4209            format!("the assistant now answers to {}", identity.name),
4210            wire.clone(),
4211        )
4212        .await;
4213    Ok(wire)
4214}
4215
4216/// The wire shape shared by `assistant.identity.*` and `server.handshake`.
4217///
4218/// `aliases` is derived, not stored: hosts match against it locally (their wake
4219/// matchers must work before the daemon answers), and deriving it here is what
4220/// keeps Swift, Kotlin, and Rust from drifting into three different lists —
4221/// which is the exact failure this whole change exists to fix.
4222fn assistant_identity_wire(identity: &car_identity::AssistantIdentity) -> Value {
4223    serde_json::json!({
4224        "name": identity.name,
4225        "spellings": identity.spellings,
4226        "aliases": identity.aliases(),
4227        "user_name": identity.user_name,
4228        "role": identity.role,
4229        "focus_areas": identity.focus_areas,
4230        "apps": identity.apps,
4231        "brand": car_identity::BRAND_NAME,
4232        "updated_at_unix": identity.updated_at_unix,
4233    })
4234}
4235
4236fn handle_openrouter_status(
4237    session: &crate::session::ClientSession,
4238    state: &ServerState,
4239) -> Result<Value, String> {
4240    require_approval_authority(session, state)?;
4241    serde_json::to_value(crate::openrouter_auth::status()).map_err(|e| e.to_string())
4242}
4243
4244async fn handle_openrouter_auth_start(
4245    req: &JsonRpcMessage,
4246    session: &crate::session::ClientSession,
4247    state: &ServerState,
4248) -> Result<Value, String> {
4249    require_approval_authority(session, state)?;
4250    let authorization_base_url = opt_str(&req.params, "authorization_base_url");
4251    let exchange_url = opt_str(&req.params, "exchange_url");
4252    let timeout_seconds = req.params.get("timeout_seconds").and_then(Value::as_u64);
4253    let started =
4254        crate::openrouter_auth::start(authorization_base_url, exchange_url, timeout_seconds)
4255            .await?;
4256    serde_json::to_value(started).map_err(|e| e.to_string())
4257}
4258
4259fn handle_openrouter_auth_cancel(
4260    req: &JsonRpcMessage,
4261    session: &crate::session::ClientSession,
4262    state: &ServerState,
4263) -> Result<Value, String> {
4264    require_approval_authority(session, state)?;
4265    let status = crate::openrouter_auth::cancel(opt_str(&req.params, "flow_id"));
4266    serde_json::to_value(status).map_err(|e| e.to_string())
4267}
4268
4269async fn handle_openrouter_disconnect(
4270    session: &crate::session::ClientSession,
4271    state: &ServerState,
4272) -> Result<Value, String> {
4273    require_approval_authority(session, state)?;
4274    serde_json::to_value(crate::openrouter_auth::disconnect().await?).map_err(|e| e.to_string())
4275}
4276
4277/// List every stored Parslee login (`active` marks the current one). Migrates a
4278/// pre-multi-login session into the registry on first call.
4279async fn handle_auth_accounts(req: &JsonRpcMessage) -> Result<Value, String> {
4280    let api_base = opt_str(&req.params, "api_base");
4281    let accounts = car_auth::list_accounts(api_base).await?;
4282    Ok(serde_json::json!({ "accounts": accounts }))
4283}
4284
4285/// Switch which stored login is active (swaps the keychain token slots), then
4286/// returns the refreshed session for the newly-active login.
4287async fn handle_auth_switch_account(req: &JsonRpcMessage) -> Result<Value, String> {
4288    let account_id = req
4289        .params
4290        .get("account_id")
4291        .and_then(|v| v.as_str())
4292        .ok_or("invalid params: account_id is required")?;
4293    let api_base = opt_str(&req.params, "api_base");
4294    car_auth::switch_account(account_id).await?;
4295    // The swapped-in token may be near expiry; refresh before reporting.
4296    let _ = car_auth::access_token_refreshing().await;
4297    match car_auth::fetch_status(api_base).await? {
4298        Some(session_json) => {
4299            let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4300            Ok(serde_json::json!({ "authenticated": true, "session": session }))
4301        }
4302        None => Ok(serde_json::json!({ "authenticated": false })),
4303    }
4304}
4305
4306/// Remove a stored login. If it was active, another remaining login becomes
4307/// active (or the session is cleared when none remain). Returns the new list.
4308async fn handle_auth_remove_account(req: &JsonRpcMessage) -> Result<Value, String> {
4309    let account_id = req
4310        .params
4311        .get("account_id")
4312        .and_then(|v| v.as_str())
4313        .ok_or("invalid params: account_id is required")?;
4314    let accounts = car_auth::remove_account(account_id).await?;
4315    Ok(serde_json::json!({ "ok": true, "accounts": accounts }))
4316}
4317
4318/// Switch the signed-in account's active organization. Silent — mints a fresh
4319/// token scoped to `organization_id` via the refresh grant (the backend
4320/// validates membership), so inference immediately follows the new org.
4321/// Returns the refreshed session so the caller can render the new active org.
4322async fn handle_auth_switch_org(req: &JsonRpcMessage) -> Result<Value, String> {
4323    let org_id = req
4324        .params
4325        .get("organization_id")
4326        .and_then(|v| v.as_str())
4327        .ok_or("invalid params: organization_id is required")?;
4328    let api_base = opt_str(&req.params, "api_base");
4329    car_auth::switch_org(api_base, org_id).await?;
4330    match car_auth::fetch_status(api_base).await? {
4331        Some(session_json) => {
4332            let session: Value = serde_json::from_str(&session_json).unwrap_or(Value::Null);
4333            Ok(serde_json::json!({ "authenticated": true, "session": session }))
4334        }
4335        None => Ok(serde_json::json!({ "authenticated": false })),
4336    }
4337}
4338
4339async fn handle_host_agents(
4340    session: &crate::session::ClientSession,
4341    state: &Arc<ServerState>,
4342) -> Result<Value, String> {
4343    // Base: agents that explicitly registered into HostState (callback clients
4344    // via `host.register_agent`, multi-agent runners via `WsAgentRunner`).
4345    let mut agents = session.host.agents().await;
4346
4347    // Merge: registry-supervised agents that ADVERTISE capabilities (e.g.
4348    // `car-assistant` with `["chat"]`). Those attach via `session.auth
4349    // { agent_id }`, which never registers into HostState, so their
4350    // `AgentSpec.capabilities` — present in `agents.list` — were invisible to
4351    // `host.agents` (issue #483). We read them from the supervisor at request
4352    // time rather than registering on attach: it's self-correcting (reflects
4353    // live supervisor status, can't leak a stale record across a disconnect the
4354    // attach path doesn't clean up). Scoped to capability-bearing agents so the
4355    // existing `host.agents` contents are unchanged for the common case. An
4356    // explicit HostState registration for the same id wins (skip on dup).
4357    if let Ok(supervisor) = state.supervisor() {
4358        use car_registry::supervisor::AgentStatus;
4359        let have: std::collections::HashSet<String> = agents.iter().map(|a| a.id.clone()).collect();
4360        for m in supervisor.list().await {
4361            if m.spec.capabilities.is_empty() || have.contains(&m.spec.id) {
4362                continue;
4363            }
4364            let status = match m.status {
4365                AgentStatus::Running => car_proto::HostAgentStatus::Running,
4366                AgentStatus::Starting => car_proto::HostAgentStatus::Idle,
4367                AgentStatus::Backoff | AgentStatus::Errored => car_proto::HostAgentStatus::Errored,
4368                AgentStatus::Stopped => car_proto::HostAgentStatus::Stopped,
4369            };
4370            agents.push(car_proto::HostAgent {
4371                id: m.spec.id.clone(),
4372                name: m.spec.name.clone(),
4373                kind: "supervised".to_string(),
4374                capabilities: m.spec.capabilities.clone(),
4375                project: None,
4376                session_id: None,
4377                status,
4378                current_task: None,
4379                pid: m.pid,
4380                display: Default::default(),
4381                updated_at: chrono::Utc::now(),
4382                metadata: Value::Null,
4383            });
4384        }
4385    }
4386
4387    serde_json::to_value(agents).map_err(|e| e.to_string())
4388}
4389
4390async fn handle_host_events(
4391    req: &JsonRpcMessage,
4392    session: &crate::session::ClientSession,
4393) -> Result<Value, String> {
4394    let limit = req
4395        .params
4396        .get("limit")
4397        .and_then(|v| v.as_u64())
4398        .unwrap_or(100) as usize;
4399    serde_json::to_value(session.host.events(limit).await).map_err(|e| e.to_string())
4400}
4401
4402async fn handle_host_approvals(session: &crate::session::ClientSession) -> Result<Value, String> {
4403    serde_json::to_value(session.host.approvals().await).map_err(|e| e.to_string())
4404}
4405
4406async fn handle_a2ui_apply(
4407    req: &JsonRpcMessage,
4408    state: &Arc<ServerState>,
4409) -> Result<Value, String> {
4410    #[derive(Deserialize)]
4411    struct Params {
4412        #[serde(default)]
4413        envelope: Option<car_a2ui::A2uiEnvelope>,
4414        #[serde(default)]
4415        message: Option<car_a2ui::A2uiEnvelope>,
4416    }
4417
4418    let envelope = if req.params.get("createSurface").is_some()
4419        || req.params.get("updateComponents").is_some()
4420        || req.params.get("updateDataModel").is_some()
4421        || req.params.get("deleteSurface").is_some()
4422    {
4423        serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4424            .map_err(|e| e.to_string())?
4425    } else {
4426        match serde_json::from_value::<Params>(req.params.clone()) {
4427            Ok(params) => params
4428                .envelope
4429                .or(params.message)
4430                .ok_or_else(|| "`a2ui.apply` requires an A2UI envelope".to_string())?,
4431            Err(_) => serde_json::from_value::<car_a2ui::A2uiEnvelope>(req.params.clone())
4432                .map_err(|e| e.to_string())?,
4433        }
4434    };
4435
4436    apply_a2ui_envelope(state, envelope, None, None).await
4437}
4438
4439async fn handle_a2ui_ingest(
4440    req: &JsonRpcMessage,
4441    state: &Arc<ServerState>,
4442) -> Result<Value, String> {
4443    #[derive(Deserialize)]
4444    #[serde(rename_all = "camelCase")]
4445    struct Params {
4446        #[serde(default)]
4447        endpoint: Option<String>,
4448        #[serde(default)]
4449        a2a_endpoint: Option<String>,
4450        #[serde(default)]
4451        owner: Option<car_a2ui::A2uiSurfaceOwner>,
4452        #[serde(default)]
4453        route_auth: Option<A2aRouteAuth>,
4454        #[serde(default)]
4455        allow_untrusted_endpoint: bool,
4456    }
4457
4458    let params = serde_json::from_value::<Params>(req.params.clone()).unwrap_or(Params {
4459        endpoint: None,
4460        a2a_endpoint: None,
4461        owner: None,
4462        route_auth: None,
4463        allow_untrusted_endpoint: false,
4464    });
4465    let payload = req.params.get("payload").unwrap_or(&req.params);
4466    state
4467        .a2ui
4468        .validate_payload(payload)
4469        .map_err(|e| e.to_string())?;
4470    let envelopes = car_a2ui::envelopes_from_value(payload).map_err(|e| e.to_string())?;
4471    if envelopes.is_empty() {
4472        return Err("no A2UI envelopes found in payload".into());
4473    }
4474    let endpoint = params.endpoint.or(params.a2a_endpoint);
4475    let endpoint = trusted_route_endpoint(endpoint, params.allow_untrusted_endpoint);
4476    let owner = params
4477        .owner
4478        .or_else(|| car_a2ui::owner_from_value(payload))
4479        .map(|owner| match endpoint.clone() {
4480            Some(endpoint) => owner.with_endpoint(Some(endpoint)),
4481            None => owner,
4482        });
4483
4484    let mut results = Vec::new();
4485    for envelope in envelopes {
4486        let value =
4487            apply_a2ui_envelope(state, envelope, owner.clone(), params.route_auth.clone()).await?;
4488        results.push(value);
4489    }
4490    Ok(serde_json::json!({ "applied": results }))
4491}
4492
4493async fn apply_a2ui_envelope(
4494    state: &Arc<ServerState>,
4495    envelope: car_a2ui::A2uiEnvelope,
4496    owner: Option<car_a2ui::A2uiSurfaceOwner>,
4497    route_auth: Option<A2aRouteAuth>,
4498) -> Result<Value, String> {
4499    let result = state
4500        .a2ui
4501        .apply_with_owner(envelope, owner)
4502        .await
4503        .map_err(|e| e.to_string())?;
4504    update_a2ui_route_auth(state, &result, route_auth).await;
4505    let kind = if result.deleted {
4506        "a2ui.surface_deleted"
4507    } else {
4508        "a2ui.surface_updated"
4509    };
4510    let message = if result.deleted {
4511        format!("A2UI surface {} deleted", result.surface_id)
4512    } else {
4513        format!("A2UI surface {} updated", result.surface_id)
4514    };
4515    let payload = serde_json::to_value(&result).map_err(|e| e.to_string())?;
4516    state
4517        .host
4518        .record_event(kind, None, message, payload.clone())
4519        .await;
4520    // Push the envelope result to every WS subscriber as an
4521    // `a2ui.event` notification — Parslee-ai/car-releases#29. Late
4522    // joiners catch up via `a2ui/replay` (or `a2ui.surfaces`).
4523    broadcast_a2ui_event(state, kind, &payload).await;
4524    serde_json::to_value(result).map_err(|e| e.to_string())
4525}
4526
4527async fn broadcast_a2ui_event(state: &Arc<ServerState>, kind: &str, result: &Value) {
4528    use futures::SinkExt;
4529    use tokio_tungstenite::tungstenite::Message;
4530    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
4531        .a2ui_subscribers
4532        .lock()
4533        .await
4534        .values()
4535        .cloned()
4536        .collect();
4537    if subscribers.is_empty() {
4538        return;
4539    }
4540    let Ok(json) = serde_json::to_string(&serde_json::json!({
4541        "jsonrpc": "2.0",
4542        "method": "a2ui.event",
4543        "params": {
4544            "kind": kind,
4545            "result": result,
4546        },
4547    })) else {
4548        return;
4549    };
4550    for channel in subscribers {
4551        let _ = channel
4552            .write
4553            .lock()
4554            .await
4555            .send(Message::Text(json.clone().into()))
4556            .await;
4557    }
4558}
4559
4560async fn update_a2ui_route_auth(
4561    state: &Arc<ServerState>,
4562    result: &car_a2ui::A2uiApplyResult,
4563    route_auth: Option<A2aRouteAuth>,
4564) {
4565    let mut auth = state.a2ui_route_auth.lock().await;
4566    if result.deleted {
4567        auth.remove(&result.surface_id);
4568        return;
4569    }
4570
4571    let has_route_endpoint = result
4572        .surface
4573        .as_ref()
4574        .and_then(|surface| surface.owner.as_ref())
4575        .and_then(|owner| owner.endpoint.as_ref())
4576        .is_some();
4577    match (has_route_endpoint, route_auth) {
4578        (true, Some(route_auth)) => {
4579            auth.insert(result.surface_id.clone(), route_auth);
4580        }
4581        _ => {
4582            auth.remove(&result.surface_id);
4583        }
4584    }
4585}
4586
4587fn handle_a2ui_capabilities(state: &Arc<ServerState>) -> Result<Value, String> {
4588    serde_json::to_value(state.a2ui.capabilities()).map_err(|e| e.to_string())
4589}
4590
4591async fn handle_a2ui_reap(state: &Arc<ServerState>) -> Result<Value, String> {
4592    let removed = state.a2ui.reap_expired(chrono::Utc::now()).await;
4593    if !removed.is_empty() {
4594        let mut auth = state.a2ui_route_auth.lock().await;
4595        for surface_id in &removed {
4596            auth.remove(surface_id);
4597        }
4598    }
4599    Ok(serde_json::json!({ "removed": removed }))
4600}
4601
4602async fn handle_a2ui_surfaces(state: &Arc<ServerState>) -> Result<Value, String> {
4603    serde_json::to_value(state.a2ui.list().await).map_err(|e| e.to_string())
4604}
4605
4606async fn handle_a2ui_get(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
4607    let surface_id = req
4608        .params
4609        .get("surface_id")
4610        .or_else(|| req.params.get("surfaceId"))
4611        .and_then(Value::as_str)
4612        .ok_or_else(|| "`a2ui.get` requires surface_id".to_string())?;
4613    serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4614}
4615
4616/// `a2ui/subscribe` — opt this WS connection into `a2ui.event`
4617/// notifications. Subscribers receive every `apply_a2ui_envelope`
4618/// result for as long as they're connected; the cleanup hook in
4619/// `run_dispatch` removes them on disconnect. Closes
4620/// Parslee-ai/car-releases#29.
4621async fn handle_a2ui_subscribe(
4622    session: &crate::session::ClientSession,
4623    state: &Arc<ServerState>,
4624) -> Result<Value, String> {
4625    state
4626        .a2ui_subscribers
4627        .lock()
4628        .await
4629        .insert(session.client_id.clone(), session.channel.clone());
4630    Ok(serde_json::json!({ "subscribed": true }))
4631}
4632
4633/// `a2ui/unsubscribe` — opt out of `a2ui.event` notifications.
4634/// Idempotent: returns `{ subscribed: false }` regardless of prior
4635/// state.
4636async fn handle_a2ui_unsubscribe(
4637    session: &crate::session::ClientSession,
4638    state: &Arc<ServerState>,
4639) -> Result<Value, String> {
4640    state
4641        .a2ui_subscribers
4642        .lock()
4643        .await
4644        .remove(&session.client_id);
4645    Ok(serde_json::json!({ "subscribed": false }))
4646}
4647
4648/// `a2ui/replay` — fetch the current state of one surface. Intended
4649/// for late joiners and reconnect: a client calls `subscribe`, then
4650/// `replay` once per surface it's tracking, and from then on
4651/// notifications keep it in sync. Equivalent to `a2ui.get` on the
4652/// surface store; lives in the subscribe namespace for
4653/// discoverability.
4654async fn handle_a2ui_replay(
4655    req: &JsonRpcMessage,
4656    state: &Arc<ServerState>,
4657) -> Result<Value, String> {
4658    let surface_id = req
4659        .params
4660        .get("surface_id")
4661        .or_else(|| req.params.get("surfaceId"))
4662        .and_then(Value::as_str)
4663        .ok_or_else(|| "`a2ui/replay` requires surface_id".to_string())?;
4664    serde_json::to_value(state.a2ui.get(surface_id).await).map_err(|e| e.to_string())
4665}
4666
4667async fn handle_a2ui_action(
4668    req: &JsonRpcMessage,
4669    state: &Arc<ServerState>,
4670) -> Result<Value, String> {
4671    let action: car_a2ui::ClientAction =
4672        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4673    let owner = state.a2ui.owner(&action.surface_id).await;
4674
4675    // Deliver the interaction to A2UI subscribers on the same channel that
4676    // carries surface updates — Parslee-ai/car-releases#58. The owning agent
4677    // listens for `a2ui.event` (that's how it gets `surface_updated`); before
4678    // this it saw nothing on click, so its surfaces were display-only.
4679    // Broadcast BEFORE the A2A route below: the local notification must not be
4680    // gated behind a (possibly slow) outbound A2A round-trip, and `route` is
4681    // deliberately excluded so an A2A endpoint URL/response isn't fanned out to
4682    // unrelated subscribers — it stays in the `host.event` record and the RPC
4683    // return for the privileged consumers that already see it.
4684    let action_result = serde_json::json!({
4685        "surfaceId": action.surface_id,
4686        "action": action,
4687        "owner": owner,
4688    });
4689    broadcast_a2ui_event(state, "a2ui.action", &action_result).await;
4690
4691    let route = route_a2ui_action(state, &action, owner.clone()).await;
4692    let payload = serde_json::json!({
4693        "action": action,
4694        "owner": owner,
4695        "route": route,
4696    });
4697    let event = state
4698        .host
4699        .record_event(
4700            "a2ui.action",
4701            None,
4702            format!(
4703                "A2UI action {} from {}",
4704                action.name, action.source_component_id
4705            ),
4706            payload,
4707        )
4708        .await;
4709    Ok(serde_json::json!({
4710        "event": event,
4711        "route": route,
4712    }))
4713}
4714
4715/// `a2ui.render_report` — renderer-emitted telemetry envelope
4716/// (Parslee-ai/car#180). Fire-and-forget; we record an event in
4717/// the host log (so dev tools and the conversation log see it) and
4718/// broadcast as `a2ui.event { kind: "a2ui.render_report", result }`
4719/// to every WS subscriber. The improvement agent reads the report
4720/// and decides whether to issue a follow-up `patchComponents`.
4721async fn handle_a2ui_render_report(
4722    req: &JsonRpcMessage,
4723    state: &Arc<ServerState>,
4724) -> Result<Value, String> {
4725    // Parse into the typed struct to enforce the schema; we
4726    // re-serialize for the event/broadcast payload so downstream
4727    // consumers don't have to defensively re-validate.
4728    let report: car_a2ui::RenderReport =
4729        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4730    let payload = serde_json::to_value(&report).map_err(|e| e.to_string())?;
4731    let kind = "a2ui.render_report";
4732    let message = format!("A2UI render report for surface {}", report.surface_id);
4733    let event = state
4734        .host
4735        .record_event(kind, None, message, payload.clone())
4736        .await;
4737    broadcast_a2ui_event(state, kind, &payload).await;
4738
4739    // Hand the report to the in-process UI-improvement agent. The
4740    // agent is sync but cheap; we await the surface lookup before
4741    // calling it so the strategies see the same surface state the
4742    // renderer saw at report-emit time. Best-effort: surface lookup
4743    // misses (the surface might have been deleted between
4744    // report-emit and report-receive) are logged and skipped, never
4745    // surfaced as JSON-RPC errors — render_report is fire-and-forget.
4746    if let Some(surface) = state.a2ui.get(&report.surface_id).await {
4747        // Iteration budget — runaway-loop backstop. try_consume
4748        // claims the slot atomically; if the surface is already at
4749        // the cap, we short-circuit before consulting the agent.
4750        // The slot stays consumed only if the patch actually
4751        // applies — failure paths refund.
4752        if !state.ui_agent_budget.try_consume(&report.surface_id) {
4753            tracing::warn!(
4754                surface_id = %report.surface_id,
4755                count = state.ui_agent_budget.count(&report.surface_id),
4756                max = state.ui_agent_budget.max(),
4757                "ui-agent iteration budget exhausted; skipping agent invocation"
4758            );
4759            return Ok(serde_json::json!({ "event": event }));
4760        }
4761        // From here on, every non-applied branch must `refund` the
4762        // slot we just claimed. Only the successful-apply branch
4763        // keeps it consumed.
4764        match state.ui_agent.on_render_report(&report, &surface) {
4765            car_ui_agent::Decision::Patch {
4766                envelope,
4767                strategy_id,
4768                patch_hash,
4769                elapsed_ns,
4770            } => {
4771                // Runtime-side convergence monitor — neo's deferred
4772                // ask. The agent's no-double-patch guard catches
4773                // same-sequence repeats; this catches A→B→A across
4774                // sequences by tracking recent patch hashes per
4775                // surface. When a proposal would repeat one in the
4776                // window, drop it; the loop waits for the next
4777                // signature change.
4778                if !state
4779                    .ui_agent_oscillation
4780                    .check_and_record(&report.surface_id, patch_hash)
4781                {
4782                    tracing::warn!(
4783                        surface_id = %report.surface_id,
4784                        strategy = %strategy_id,
4785                        patch_hash,
4786                        "ui-agent oscillation detected; suppressing patch"
4787                    );
4788                    // Suppressed patch never applied → release the
4789                    // budget slot we claimed above.
4790                    state.ui_agent_budget.refund(&report.surface_id);
4791                    return Ok(serde_json::json!({ "event": event }));
4792                }
4793                let a2ui_envelope = car_a2ui::A2uiEnvelope {
4794                    patch_components: Some(envelope),
4795                    ..Default::default()
4796                };
4797                if let Err(e) = apply_a2ui_envelope(state, a2ui_envelope, None, None).await {
4798                    tracing::warn!(
4799                        surface_id = %report.surface_id,
4800                        strategy = %strategy_id,
4801                        patch_hash,
4802                        elapsed_ns,
4803                        error = %e,
4804                        "ui-agent patch apply failed",
4805                    );
4806                    // Apply failed → release the budget slot.
4807                    state.ui_agent_budget.refund(&report.surface_id);
4808                } else {
4809                    tracing::debug!(
4810                        surface_id = %report.surface_id,
4811                        strategy = %strategy_id,
4812                        patch_hash,
4813                        elapsed_ns,
4814                        iteration = state.ui_agent_budget.count(&report.surface_id),
4815                        "ui-agent patch applied",
4816                    );
4817                    // Memgine trace: one Conversation node per
4818                    // successful patch, tagged "ui-agent/<surface>".
4819                    // Spreading activation can surface these on
4820                    // future renders of related surfaces. Spawned
4821                    // off the render-report hot path — ingest walks
4822                    // the graph for spreading activation and can
4823                    // take real time; we don't want two surfaces
4824                    // churning patches to serialize through the
4825                    // memgine mutex behind the WS handler.
4826                    if let Some(memgine) = state.shared_memgine.clone() {
4827                        let speaker = format!("ui-agent/{}", report.surface_id);
4828                        let text = format!("strategy applied: {}", strategy_id);
4829                        tokio::spawn(async move {
4830                            let mut guard = memgine.lock().await;
4831                            guard.ingest_conversation(&speaker, &text, chrono::Utc::now());
4832                        });
4833                    }
4834                }
4835            }
4836            car_ui_agent::Decision::StableNoChange => {
4837                // No patch this round → release the slot.
4838                state.ui_agent_budget.refund(&report.surface_id);
4839            }
4840            car_ui_agent::Decision::HardStop { reason } => {
4841                state.ui_agent_budget.refund(&report.surface_id);
4842                // Renderer painted unknown components — contract
4843                // violation between server and renderer. Per
4844                // types.rs docs: "loud failure" + "MUST pause."
4845                // `error!` not `warn!` so it surfaces in production
4846                // logs at the right severity.
4847                tracing::error!(
4848                    surface_id = %report.surface_id,
4849                    reason = %reason,
4850                    "ui-agent hard-stopped improvement loop",
4851                );
4852            }
4853        }
4854    } else {
4855        tracing::debug!(
4856            surface_id = %report.surface_id,
4857            "ui-agent skipped — surface not found in store",
4858        );
4859    }
4860
4861    Ok(serde_json::json!({ "event": event }))
4862}
4863
4864async fn route_a2ui_action(
4865    state: &Arc<ServerState>,
4866    action: &car_a2ui::ClientAction,
4867    owner: Option<car_a2ui::A2uiSurfaceOwner>,
4868) -> Value {
4869    let Some(owner) = owner else {
4870        return serde_json::json!({ "delivered": false, "reason": "surface has no owner" });
4871    };
4872    if owner.kind != "a2a" {
4873        return serde_json::json!({ "delivered": false, "reason": "unsupported owner kind", "owner": owner });
4874    }
4875    let Some(endpoint) = owner.endpoint.clone() else {
4876        return serde_json::json!({
4877            "delivered": false,
4878            "reason": "surface owner has no endpoint",
4879            "owner": owner
4880        });
4881    };
4882
4883    let message = car_a2a::Message {
4884        message_id: format!("a2ui-action-{}", uuid::Uuid::new_v4().simple()),
4885        role: car_a2a::MessageRole::User,
4886        parts: vec![car_a2a::Part::Data(car_a2a::types::DataPart {
4887            data: serde_json::json!({
4888                "a2uiAction": action,
4889            }),
4890            metadata: Default::default(),
4891        })],
4892        task_id: owner.task_id.clone(),
4893        context_id: owner.context_id.clone(),
4894        metadata: Default::default(),
4895    };
4896
4897    let auth = state
4898        .a2ui_route_auth
4899        .lock()
4900        .await
4901        .get(&action.surface_id)
4902        .cloned()
4903        .map(client_auth_from_route_auth)
4904        .unwrap_or(car_a2a::ClientAuth::None);
4905
4906    match car_a2a::A2aClient::new(endpoint.clone())
4907        .with_auth(auth)
4908        .send_message(message, false)
4909        .await
4910    {
4911        Ok(result) => serde_json::json!({
4912            "delivered": true,
4913            "owner": owner,
4914            "endpoint": endpoint,
4915            "result": result,
4916        }),
4917        Err(error) => serde_json::json!({
4918            "delivered": false,
4919            "owner": owner,
4920            "endpoint": endpoint,
4921            "error": error.to_string(),
4922        }),
4923    }
4924}
4925
4926fn client_auth_from_route_auth(auth: A2aRouteAuth) -> car_a2a::ClientAuth {
4927    match auth {
4928        A2aRouteAuth::None => car_a2a::ClientAuth::None,
4929        A2aRouteAuth::Bearer { token } => car_a2a::ClientAuth::Bearer(token),
4930        A2aRouteAuth::Header { name, value } => car_a2a::ClientAuth::Header { name, value },
4931    }
4932}
4933
4934fn trusted_route_endpoint(endpoint: Option<String>, allow_untrusted: bool) -> Option<String> {
4935    let endpoint = endpoint?;
4936    if allow_untrusted || is_loopback_http_endpoint(&endpoint) {
4937        Some(endpoint)
4938    } else {
4939        None
4940    }
4941}
4942
4943fn is_loopback_http_endpoint(endpoint: &str) -> bool {
4944    endpoint == "http://localhost"
4945        || endpoint.starts_with("http://localhost:")
4946        || endpoint.starts_with("http://localhost/")
4947        || endpoint == "http://127.0.0.1"
4948        || endpoint.starts_with("http://127.0.0.1:")
4949        || endpoint.starts_with("http://127.0.0.1/")
4950        || endpoint == "http://[::1]"
4951        || endpoint.starts_with("http://[::1]:")
4952        || endpoint.starts_with("http://[::1]/")
4953}
4954
4955async fn handle_host_register_agent(
4956    req: &JsonRpcMessage,
4957    session: &crate::session::ClientSession,
4958) -> Result<Value, String> {
4959    let request: RegisterHostAgentRequest =
4960        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4961    serde_json::to_value(
4962        session
4963            .host
4964            .register_agent(&session.client_id, request)
4965            .await?,
4966    )
4967    .map_err(|e| e.to_string())
4968}
4969
4970async fn handle_host_unregister_agent(
4971    req: &JsonRpcMessage,
4972    session: &crate::session::ClientSession,
4973) -> Result<Value, String> {
4974    let agent_id = req
4975        .params
4976        .get("agent_id")
4977        .and_then(|v| v.as_str())
4978        .ok_or("missing agent_id")?;
4979    session
4980        .host
4981        .unregister_agent(&session.client_id, agent_id)
4982        .await?;
4983    Ok(serde_json::json!({"ok": true}))
4984}
4985
4986async fn handle_host_set_status(
4987    req: &JsonRpcMessage,
4988    session: &crate::session::ClientSession,
4989) -> Result<Value, String> {
4990    let request: SetHostAgentStatusRequest =
4991        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
4992    serde_json::to_value(session.host.set_status(&session.client_id, request).await?)
4993        .map_err(|e| e.to_string())
4994}
4995
4996async fn handle_host_register_device(
4997    req: &JsonRpcMessage,
4998    session: &crate::session::ClientSession,
4999) -> Result<Value, String> {
5000    let request: RegisterHostDeviceRequest =
5001        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5002    serde_json::to_value(
5003        session
5004            .host
5005            .register_device(&session.client_id, request)
5006            .await?,
5007    )
5008    .map_err(|e| e.to_string())
5009}
5010
5011async fn handle_host_update_device(
5012    req: &JsonRpcMessage,
5013    session: &crate::session::ClientSession,
5014) -> Result<Value, String> {
5015    let request: UpdateHostDeviceRequest =
5016        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5017    serde_json::to_value(
5018        session
5019            .host
5020            .update_device(&session.client_id, request)
5021            .await?,
5022    )
5023    .map_err(|e| e.to_string())
5024}
5025
5026async fn handle_host_devices(session: &crate::session::ClientSession) -> Result<Value, String> {
5027    serde_json::to_value(session.host.devices().await).map_err(|e| e.to_string())
5028}
5029
5030async fn handle_mobile_runtime(state: &Arc<ServerState>) -> Result<Value, String> {
5031    let url = state
5032        .mobile_runtime_url
5033        .get()
5034        .cloned()
5035        .ok_or_else(|| "mobile Parslee Core connection is not configured".to_string())?;
5036    let token = state.auth_token.get().cloned();
5037    Ok(serde_json::json!({
5038        "name": "Parslee Core",
5039        "source": "car",
5040        "url": url,
5041        "token": token,
5042    }))
5043}
5044
5045async fn handle_host_notify(
5046    req: &JsonRpcMessage,
5047    session: &crate::session::ClientSession,
5048) -> Result<Value, String> {
5049    let kind = req
5050        .params
5051        .get("kind")
5052        .and_then(|v| v.as_str())
5053        .unwrap_or("host.notification");
5054    let agent_id = req
5055        .params
5056        .get("agent_id")
5057        .and_then(|v| v.as_str())
5058        .map(str::to_string);
5059    let message = req
5060        .params
5061        .get("message")
5062        .and_then(|v| v.as_str())
5063        .unwrap_or("");
5064    let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
5065    serde_json::to_value(
5066        session
5067            .host
5068            .record_event(kind, agent_id, message, payload)
5069            .await,
5070    )
5071    .map_err(|e| e.to_string())
5072}
5073
5074async fn handle_host_request_approval(
5075    req: &JsonRpcMessage,
5076    session: &crate::session::ClientSession,
5077) -> Result<Value, String> {
5078    let mut request: CreateHostApprovalRequest =
5079        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5080    // Stamp the requester from the session's AUTHENTICATED agent binding rather
5081    // than trusting the caller's claim — the same stance
5082    // `handle_permission_decision` takes for `reviewer` ("an audit log whose
5083    // 'who approved' is forgeable by the approver undercuts §5.2.5"). The
5084    // binding is not self-declared: `session.auth` validates `agent_id` against
5085    // the per-agent token the supervisor minted before setting it.
5086    //
5087    // Without this the row's `agent_id` was whatever the requester typed, so it
5088    // was neither an audit record nor usable as the requester half of the
5089    // requester-vs-resolver comparison in `car_server_types::host::Resolver`.
5090    request.agent_id = stamped_requester(
5091        authenticated_bound_agent_id(session).await,
5092        request.agent_id.take(),
5093    );
5094    if let Some(agent_id) = &request.agent_id {
5095        // Best-effort. If the caller doesn't own this agent the
5096        // ACL added 2026-05 will refuse the status update — that
5097        // is the correct semantics; we still want the approval row
5098        // itself to land so the UI can render the request.
5099        let _ = session
5100            .host
5101            .set_status(
5102                &session.client_id,
5103                SetHostAgentStatusRequest {
5104                    agent_id: agent_id.clone(),
5105                    status: HostAgentStatus::WaitingForApproval,
5106                    current_task: None,
5107                    message: Some("Waiting for approval".to_string()),
5108                    payload: Value::Null,
5109                },
5110            )
5111            .await;
5112    }
5113    // `system_level: true` opts the approval out of per-session
5114    // ownership. The host-side ACL then allows any authenticated
5115    // session (typically CarHost or `car-host approve`) to resolve.
5116    // Agents requesting user approval should always set this — the
5117    // session-owned mode is only correct when the requesting session
5118    // is also the resolving session, which approval-via-UI never is.
5119    let owner_client_id = if request.system_level {
5120        None
5121    } else {
5122        Some(session.client_id.as_str())
5123    };
5124    serde_json::to_value(
5125        session
5126            .host
5127            .create_approval(owner_client_id, request)
5128            .await?,
5129    )
5130    .map_err(|e| e.to_string())
5131}
5132
5133async fn handle_host_resolve_approval(
5134    req: &JsonRpcMessage,
5135    session: &crate::session::ClientSession,
5136) -> Result<Value, String> {
5137    let request: ResolveHostApprovalRequest =
5138        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5139    // The agent identity only counts when the session is authenticated AND
5140    // bound to one — an unauthenticated claim must not launder itself into an
5141    // identity the self-resolution check would then trust.
5142    let bound_agent = authenticated_bound_agent_id(session).await;
5143    let resolver =
5144        car_server_types::host::Resolver::agent_session(&session.client_id, bound_agent.as_deref());
5145    serde_json::to_value(session.host.resolve_approval(resolver, request).await?)
5146        .map_err(|e| e.to_string())
5147}
5148
5149/// Method-not-found-class denial for a method absent from a supervised-agent
5150/// token's scope. The stable message prefix is the named authorization error;
5151/// callers can distinguish it from the ordinary `unknown method: ...` response
5152/// while receiving the same non-disclosure-oriented JSON-RPC class.
5153const AGENT_METHOD_NOT_ALLOWED_ERROR_CODE: i32 = -32601;
5154const AGENT_METHOD_NOT_ALLOWED_MESSAGE_PREFIX: &str =
5155    "agent_method_not_allowed: supervised agent token does not allow daemon method ";
5156
5157/// Transport-establishment calls are necessarily implicit: the daemon cannot
5158/// know a token's scope until `session.auth` succeeds, and a client must be able
5159/// to negotiate the protocol before invoking its first scoped application
5160/// method. Every other request/notification is checked at the dispatch boundary.
5161fn agent_scope_implicitly_allows(method: &str) -> bool {
5162    matches!(
5163        method,
5164        "session.auth" | "server.handshake" | "server.schema"
5165    )
5166}
5167
5168fn agent_method_scope_allows(session: &crate::session::ClientSession, method: &str) -> bool {
5169    if agent_scope_implicitly_allows(method)
5170        || session.is_host.load(std::sync::atomic::Ordering::Acquire)
5171    {
5172        return true;
5173    }
5174    match session.agent_method_allowlist.read() {
5175        Ok(scope) => scope
5176            .as_ref()
5177            .is_none_or(|methods| methods.contains(method)),
5178        // Poisoning means a prior scope write panicked. Failing closed is the
5179        // only safe interpretation for a bound credential.
5180        Err(_) => false,
5181    }
5182}
5183
5184/// `session.auth` — present the per-launch token to unlock the
5185/// connection. When `state.auth_token` is unset, this method is a
5186/// no-op success (auth is disabled). When set, the supplied token
5187/// must equal it (constant-time comparison) — a successful auth
5188/// flips `session.authenticated` to `true` so subsequent methods
5189/// pass the gate. Wrong token returns an error AND leaves the
5190/// session unauthenticated; the dispatcher loop's gate then closes
5191/// the connection on the next non-auth method.
5192///
5193/// Closes Parslee-ai/car-releases#32.
5194async fn handle_session_auth(
5195    req: &JsonRpcMessage,
5196    session: &crate::session::ClientSession,
5197    state: &Arc<ServerState>,
5198) -> Result<Value, String> {
5199    // C-4: optional `tenant_id` binds the connection to one tenant
5200    // namespace. Parsed + validated up front, bound only on auth
5201    // success. Once bound, tenant-scoped handlers use this identity
5202    // and reject a conflicting per-request `tenant_id`.
5203    let tenant_binding = match req.params.get("tenant_id").and_then(Value::as_str) {
5204        Some(t) if !t.is_empty() => {
5205            validate_tenant_id(t)?;
5206            Some(t.to_string())
5207        }
5208        _ => None,
5209    };
5210
5211    // car-releases#79: an optional host-declared memory namespace. A SEPARATE
5212    // axis from `agent_id` — one agent may span namespaces, and two hosts may
5213    // share a namespace without sharing an identity — so it binds its own
5214    // engine from its own registry. Absent, the session keeps the daemon's
5215    // shared graph, which is what makes MCP-ingested facts visible over WS.
5216    //
5217    // Read BEFORE the host-token branch: that branch returns early, and a host
5218    // client is exactly the caller that wants per-project memory.
5219    let memory_namespace = req
5220        .params
5221        .get("memory_namespace")
5222        .and_then(Value::as_str)
5223        .filter(|s| !s.trim().is_empty())
5224        .map(str::to_string);
5225
5226    // #254: optional `host_token` elevates the connection to the
5227    // host-management role. It is a *distinct* credential from the
5228    // daemon `token` — only readable from the `0600` host-token file,
5229    // never served over `GET /auth-token` — so a generic authenticated
5230    // client (or another local user who scraped the auth token via the
5231    // HTTP endpoint) cannot self-elevate to host and read other agents'
5232    // run traces. Validated first, before the `token` requirement,
5233    // because presenting a valid host token both authenticates and
5234    // elevates the session (a host client sends only `host_token`).
5235    if let Some(host_supplied) = req.params.get("host_token").and_then(Value::as_str) {
5236        let expected = state.host_token.get().ok_or_else(|| {
5237            "host auth unavailable: this daemon has no host token (started with --no-auth?)"
5238                .to_string()
5239        })?;
5240        if !constant_time_eq(host_supplied.as_bytes(), expected.as_bytes()) {
5241            return Err("auth failed: host token mismatch".to_string());
5242        }
5243        session
5244            .authenticated
5245            .store(true, std::sync::atomic::Ordering::Release);
5246        session
5247            .is_host
5248            .store(true, std::sync::atomic::Ordering::Release);
5249        // A host just appeared. Supervised agent processes cache this answer
5250        // — they have no read of the daemon's session set — and it decides
5251        // whether `browser_await_signin` points at the drawer or tells the
5252        // user to open the CAR app. Push the transition rather than leaving
5253        // them to find out at their next registration.
5254        state.browser_views.broadcast_host_connected(true).await;
5255        *session.tenant.lock().await = tenant_binding;
5256        if let Some(ns) = memory_namespace.as_deref() {
5257            bind_memory_namespace(state, session, ns).await?;
5258        }
5259        return Ok(serde_json::json!({
5260            "ok": true,
5261            "auth_enabled": true,
5262            "role": "host",
5263            "memory_namespace": memory_namespace,
5264        }));
5265    }
5266
5267    let supplied = req
5268        .params
5269        .get("token")
5270        .and_then(Value::as_str)
5271        .ok_or_else(|| "session.auth requires { token: string }".to_string())?;
5272    // #169: optional `agent_id` binds the WS connection to a
5273    // supervised lifecycle agent. When present, the supplied token
5274    // must equal the per-agent token the supervisor minted at upsert
5275    // (NOT the daemon-wide auth token). When absent, fall back to
5276    // the daemon-wide token — preserves the legacy unbound-token
5277    // path for browser/host/CLI clients.
5278    let agent_id = req
5279        .params
5280        .get("agent_id")
5281        .and_then(Value::as_str)
5282        .map(str::to_string);
5283
5284    if let Some(id) = agent_id {
5285        let supervisor = state.supervisor()?;
5286        let binding = supervisor
5287            .authenticate_agent_token(&id, supplied)
5288            .await
5289            .map_err(|error| format!("auth failed for agent_id `{id}`: {error}"))?
5290            .ok_or_else(|| {
5291                format!("auth failed: agent_id `{id}` is not supervised, or token mismatch")
5292            })?;
5293        // Bind the token's method scope before publishing the agent identity.
5294        // On an auth-enabled daemon the read-loop auth gate also serializes
5295        // this handshake; the ordering closes the dominant auth-disabled
5296        // pipelining window in the same way the identity-first ordering below
5297        // closes the sandbox-bind window.
5298        *session
5299            .agent_method_allowlist
5300            .write()
5301            .map_err(|_| "auth failed: agent method allowlist lock poisoned".to_string())? =
5302            binding
5303                .method_allowlist
5304                .clone()
5305                .map(|methods| methods.into_iter().collect());
5306        // Commit the bound agent identity FIRST, before the attached-agents
5307        // lock and the (disk-I/O) memgine load below (Parslee-ai/car#480 review).
5308        // Frames on one connection are dispatched concurrently, so on an
5309        // auth-DISABLED daemon a pipelined `session.bindSandbox` could otherwise
5310        // race this handler and observe `agent_id == None` during that I/O
5311        // window, escaping its supervised-agent gate. (On an auth-token daemon —
5312        // the deployment that cares about confinement — the read-loop auth gate
5313        // already serializes the handshake, so the gate is sound by
5314        // construction; this ordering closes the dominant auth-disabled window
5315        // too.)
5316        *session.agent_id.lock().await = Some(id.clone());
5317        // Single-claim: only one connection at a time per
5318        // agent_id. A second claim is rejected so the daemon-side
5319        // per-agent state stays unambiguous.
5320        {
5321            let mut attached = state.attached_agents.lock().await;
5322            if let Some(prior) = attached.get(&id) {
5323                if prior != &session.client_id {
5324                    // Supersede a stale claim rather than reject it. The
5325                    // supervisor runs a single instance per `agent_id`, and this
5326                    // connection just passed `validate_agent_token`, so a fresh
5327                    // valid attach is authoritative — it's the live process.
5328                    //
5329                    // Why this matters (Parslee-ai/car Windows bring-up): when a
5330                    // supervised agent is hard-killed and respawned, the dead
5331                    // process's socket can linger half-open (no FIN on a
5332                    // TerminateProcess), so the disconnect teardown that frees
5333                    // this claim (session.rs) hasn't run yet. The old
5334                    // reject-on-conflict then failed EVERY respawn with
5335                    // "already attached", and the agent flapped forever
5336                    // (running→errored→backoff), so no chat turn could route.
5337                    // Last-valid-attach-wins is exactly what the teardown code's
5338                    // own comment intends ("supervisor-respawned replacement can
5339                    // take the slot"); this makes it robust to delayed teardown.
5340                    tracing::warn!(
5341                        agent_id = %id,
5342                        prior_client = %prior,
5343                        new_client = %session.client_id,
5344                        "superseding stale agent attach claim (respawn took over)"
5345                    );
5346                }
5347            }
5348            attached.insert(id.clone(), session.client_id.clone());
5349        }
5350        // #170: attach the daemon-owned persistent memgine for
5351        // this agent. Lazy-loaded on first connection per id from
5352        // `~/.car/memory/agents/<id>.jsonl`; retained across
5353        // disconnect so the next session sees the same state.
5354        let agent_eng = get_or_load_agent_memgine(state, &id).await?;
5355        // An explicit namespace wins for the memory scope: it is the more
5356        // specific request, and it is what a single agent working across two
5357        // projects needs. The agent binding still governs identity, tokens and
5358        // the attach claim above — only the graph differs.
5359        let bound = match memory_namespace.as_deref() {
5360            Some(ns) => {
5361                // Record the namespace here too — this path binds the graph
5362                // directly rather than through bind_memory_namespace, because
5363                // it falls back to the agent engine when no namespace is given.
5364                *session.memory_namespace.lock().await = Some(ns.to_string());
5365                get_or_load_namespace_memgine(state, ns).await?
5366            }
5367            None => agent_eng,
5368        };
5369        *session.bound_memgine.lock().await = Some(bound);
5370        session
5371            .authenticated
5372            .store(true, std::sync::atomic::Ordering::Release);
5373        *session.tenant.lock().await = tenant_binding;
5374        let mut response = serde_json::json!({
5375            "ok": true,
5376            "auth_enabled": true,
5377            "agent_id": id,
5378        });
5379        if let Some(methods) = binding.method_allowlist {
5380            response["method_allowlist"] = serde_json::json!(methods);
5381        }
5382        return Ok(response);
5383    }
5384
5385    let expected = match state.auth_token.get() {
5386        Some(t) => t,
5387        None => {
5388            // Auth disabled — accept any token politely so callers
5389            // that always include a session.auth handshake (e.g. the
5390            // FFI proxy) don't fail when the daemon happens to be
5391            // unauthed. Mark the session authenticated anyway so the
5392            // gate is a no-op below.
5393            if let Some(ns) = memory_namespace.as_deref() {
5394                bind_memory_namespace(state, session, ns).await?;
5395            }
5396            session
5397                .authenticated
5398                .store(true, std::sync::atomic::Ordering::Release);
5399            *session.tenant.lock().await = tenant_binding;
5400            return Ok(serde_json::json!({
5401                "ok": true,
5402                "auth_enabled": false,
5403                "memory_namespace": memory_namespace,
5404            }));
5405        }
5406    };
5407    if !constant_time_eq(supplied.as_bytes(), expected.as_bytes()) {
5408        return Err("auth failed: token mismatch".to_string());
5409    }
5410    if let Some(ns) = memory_namespace.as_deref() {
5411        bind_memory_namespace(state, session, ns).await?;
5412    }
5413    session
5414        .authenticated
5415        .store(true, std::sync::atomic::Ordering::Release);
5416    *session.tenant.lock().await = tenant_binding;
5417    Ok(serde_json::json!({
5418        "ok": true,
5419        "auth_enabled": true,
5420        "memory_namespace": memory_namespace,
5421        "parslee": state.parslee_session.get().map(|session| session.identity.clone()),
5422    }))
5423}
5424
5425/// Length-checked constant-time byte comparison. Returns false when
5426/// lengths differ (so length itself is the only timing leak — fine
5427/// for our 43-char fixed-length tokens).
5428fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
5429    if a.len() != b.len() {
5430        return false;
5431    }
5432    let mut diff: u8 = 0;
5433    for (x, y) in a.iter().zip(b.iter()) {
5434        diff |= x ^ y;
5435    }
5436    diff == 0
5437}
5438
5439/// Block dispatch of `method` until the user resolves the approval
5440/// raised on [`HostState`].
5441///
5442/// Called from the dispatcher loop only when
5443/// [`crate::session::ApprovalGate::requires_approval`] returns true.
5444/// `Ok(())` means the user picked "approve"; `Err(reason)` is sent
5445/// to the caller as JSON-RPC error code `-32003` with the supplied
5446/// reason. On timeout, the approval row stays in `Pending` so the
5447/// UI keeps a record of the unanswered request.
5448async fn gate_high_risk_method(
5449    method: &str,
5450    params: &Value,
5451    state: &Arc<ServerState>,
5452) -> Result<(), String> {
5453    let timeout = state.approval_gate.timeout;
5454    let req = CreateHostApprovalRequest {
5455        agent_id: None,
5456        action: format!("ws.method:{method}"),
5457        details: serde_json::json!({
5458            "method": method,
5459            // Truncate params for the UI — full payload is recoverable
5460            // via the request-time host event log if needed. The cap
5461            // keeps a malicious caller from drowning the UI in JSON.
5462            "params_preview": preview_params(params, 2_000),
5463        }),
5464        options: vec!["approve".to_string(), "deny".to_string()],
5465        // The high-risk-method gate is already system-level (it
5466        // passes None as the owner via request_and_wait_approval's
5467        // internal call). This field is informational here.
5468        system_level: true,
5469    };
5470    match state
5471        .host
5472        .request_and_wait_approval(req, "approve", timeout)
5473        .await
5474    {
5475        Ok(crate::host::ApprovalOutcome::Approved) => Ok(()),
5476        Ok(crate::host::ApprovalOutcome::Denied) => Err(format!(
5477            "{method} denied by user (approval gate, audit 2026-05). \
5478             To call this method without an interactive prompt, start \
5479             car-server with --no-approvals on a trusted machine."
5480        )),
5481        Ok(crate::host::ApprovalOutcome::TimedOut) => Err(format!(
5482            "{method} approval timed out after {}s with no resolution. \
5483             The approval is still visible in `host.approvals` for \
5484             forensics; resubmit the request to retry.",
5485            timeout.as_secs()
5486        )),
5487        Err(e) => Err(format!("approval gate error: {e}")),
5488    }
5489}
5490
5491fn preview_params(value: &Value, max_chars: usize) -> Value {
5492    let s = value.to_string();
5493    if s.len() <= max_chars {
5494        value.clone()
5495    } else {
5496        Value::String(format!("{}… (truncated)", &s[..max_chars]))
5497    }
5498}
5499
5500async fn handle_session_init(
5501    req: &JsonRpcMessage,
5502    session: &crate::session::ClientSession,
5503) -> Result<Value, String> {
5504    let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5505    let init: SessionInitRequest =
5506        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5507
5508    for tool in &init.tools {
5509        register_callback_definition(session, tool).await?;
5510    }
5511
5512    let mut policy_count = 0;
5513    {
5514        let mut policies = session.runtime.policies.write().await;
5515        for policy_def in &init.policies {
5516            if let Some(check) = build_policy_check(policy_def) {
5517                match blanket_denied_tool(policy_def) {
5518                    Some(tool) => policies.register_tool_deny(&policy_def.name, &tool, check, ""),
5519                    None => policies.register(&policy_def.name, check, ""),
5520                }
5521                policy_count += 1;
5522            }
5523        }
5524    }
5525
5526    serde_json::to_value(SessionInitResponse {
5527        session_id: session.client_id.clone(),
5528        tools_registered: init.tools.len(),
5529        policies_registered: policy_count,
5530    })
5531    .map_err(|e| e.to_string())
5532}
5533
5534/// Clear the fail-stop latch on this exact WebSocket session.
5535///
5536/// The latch belongs to the connection, so only a host-authenticated client
5537/// on that same connection can clear it. Ordinary clients recover by
5538/// reconnecting, which creates a fresh session as required by the non-durable
5539/// contract.
5540async fn handle_session_clear_halt(
5541    session: &crate::session::ClientSession,
5542) -> Result<Value, String> {
5543    if !session.is_host.load(Ordering::Acquire) {
5544        return Err("session.clear_halt requires the host-management role".to_string());
5545    }
5546    let cleared = session.halted.swap(false, Ordering::AcqRel);
5547    Ok(serde_json::json!({ "cleared": cleared, "halted": false }))
5548}
5549
5550/// `session.bindSubstrate { connector: "<slug>" }` — bind THIS session's
5551/// runtime to the execution substrate of an already-connected MCP
5552/// connector (`docs/execution-substrate.md` phase 3). After binding, the
5553/// session's commodity built-ins (`read_file`/`write_file`/`edit_file`/
5554/// `list_dir`/`find_files`/`grep_files`) execute on the connector's
5555/// environment instead of the WS client/host, so they co-locate with the
5556/// session's `mcp_{slug}_*` tools.
5557///
5558/// Opt-in and per-session: a session that never calls this keeps the
5559/// historic host/client composition. Returns
5560/// `{ "bound": true, "substrate": "<name>" }` on success.
5561async fn handle_session_bind_substrate(
5562    req: &JsonRpcMessage,
5563    session: &Arc<crate::session::ClientSession>,
5564    state: &Arc<ServerState>,
5565) -> Result<Value, String> {
5566    let connector = req
5567        .params
5568        .get("connector")
5569        .and_then(|v| v.as_str())
5570        .ok_or_else(|| "missing `connector` (MCP connector slug)".to_string())?;
5571
5572    let name = state
5573        .bind_substrate_to_connector(session, connector)
5574        .await?;
5575    Ok(serde_json::json!({ "bound": true, "substrate": name }))
5576}
5577
5578/// `session.bindSandbox { working_dir, image?, network?, memory?, cpus?,
5579/// pids_limit?, persistent?, command_timeout_secs? }` — bind this session's
5580/// runtime to a Docker-sandboxed execution environment (D1). The session's
5581/// commodity built-ins (shell / read_file / write_file / list_dir /
5582/// grep_files) then run INSIDE a hardened container mounted on
5583/// `working_dir` (default-secure: no network unless `network` is set,
5584/// memory/pids/cpu caps, dropped capabilities — D2/D3).
5585///
5586/// Preflights Docker + the image FIRST and returns the preflight's
5587/// actionable error when the host can't run containers (no daemon, image
5588/// unpullable) — an explicit, typed refusal, never a silent fallback to
5589/// unsandboxed local execution (D3: substituting the host for the sandbox
5590/// behind the caller's back would be a security downgrade).
5591async fn handle_session_bind_sandbox(
5592    req: &JsonRpcMessage,
5593    session: &Arc<crate::session::ClientSession>,
5594    state: &Arc<ServerState>,
5595) -> Result<Value, String> {
5596    // `session.bindSandbox` mounts an arbitrary host `working_dir` read-write
5597    // into a container the caller drives via shell. Bound agents are denied
5598    // (Parslee-ai/car#480); host/CLI clients are unaffected.
5599    //
5600    // **Advisory, not a confinement boundary — do not build authorization on
5601    // it.** This used to say that letting a supervised agent bind-mount any
5602    // host dir "would turn the daemon into a confinement escape". It would not.
5603    // A supervised agent runs as the launching user with no sandbox, so it can
5604    // already read `~/.ssh` directly; the isolation car-pr-review has is
5605    // app-level (CAR declines to hand it credentials), not an OS boundary. And
5606    // the check is escapable on its own terms: an agent controls its own
5607    // environment, so it can unset `CAR_AGENT_ID` and reconnect unbound
5608    // (car#1297).
5609    //
5610    // Kept anyway, because it costs nothing and makes the confined path the
5611    // easy one. What it must not do is imply a guarantee it cannot make. The
5612    // per-gate audit is in docs/proposals/agent-binding-trust-boundary.md;
5613    // `host_token` is what a real boundary looks like in this codebase.
5614    if let Some(agent_id) = session.agent_id.lock().await.clone() {
5615        return Err(format!(
5616            "session.bindSandbox is not available to supervised agents \
5617             (agent '{agent_id}'): it would let a confined agent bind-mount any \
5618             host directory into a container it controls. See Parslee-ai/car#480."
5619        ));
5620    }
5621
5622    let working_dir = req
5623        .params
5624        .get("working_dir")
5625        .and_then(|v| v.as_str())
5626        .ok_or_else(|| "missing `working_dir`".to_string())?;
5627    let wd = std::path::Path::new(working_dir);
5628    if !wd.is_dir() {
5629        return Err(format!("working_dir '{working_dir}' is not a directory"));
5630    }
5631
5632    let mut config = car_sandbox::SandboxConfig {
5633        working_dir: wd.to_path_buf(),
5634        ..Default::default()
5635    };
5636    if let Some(image) = opt_str(&req.params, "image") {
5637        config.image = image.to_string();
5638    }
5639    // Egress stays OFF unless explicitly requested (D2).
5640    if let Some(net) = opt_str(&req.params, "network") {
5641        config.network = Some(net.to_string());
5642    }
5643    if let Some(mem) = opt_str(&req.params, "memory") {
5644        config.memory = Some(mem.to_string());
5645    }
5646    if let Some(cpus) = opt_str(&req.params, "cpus") {
5647        config.cpus = Some(cpus.to_string());
5648    }
5649    if let Some(pids) = req.params.get("pids_limit").and_then(|v| v.as_u64()) {
5650        config.pids_limit = Some(pids);
5651    }
5652    // NOTE: no `persistent` param — SandboxConfig::persistent is not yet
5653    // wired in the executor (the container is always cached per executor),
5654    // and advertising a no-op knob is worse than not offering it (review Q1).
5655    if let Some(t) = req
5656        .params
5657        .get("command_timeout_secs")
5658        .and_then(|v| v.as_u64())
5659    {
5660        if t == 0 {
5661            return Err("command_timeout_secs must be >= 1".to_string());
5662        }
5663        config.command_timeout_secs = t;
5664    }
5665
5666    // D3: preflight before binding — a host without Docker gets the
5667    // preflight's own actionable message (install/start Docker, pull the
5668    // image), not a broken half-bound session.
5669    let pf = car_sandbox::preflight(&config.image).await;
5670    if !pf.is_ok() {
5671        return Err(format!("sandbox preflight failed: {}", pf.message()));
5672    }
5673
5674    let executor = std::sync::Arc::new(car_sandbox::SandboxExecutor::new(config));
5675    let substrate: std::sync::Arc<dyn car_engine::Substrate> = executor;
5676    let name = substrate.name().to_string();
5677
5678    // Same three-step swap as bind_substrate_to_connector (see its note on
5679    // why the non-atomic swap is benign: a WS connection handles its
5680    // JSON-RPC sequentially and bind is a control call issued before tool
5681    // work): bind the substrate, re-register the substrate-backed
5682    // built-ins, then shadow the substrate-owned tool names so they fall
5683    // through to the sandbox while everything else keeps the WS callback
5684    // path.
5685    session.runtime.set_substrate(substrate).await;
5686    session.runtime.register_agent_basics().await;
5687    // Compose EXACTLY like bind_substrate_to_connector: connector
5688    // (mcp_{slug}_*) routes first, then the WS client callback, with the
5689    // substrate-owned built-in names shadowed to the sandbox. Wrapping the
5690    // bare WS executor here severed every connector tool on the session —
5691    // still registered and advertised to the model, but dispatched to a WS
5692    // client that has never heard of them (review C1).
5693    let ws_executor = std::sync::Arc::new(crate::session::WsToolExecutor::new(
5694        session.channel.clone(),
5695        session.negotiated_capabilities.clone(),
5696        session.halted.clone(),
5697    ));
5698    let composed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5699        std::sync::Arc::new(state.mcp_executor.share_with_fallback(ws_executor));
5700    let shadowed: std::sync::Arc<dyn car_engine::ToolExecutor> =
5701        std::sync::Arc::new(crate::session::SubstrateShadowExecutor::new(composed));
5702    session.runtime.set_executor(shadowed).await;
5703
5704    Ok(serde_json::json!({ "bound": true, "substrate": name }))
5705}
5706
5707/// The tool a policy definition forbids **outright**, if it forbids one.
5708///
5709/// `deny_tool` is the only wire rule kind whose totality is decidable from the
5710/// kind alone, which is the same line `PolicyEngine::blanket_denied_tools`
5711/// draws. `deny_connector` is also a blanket refusal but of an `mcp_{slug}_*`
5712/// PREFIX rather than a named tool, which that surface cannot express. It stays
5713/// enforcement-only, and nothing is lost by that: connector rules live on the
5714/// daemon's session runtime, which never assembles a model-facing tool list
5715/// (the model is in the client there), and disabling a connector already calls
5716/// `Runtime::unregister_tool` on each of its tools — removing them from the
5717/// registry outright, which is strictly stronger than declining to advertise
5718/// them. A prefix read-back here would have no caller.
5719fn blanket_denied_tool(def: &PolicyDefinition) -> Option<String> {
5720    (def.rule.as_str() == "deny_tool").then(|| def.target.clone())
5721}
5722
5723fn build_policy_check(def: &PolicyDefinition) -> Option<car_policy::PolicyCheck> {
5724    match def.rule.as_str() {
5725        "deny_tool" => {
5726            let target = def.target.clone();
5727            Some(Box::new(
5728                move |action: &car_ir::Action, _: &car_state::StateStore| {
5729                    if action.tool.as_deref() == Some(&target) {
5730                        Some(format!("tool '{}' denied", target))
5731                    } else {
5732                        None
5733                    }
5734                },
5735            ))
5736        }
5737        // Deny every tool from a remote MCP connector by slug. Connector
5738        // tools are named `mcp_{slug}_{tool}` (car-connectors), so a
5739        // single rule governs the whole connector — the policy-level
5740        // counterpart to per-tool enablement.
5741        "deny_connector" => {
5742            let slug = def.target.clone();
5743            let prefix = format!("mcp_{slug}_");
5744            Some(Box::new(
5745                move |action: &car_ir::Action, _: &car_state::StateStore| match action
5746                    .tool
5747                    .as_deref()
5748                {
5749                    Some(tool) if tool.starts_with(&prefix) => {
5750                        Some(format!("connector '{}' denied", slug))
5751                    }
5752                    _ => None,
5753                },
5754            ))
5755        }
5756        "require_state" => {
5757            let key = def.key.clone();
5758            let value = def.value.clone();
5759            Some(Box::new(
5760                move |_: &car_ir::Action, state: &car_state::StateStore| {
5761                    if state.get(&key).as_ref() != Some(&value) {
5762                        Some(format!("state['{}'] must be {:?}", key, value))
5763                    } else {
5764                        None
5765                    }
5766                },
5767            ))
5768        }
5769        "deny_tool_param" => {
5770            let target = def.target.clone();
5771            let param = def.key.clone();
5772            let pattern = def.pattern.clone();
5773            Some(Box::new(
5774                move |action: &car_ir::Action, _: &car_state::StateStore| {
5775                    if action.tool.as_deref() != Some(&target) {
5776                        return None;
5777                    }
5778                    if let Some(val) = action.parameters.get(&param) {
5779                        let s = val.as_str().unwrap_or(&val.to_string()).to_string();
5780                        if s.contains(&pattern) {
5781                            return Some(format!("param '{}' matches '{}'", param, pattern));
5782                        }
5783                    }
5784                    None
5785                },
5786            ))
5787        }
5788        _ => None,
5789    }
5790}
5791
5792async fn handle_tools_register(
5793    req: &JsonRpcMessage,
5794    session: &crate::session::ClientSession,
5795) -> Result<Value, String> {
5796    let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5797    let tools: Vec<ToolDefinition> =
5798        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
5799    for tool in &tools {
5800        register_callback_definition(session, tool).await?;
5801    }
5802    serde_json::to_value(crate::wire_schema::ToolsRegisterResult(tools.len()))
5803        .map_err(|e| format!("serialize tools.register result: {e}"))
5804}
5805
5806/// Bridge a wire-protocol `ToolDefinition` to the engine's
5807/// schema-aware registration. Carries the caller-settable ToolSchema fields
5808/// (description, parameters, returns, idempotency, caching, rate limit) through
5809/// to the validator, while assigning `source = user_defined` server-side. An
5810/// empty `parameters` object is
5811/// the legacy schemaless registration — the validator no-ops for
5812/// those, so pre-v0.5.x callers see no change.
5813fn callback_standing_authority_eligible(def: &ToolDefinition) -> bool {
5814    // Standing authority is intentionally limited to the one callback CAR
5815    // currently needs for Daily Continuity. A denylist is unsafe here: an
5816    // equivalent generic executor can always be renamed (for example `curl`
5817    // or `fetch`) or hide its authority behind a new parameter name.
5818    if def.name != "newsroom.publish"
5819        || def.idempotent
5820        || def.cache_ttl_secs.is_some()
5821        || def.rate_limit.is_some()
5822    {
5823        return false;
5824    }
5825    def.parameters
5826        == serde_json::json!({
5827            "type": "object",
5828            "properties": {
5829                "edition_id": {
5830                    "type": "string",
5831                    "minLength": 1
5832                }
5833            },
5834            "required": ["edition_id"],
5835            "additionalProperties": false
5836        })
5837}
5838
5839async fn register_callback_definition(
5840    session: &crate::session::ClientSession,
5841    def: &ToolDefinition,
5842) -> Result<(), String> {
5843    if session.runtime.registry.get(&def.name).await.is_some() {
5844        return Err(format!(
5845            "tool '{}' is server-owned and cannot be shadowed by tools.register",
5846            def.name
5847        ));
5848    }
5849    let digest = car_proto::canonical_sha256(def)?;
5850    {
5851        let mut callbacks = session.callback_tool_schema_digests.write().await;
5852        if callback_standing_authority_eligible(def) {
5853            callbacks.insert(def.name.clone(), digest);
5854        } else {
5855            callbacks.remove(&def.name);
5856        }
5857    }
5858    session
5859        .runtime
5860        .register_tool_schema(car_ir::ToolSchema {
5861            name: def.name.clone(),
5862            source: car_ir::ToolSourceKind::UserDefined,
5863            description: def.description.clone(),
5864            parameters: def.parameters.clone(),
5865            returns: def.returns.clone(),
5866            idempotent: def.idempotent,
5867            cache_ttl_secs: def.cache_ttl_secs,
5868            rate_limit: def.rate_limit.as_ref().map(|rl| car_ir::ToolRateLimit {
5869                max_calls: rl.max_calls,
5870                interval_secs: rl.interval_secs,
5871            }),
5872        })
5873        .await;
5874    Ok(())
5875}
5876
5877/// `tools.list` — the toolset actually in effect on this connection. No params.
5878///
5879/// Counterpart to `tools.register`, which had none: a client could add tools to
5880/// its session runtime but had no way to ask what was registered, so a governed
5881/// or read-only deployment could not prove "only X and Y are callable here"
5882/// (Parslee-ai/car#892). The enumerate already existed on the engine
5883/// (`Runtime::tool_schemas`) — it was simply never exposed to daemon clients.
5884/// Exactly the gap `policy.list` closed for `policy.register` in
5885/// Parslee-ai/car#623; this is the other half of the same pair.
5886///
5887/// Returns `{ tools: [ToolSchema, ...], count }`. Each entry is the **full**
5888/// schema — `name`, runtime-assigned `source`, `description`, `parameters`,
5889/// and (when set) `returns`, `idempotent`, `cache_ttl_secs`, `rate_limit` — not
5890/// just the name, because
5891/// what a tool accepts is part of the effective surface being proven. Optional
5892/// fields are omitted when unset, per `car_ir::ToolSchema`'s own serialization.
5893///
5894/// **The array is sorted by tool name.** The underlying store is a `HashMap`,
5895/// so unsorted output would reorder between two calls that registered nothing
5896/// in between — an audit surface whose order changes on its own cannot be
5897/// diffed and is therefore not usable as proof.
5898///
5899/// Scope is the connection: every WebSocket client gets its own
5900/// `car_engine::Runtime`, so this reports what is in force on *this* session,
5901/// not a daemon-wide set. It is also not the assistant's set — `car do` /
5902/// `agents.chat` build their own `Runtime`, so a client that lists here and
5903/// then drives the assistant is not looking at the toolset that will execute
5904/// there.
5905///
5906/// Note that "in force" is a superset of "what the client registered", which is
5907/// most of why enumerating is worth doing. A fresh session already carries the
5908/// `messaging.send` built-in — `create_session` attaches an outbound message
5909/// sink, and `Runtime::with_message_sink` registers the matching schema in the
5910/// same step so the model is never shown a tool the runtime cannot execute. The
5911/// commodity stdlib is *not* included until the session asks for it; both
5912/// `session.bindSubstrate` and `session.bindSandbox` call
5913/// `register_agent_basics`, so either one adds it.
5914async fn handle_tools_list(session: &crate::session::ClientSession) -> Result<Value, String> {
5915    let mut schemas = session.runtime.tool_schemas().await;
5916    schemas.sort_by(|a, b| a.name.cmp(&b.name));
5917    let count = schemas.len();
5918    // Typed rather than an inline `json!` so `rpc.tools.list.result` in
5919    // `docs/wire-schema.json` is generated from the value this returns.
5920    serde_json::to_value(crate::wire_schema::ToolsListResult {
5921        tools: schemas,
5922        count,
5923    })
5924    .map_err(|e| format!("serialize tool schema: {e}"))
5925}
5926
5927/// `tools.unregister` — remove one tool from this session's runtime.
5928/// `{ name }`.
5929///
5930/// The removal half of Parslee-ai/car#892: with only `tools.register` on the
5931/// wire, a tool added to a session could not be taken back for the life of the
5932/// connection, so narrowing an over-broad registration meant reconnecting and
5933/// rebuilding the session's state. `Runtime::unregister_tool` already dropped
5934/// the tool from **both** the canonical registry and the legacy schema map —
5935/// the model stops seeing it and the validator stops accepting it — but nothing
5936/// called it from the dispatcher.
5937///
5938/// Returns `{ unregistered, removed }`, where `removed` is `1` if the tool was
5939/// present and `0` if it was not. A `0` is reported rather than raised, so a
5940/// client cleaning up can call this unconditionally without first listing —
5941/// the same contract `policy.unregister` settled on (Parslee-ai/car#623).
5942async fn handle_tools_unregister(
5943    req: &JsonRpcMessage,
5944    session: &crate::session::ClientSession,
5945) -> Result<Value, String> {
5946    let _lifecycle_guard = session.run_lifecycle_guard.lock().await;
5947    let name = req
5948        .params
5949        .get("name")
5950        .and_then(|v| v.as_str())
5951        .ok_or("missing 'name'")?;
5952    let removed = session.runtime.unregister_tool(name).await;
5953    session
5954        .callback_tool_schema_digests
5955        .write()
5956        .await
5957        .remove(name);
5958    serde_json::to_value(crate::wire_schema::ToolsUnregisterResult {
5959        unregistered: name.to_string(),
5960        removed: u32::from(removed),
5961    })
5962    .map_err(|e| format!("serialize tools.unregister result: {e}"))
5963}
5964
5965// ---- Detached tool dispatch (streaming/long-running, EPIC C / C2) ----
5966//
5967// A ToolCall action with `invocation_mode: "streaming" | "long_running"`
5968// is *started*, not awaited: its output is `{tool_handle, status:
5969// "running"}` and the DAG proceeds. These methods drive the handle
5970// against the session runtime's `ToolHandleRegistry`.
5971
5972/// `tools.poll { handle }` — drain the chunks buffered since the last
5973/// poll plus the invocation's current status (and, once terminal, its
5974/// final result/error). Returns `null` for an unknown or already
5975/// fully-consumed handle — absence, not an error (a terminal handle is
5976/// removed after its final state has been observed once with an empty
5977/// buffer, per the `car_engine::tool_handles` contract).
5978async fn handle_tools_poll(
5979    req: &JsonRpcMessage,
5980    session: &crate::session::ClientSession,
5981) -> Result<Value, String> {
5982    let handle = req
5983        .params
5984        .get("handle")
5985        .and_then(|v| v.as_str())
5986        .ok_or_else(|| "missing `handle`".to_string())?;
5987    match session.runtime.tool_poll(handle).await {
5988        Some(res) => serde_json::to_value(&res).map_err(|e| e.to_string()),
5989        None => Ok(Value::Null),
5990    }
5991}
5992
5993/// `tools.cancel { handle }` — request cooperative cancellation of a
5994/// detached invocation. Returns `{ cancelled: bool }` — `false` for an
5995/// unknown handle. Distinct from the server → client `tools.cancel`
5996/// *notification* (car#264, emitted when a `tools.execute` callback is
5997/// reaped): same method name, opposite direction.
5998async fn handle_tools_cancel(
5999    req: &JsonRpcMessage,
6000    session: &crate::session::ClientSession,
6001) -> Result<Value, String> {
6002    let handle = req
6003        .params
6004        .get("handle")
6005        .and_then(|v| v.as_str())
6006        .ok_or_else(|| "missing `handle`".to_string())?;
6007    let cancelled = session.runtime.tool_cancel(handle).await;
6008    serde_json::to_value(crate::wire_schema::ToolsCancelResult { cancelled })
6009        .map_err(|e| format!("serialize tools.cancel result: {e}"))
6010}
6011
6012/// `tools.stream.subscribe {}` — forward every [`car_ir::ToolStreamEvent`]
6013/// from this session runtime's fanout to the connection as a
6014/// `tools.stream.event` JSON-RPC notification `{ handle, chunk }`.
6015/// Idempotent per connection (a second subscribe is a no-op — one
6016/// forwarder task per session). The forwarder holds only the broadcast
6017/// receiver + the WS channel: it exits when the socket is gone (write
6018/// failure/timeout, same bounded-write policy as the run-trace drain
6019/// task) or the broadcast closes; on lag it skips the missed events and
6020/// keeps forwarding — missed chunks remain drainable via `tools.poll`,
6021/// which reads the buffer, not the broadcast.
6022async fn handle_tools_stream_subscribe(
6023    session: &crate::session::ClientSession,
6024) -> Result<Value, String> {
6025    use std::sync::atomic::Ordering;
6026    if session
6027        .tool_stream_subscribed
6028        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
6029        .is_err()
6030    {
6031        // Already forwarding to this connection.
6032        return serde_json::to_value(crate::wire_schema::ToolsStreamSubscribeResult {
6033            subscribed: true,
6034        })
6035        .map_err(|e| format!("serialize tools.stream.subscribe result: {e}"));
6036    }
6037    let mut rx = session.runtime.subscribe_tool_events();
6038    let channel = session.channel.clone();
6039    // Q2 (linus review): the forwarder can exit on a 10s write TIMEOUT
6040    // while the connection later recovers — the flag must reset when
6041    // the task exits so a re-subscribe spawns a fresh forwarder instead
6042    // of no-op'ing while events silently never flow again.
6043    let subscribed = session.tool_stream_subscribed.clone();
6044    tokio::spawn(async move {
6045        use futures::SinkExt;
6046        struct ResetOnExit(std::sync::Arc<std::sync::atomic::AtomicBool>);
6047        impl Drop for ResetOnExit {
6048            fn drop(&mut self) {
6049                self.0.store(false, std::sync::atomic::Ordering::SeqCst);
6050            }
6051        }
6052        let _reset_on_exit = ResetOnExit(subscribed);
6053        loop {
6054            match rx.recv().await {
6055                Ok(ev) => {
6056                    let Ok(json) = serde_json::to_string(&serde_json::json!({
6057                        "jsonrpc": "2.0",
6058                        "method": "tools.stream.event",
6059                        "params": { "handle": ev.handle.id, "chunk": ev.chunk },
6060                    })) else {
6061                        continue;
6062                    };
6063                    // Bounded write: a wedged socket must not park this
6064                    // task on a full TCP buffer forever. Any write
6065                    // failure/timeout means the connection is unusable —
6066                    // stop forwarding (chunks stay pollable).
6067                    let mut guard = channel.write.lock().await;
6068                    let send = tokio::time::timeout(
6069                        std::time::Duration::from_secs(10),
6070                        guard.send(Message::Text(json.into())),
6071                    )
6072                    .await;
6073                    drop(guard);
6074                    match send {
6075                        Ok(Ok(())) => {}
6076                        _ => break,
6077                    }
6078                }
6079                // Lagged subscriber: the bounded broadcast dropped its
6080                // oldest events. Not fatal — resume from the current
6081                // position; the gap is recoverable via tools.poll.
6082                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
6083                Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
6084            }
6085        }
6086    });
6087    serde_json::to_value(crate::wire_schema::ToolsStreamSubscribeResult { subscribed: true })
6088        .map_err(|e| format!("serialize tools.stream.subscribe result: {e}"))
6089}
6090
6091// ---- Remote MCP connectors (CAR as MCP client) ----------------------
6092//
6093// All connector state is process-wide on `ServerState` (shared
6094// McpToolExecutor + ConnectorManager), so these handlers take `&state`
6095// rather than the per-session runtime. Each ensures the persisted
6096// connectors are loaded before acting (idempotent after the first call
6097// / boot-time load).
6098
6099/// `connectors.add { name, url, headers? }` — register a remote MCP
6100/// server, connect, and discover its tools. `headers` is an optional
6101/// map of secret auth-header name → value, stored in the keychain (not
6102/// the manifest). No tools are enabled by the add itself; the response
6103/// reports the discovered count.
6104async fn handle_connectors_add(
6105    req: &JsonRpcMessage,
6106    state: &Arc<ServerState>,
6107) -> Result<Value, String> {
6108    state.ensure_connectors_loaded().await;
6109    let name = req
6110        .params
6111        .get("name")
6112        .and_then(|v| v.as_str())
6113        .ok_or_else(|| "missing `name`".to_string())?;
6114    let url = req
6115        .params
6116        .get("url")
6117        .and_then(|v| v.as_str())
6118        .ok_or_else(|| "missing `url`".to_string())?;
6119    let headers: Vec<(String, String)> = req
6120        .params
6121        .get("headers")
6122        .and_then(|v| v.as_object())
6123        .map(|m| {
6124            m.iter()
6125                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
6126                .collect()
6127        })
6128        .unwrap_or_default();
6129
6130    let status = state
6131        .connectors()
6132        .add(name, url, headers)
6133        .await
6134        .map_err(|e| e.to_string())?;
6135    serde_json::to_value(status).map_err(|e| e.to_string())
6136}
6137
6138/// `connectors.add_stdio { name, command, args?, env? }` — register a
6139/// local stdio MCP server (subprocess transport), connect, and discover
6140/// its tools. No tools are enabled by the add itself.
6141async fn handle_connectors_add_stdio(
6142    req: &JsonRpcMessage,
6143    state: &Arc<ServerState>,
6144) -> Result<Value, String> {
6145    state.ensure_connectors_loaded().await;
6146    let name = req
6147        .params
6148        .get("name")
6149        .and_then(|v| v.as_str())
6150        .ok_or_else(|| "missing `name`".to_string())?;
6151    let command = req
6152        .params
6153        .get("command")
6154        .and_then(|v| v.as_str())
6155        .ok_or_else(|| "missing `command`".to_string())?;
6156    let args: Vec<String> = req
6157        .params
6158        .get("args")
6159        .and_then(|v| v.as_array())
6160        .map(|a| {
6161            a.iter()
6162                .filter_map(|v| v.as_str().map(str::to_string))
6163                .collect()
6164        })
6165        .unwrap_or_default();
6166    let env: std::collections::BTreeMap<String, String> = req
6167        .params
6168        .get("env")
6169        .and_then(|v| v.as_object())
6170        .map(|m| {
6171            m.iter()
6172                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
6173                .collect()
6174        })
6175        .unwrap_or_default();
6176
6177    let status = state
6178        .connectors()
6179        .add_stdio(name, command, args, env)
6180        .await
6181        .map_err(|e| e.to_string())?;
6182    serde_json::to_value(status).map_err(|e| e.to_string())
6183}
6184
6185/// `connectors.authenticate { name, url, redirect_uri }` — begin an
6186/// OAuth 2.1 flow for a remote MCP server. Runs discovery + dynamic
6187/// client registration + PKCE, returns `{ authorize_url, state }`. The
6188/// GUI opens `authorize_url` (with its own `redirect_uri` callback),
6189/// captures the `code`, and calls `connectors.complete_authentication`.
6190async fn handle_connectors_authenticate(
6191    req: &JsonRpcMessage,
6192    state: &Arc<ServerState>,
6193) -> Result<Value, String> {
6194    state.ensure_connectors_loaded().await;
6195    let name = req
6196        .params
6197        .get("name")
6198        .and_then(|v| v.as_str())
6199        .ok_or_else(|| "missing `name`".to_string())?;
6200    let url = req
6201        .params
6202        .get("url")
6203        .and_then(|v| v.as_str())
6204        .ok_or_else(|| "missing `url`".to_string())?;
6205    let redirect_uri = req
6206        .params
6207        .get("redirect_uri")
6208        .and_then(|v| v.as_str())
6209        .ok_or_else(|| "missing `redirect_uri`".to_string())?;
6210
6211    let (authorize_url, oauth_state) = state
6212        .connectors()
6213        .authenticate(name, url, redirect_uri)
6214        .await
6215        .map_err(|e| e.to_string())?;
6216    Ok(serde_json::json!({ "authorize_url": authorize_url, "state": oauth_state }))
6217}
6218
6219/// `connectors.complete_authentication { state, code }` — finish the
6220/// OAuth flow started by `connectors.authenticate`: exchange the code,
6221/// store tokens in the keychain, connect, and discover tools.
6222async fn handle_connectors_complete_authentication(
6223    req: &JsonRpcMessage,
6224    state: &Arc<ServerState>,
6225) -> Result<Value, String> {
6226    state.ensure_connectors_loaded().await;
6227    let oauth_state = req
6228        .params
6229        .get("state")
6230        .and_then(|v| v.as_str())
6231        .ok_or_else(|| "missing `state`".to_string())?;
6232    let code = req
6233        .params
6234        .get("code")
6235        .and_then(|v| v.as_str())
6236        .ok_or_else(|| "missing `code`".to_string())?;
6237
6238    let status = state
6239        .connectors()
6240        .complete_authentication(oauth_state, code)
6241        .await
6242        .map_err(|e| e.to_string())?;
6243    serde_json::to_value(status).map_err(|e| e.to_string())
6244}
6245
6246/// `connectors.list` — all configured connectors with status.
6247async fn handle_connectors_list(state: &Arc<ServerState>) -> Result<Value, String> {
6248    state.ensure_connectors_loaded().await;
6249    let list = state.connectors().list().await;
6250    serde_json::to_value(list).map_err(|e| e.to_string())
6251}
6252
6253/// `connectors.tools { slug }` — discovered tools for a connector,
6254/// flagged by whether each is enabled.
6255async fn handle_connectors_tools(
6256    req: &JsonRpcMessage,
6257    state: &Arc<ServerState>,
6258) -> Result<Value, String> {
6259    state.ensure_connectors_loaded().await;
6260    let slug = req
6261        .params
6262        .get("slug")
6263        .and_then(|v| v.as_str())
6264        .ok_or_else(|| "missing `slug`".to_string())?;
6265    let tools = state
6266        .connectors()
6267        .tools(slug)
6268        .await
6269        .map_err(|e| e.to_string())?;
6270    serde_json::to_value(tools).map_err(|e| e.to_string())
6271}
6272
6273/// `connectors.enable_tools { slug, tools: [..] }` — enable a set of
6274/// (bare, server-side) tool names. Routes them, registers their
6275/// schemas into every open session's runtime, and persists the choice.
6276async fn handle_connectors_enable_tools(
6277    req: &JsonRpcMessage,
6278    state: &Arc<ServerState>,
6279) -> Result<Value, String> {
6280    state.ensure_connectors_loaded().await;
6281    let slug = req
6282        .params
6283        .get("slug")
6284        .and_then(|v| v.as_str())
6285        .ok_or_else(|| "missing `slug`".to_string())?;
6286    let tools: Vec<String> = req
6287        .params
6288        .get("tools")
6289        .and_then(|v| v.as_array())
6290        .map(|a| {
6291            a.iter()
6292                .filter_map(|v| v.as_str().map(str::to_string))
6293                .collect()
6294        })
6295        .ok_or_else(|| "missing `tools` array".to_string())?;
6296
6297    let entries = state
6298        .connectors()
6299        .enable_tools(slug, &tools)
6300        .await
6301        .map_err(|e| e.to_string())?;
6302    let enabled = entries.len();
6303    state.register_connector_entries(&entries).await;
6304    Ok(serde_json::json!({ "enabled": enabled }))
6305}
6306
6307/// `connectors.refresh { slug }` — re-run `tools/list` and re-register
6308/// any still-enabled tools.
6309async fn handle_connectors_refresh(
6310    req: &JsonRpcMessage,
6311    state: &Arc<ServerState>,
6312) -> Result<Value, String> {
6313    state.ensure_connectors_loaded().await;
6314    let slug = req
6315        .params
6316        .get("slug")
6317        .and_then(|v| v.as_str())
6318        .ok_or_else(|| "missing `slug`".to_string())?;
6319    let tools = state
6320        .connectors()
6321        .refresh(slug)
6322        .await
6323        .map_err(|e| e.to_string())?;
6324    // Re-seed enabled entries into live sessions in case the refresh
6325    // re-discovered a previously-enabled tool.
6326    let entries = state.connectors().enabled_tool_entries().await;
6327    state.register_connector_entries(&entries).await;
6328    serde_json::to_value(tools).map_err(|e| e.to_string())
6329}
6330
6331/// `connectors.remove { slug }` — disconnect, drop routes, delete
6332/// keychain secrets, and remove from the manifest.
6333async fn handle_connectors_remove(
6334    req: &JsonRpcMessage,
6335    state: &Arc<ServerState>,
6336) -> Result<Value, String> {
6337    state.ensure_connectors_loaded().await;
6338    let slug = req
6339        .params
6340        .get("slug")
6341        .and_then(|v| v.as_str())
6342        .ok_or_else(|| "missing `slug`".to_string())?;
6343    let canonicals = state
6344        .connectors()
6345        .remove(slug)
6346        .await
6347        .map_err(|e| e.to_string())?;
6348    state.unregister_connector_tools(&canonicals).await;
6349    Ok(serde_json::json!({ "removed": slug }))
6350}
6351
6352async fn run_store_blocking<T, F>(context: &'static str, operation: F) -> Result<T, String>
6353where
6354    T: Send + 'static,
6355    F: FnOnce() -> Result<T, String> + Send + 'static,
6356{
6357    tokio::task::spawn_blocking(operation)
6358        .await
6359        .map_err(|error| format!("{context} blocking task failed: {error}"))?
6360}
6361
6362/// Run a trust-bearing trace read off the Tokio worker, then publish any
6363/// discovered corruption into the live run registry before returning the
6364/// typed wire error. Filesystem scanning/marker persistence never holds the
6365/// global run lock; the short quarantine step runs only after it completes.
6366async fn strict_run_trace_read_blocking<T, F>(
6367    context: &'static str,
6368    state: &Arc<ServerState>,
6369    run_id: String,
6370    operation: F,
6371) -> Result<T, String>
6372where
6373    T: Send + 'static,
6374    F: FnOnce() -> std::io::Result<T> + Send + 'static,
6375{
6376    let result = tokio::task::spawn_blocking(operation)
6377        .await
6378        .map_err(|error| format!("{context} blocking task failed: {error}"))?;
6379    match result {
6380        Ok(value) => Ok(value),
6381        Err(error) if crate::run_store::is_trace_corruption_error(&error) => Err(state
6382            .quarantine_run_trace_from_read(&run_id, error.to_string())
6383            .await),
6384        Err(error) => Err(run_trace_read_error(&run_id, error)),
6385    }
6386}
6387
6388/// Run one proposal durability boundary without pinning a Tokio worker.
6389///
6390/// The owned lifecycle guard moves into the blocking task and is returned only
6391/// after the fsync/rename operation finishes. If the JSON-RPC handler is
6392/// cancelled while awaiting, the task still owns the guard until durability is
6393/// settled, so an exact retry cannot race an in-flight marker/outbox commit.
6394async fn run_lifecycle_durability_blocking<T, F>(
6395    guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
6396    context: &'static str,
6397    operation: F,
6398) -> Result<T, String>
6399where
6400    T: Send + 'static,
6401    F: FnOnce() -> Result<T, String> + Send + 'static,
6402{
6403    let owned = guard
6404        .take()
6405        .expect("proposal durability requires the owned lifecycle guard");
6406    match tokio::task::spawn_blocking(move || {
6407        let result = operation();
6408        (owned, result)
6409    })
6410    .await
6411    {
6412        Ok((owned, result)) => {
6413            *guard = Some(owned);
6414            result
6415        }
6416        Err(error) => Err(format!("{context} blocking task failed: {error}")),
6417    }
6418}
6419
6420/// `connectors.disable_tools { slug, tools: [..] }` — disable a set of
6421/// (bare, server-side) tool names: drop their routes and unregister
6422/// their schemas from every open session's runtime.
6423async fn handle_connectors_disable_tools(
6424    req: &JsonRpcMessage,
6425    state: &Arc<ServerState>,
6426) -> Result<Value, String> {
6427    state.ensure_connectors_loaded().await;
6428    let slug = req
6429        .params
6430        .get("slug")
6431        .and_then(|v| v.as_str())
6432        .ok_or_else(|| "missing `slug`".to_string())?;
6433    let tools: Vec<String> = req
6434        .params
6435        .get("tools")
6436        .and_then(|v| v.as_array())
6437        .map(|a| {
6438            a.iter()
6439                .filter_map(|v| v.as_str().map(str::to_string))
6440                .collect()
6441        })
6442        .ok_or_else(|| "missing `tools` array".to_string())?;
6443
6444    let canonicals = state
6445        .connectors()
6446        .disable_tools(slug, &tools)
6447        .await
6448        .map_err(|e| e.to_string())?;
6449    let disabled = canonicals.len();
6450    state.unregister_connector_tools(&canonicals).await;
6451    Ok(serde_json::json!({ "disabled": disabled }))
6452}
6453
6454async fn handle_proposal_submit(
6455    req: &JsonRpcMessage,
6456    session: &crate::session::ClientSession,
6457    state: &Arc<ServerState>,
6458) -> Result<Value, String> {
6459    let submit: ProposalSubmitRequest =
6460        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
6461    let original_submission = req
6462        .params
6463        .get("proposal")
6464        .cloned()
6465        .ok_or_else(|| "proposal.submit is missing `proposal`".to_string())?;
6466    // Fail before binding a policy session or executing any action when two
6467    // proposal-local actions would share an ambiguous journal/DAG join key, or
6468    // when the exact submitted Proposal cannot carry the mandatory RFC 8785
6469    // identity used by proposal_received. Runtime entry points repeat both
6470    // guards; this boundary preserves an invalid-proposal JSON-RPC error.
6471    car_engine::validate_proposal_action_ids(&submit.proposal)
6472        .map_err(|error| format!("invalid proposal: {error}"))?;
6473    let submitted_proposal_digest = canonical_sha256(&submit.proposal)?;
6474    let submitted_proposal_value =
6475        serde_json::to_value(&submit.proposal).map_err(|e| e.to_string())?;
6476    // `session_id` is sibling to `proposal` in the params object —
6477    // not part of `ProposalSubmitRequest` (kept proto-compatible). When
6478    // present, executes the proposal under the named session so any
6479    // session-scoped policies layer on top of global ones.
6480    // See docs/proposals/per-session-policy-scoping.md.
6481    let session_id = match req.params.get("session_id") {
6482        None | Some(Value::Null) => None,
6483        Some(Value::String(value)) if !value.is_empty() => Some(value.clone()),
6484        Some(_) => return Err("invalid `session_id`: expected a non-empty string".to_string()),
6485    };
6486
6487    // Decode every execution context before acquiring/binding live lifecycle
6488    // state. A malformed scope must be a side-effect-free request error, not a
6489    // half-open proposal policy binding. CAR currently has no runtime entry
6490    // point that enforces both a policy session and a tenant scope; accepting
6491    // both while silently dropping either constraint would be an authorization
6492    // bypass, so the wire fails closed until a combined entry point exists.
6493    let scope: Option<car_engine::RuntimeScope> = match req.params.get("scope") {
6494        Some(v) if !v.is_null() => {
6495            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid scope: {e}"))?)
6496        }
6497        _ => None,
6498    };
6499    if session_id.is_some() && scope.is_some() {
6500        return Err(
6501            "proposal.submit cannot combine `session_id` and `scope`: CAR cannot yet enforce both contexts"
6502                .to_string(),
6503        );
6504    }
6505
6506    // One authenticated run bracket is one ordered journal producer. The
6507    // dispatcher otherwise runs frames concurrently, so serialize proposal
6508    // lifecycle with runs.start/runs.complete/disconnect before reading the
6509    // current run and keep the guard through the terminal proposal event.
6510    let mut run_guard = Some(session.run_lifecycle_guard.clone().lock_owned().await);
6511    let current_run = session.current_run_id.lock().await.clone();
6512
6513    // The global retry-owner check is one content-addressed file read. Run it
6514    // on blocking capacity so the async worker never performs filesystem I/O.
6515    // Keeping the lifecycle guard preserves request ordering while replacing
6516    // the old unbounded receipt/run-directory scan with one exact lookup.
6517    let receipt_owner = {
6518        let run_store = state.run_store.clone();
6519        let requested_policy_session_id = session_id.clone();
6520        let original_submission = original_submission.clone();
6521        tokio::task::spawn_blocking(move || {
6522            run_store.completed_proposal_retry_owner(
6523                requested_policy_session_id.as_deref(),
6524                &original_submission,
6525            )
6526        })
6527        .await
6528        .map_err(|error| format!("completed proposal response owner lookup failed: {error}"))?
6529        .map_err(|error| format!("completed proposal response registry is unreadable: {error}"))?
6530    };
6531    if let Some((receipt_run, receipt_client)) = receipt_owner.as_ref() {
6532        let owner_matches = if current_run.as_deref() == Some(receipt_run.as_str()) {
6533            state
6534                .run_owner_binding(receipt_run)
6535                .await
6536                .is_some_and(|(durable, active)| {
6537                    durable == *receipt_client && active == session.client_id
6538                })
6539        } else {
6540            false
6541        };
6542        if !owner_matches {
6543            return Err(format!(
6544                "{} completed proposal response belongs to run `{receipt_run}` / durable client `{receipt_client}`; this socket is not the active authenticated owner",
6545                car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6546            ));
6547        }
6548    }
6549    if let Some(run_id) = current_run.as_deref() {
6550        let (durable_client, active_client) = state
6551            .run_owner_binding(run_id)
6552            .await
6553            .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6554        let (owner_client, terminal, start_committed, completion_pending, trace_corruption) = state
6555            .run_lifecycle_state_with_corruption(run_id)
6556            .await
6557            .ok_or_else(|| format!("active run `{run_id}` is absent from CAR's run registry"))?;
6558        if let Some(error) = trace_corruption {
6559            return Err(error);
6560        }
6561        if terminal
6562            || !start_committed
6563            || completion_pending
6564            || owner_client != session.client_id
6565            || active_client != session.client_id
6566        {
6567            return Err(format!(
6568                "active run is not writable for run_id `{run_id}` / client_id `{}`",
6569                session.client_id
6570            ));
6571        }
6572        session.require_run_journal_binding(run_id).await?;
6573
6574        let (completed, resumed_policy_rotation) = {
6575            let run_store = state.run_store.clone();
6576            let run_id = run_id.to_string();
6577            let client_id = durable_client.clone();
6578            let requested_policy_session_id = session_id.clone();
6579            let original_submission = original_submission.clone();
6580            run_store_blocking("completed proposal response lookup", move || {
6581                let exact = run_store
6582                    .completed_proposal(
6583                        &run_id,
6584                        &client_id,
6585                        requested_policy_session_id.as_deref(),
6586                        &original_submission,
6587                    )
6588                    .map_err(|error| {
6589                        proposal_durability_quarantine(&run_id, "completed response", &error)
6590                    })?;
6591                let resumed_owner = client_id != active_client;
6592                if exact.is_some() || !resumed_owner {
6593                    return Ok((exact, resumed_owner));
6594                }
6595                run_store
6596                    .completed_proposal_for_resumed_owner(&run_id, &client_id, &original_submission)
6597                    .map(|receipt| (receipt, true))
6598                    .map_err(|error| {
6599                        proposal_durability_quarantine(
6600                            &run_id,
6601                            "resumed completed response",
6602                            &error,
6603                        )
6604                    })
6605            })
6606            .await?
6607        };
6608        if let Some(receipt) = completed {
6609            if resumed_policy_rotation {
6610                let original_policy = &receipt.finalization.requested_policy_session_id;
6611                let authenticated_original_policy = &receipt.finalization.policy_session_id;
6612                let replacement_policy_is_live = match session_id.as_deref() {
6613                    Some(policy_session_id) => {
6614                        session.runtime.session_exists(policy_session_id).await
6615                    }
6616                    None => false,
6617                };
6618                match (original_policy, authenticated_original_policy, &session_id) {
6619                    (None, None, None) => {}
6620                    (Some(requested), Some(authenticated), Some(_))
6621                        if requested == authenticated && replacement_policy_is_live => {}
6622                    _ => {
6623                        return Err(
6624                            "resumed proposal recovery requires the original authenticated policy provenance and a live replacement policy session"
6625                                .to_string(),
6626                        );
6627                    }
6628                }
6629            }
6630            let run_store = state.run_store.clone();
6631            let (value, cleanup_error) = run_lifecycle_durability_blocking(
6632                &mut run_guard,
6633                "completed proposal guard cleanup",
6634                move || {
6635                    let cleanup_error = run_store
6636                        .cleanup_completed_proposal_guards(&receipt)
6637                        .err()
6638                        .map(|error| error.to_string());
6639                    let value = run_store
6640                        .completed_proposal_response_value(&receipt)
6641                        .map_err(|error| {
6642                            format!("completed proposal response serialization failed: {error}")
6643                        })?;
6644                    Ok((value, cleanup_error))
6645                },
6646            )
6647            .await?;
6648            if let Some(error) = cleanup_error {
6649                tracing::warn!(run_id, %error, "completed proposal response remains recoverable while guard cleanup is pending");
6650            }
6651            return Ok(value);
6652        }
6653
6654        let pending = {
6655            let run_store = state.run_store.clone();
6656            let run_id = run_id.to_string();
6657            run_store_blocking("pending proposal lookup", move || {
6658                run_store.pending_proposal(&run_id).map_err(|error| {
6659                    proposal_durability_quarantine(&run_id, "finalization", &error)
6660                })
6661            })
6662            .await?
6663        };
6664        if let Some(pending) = pending {
6665            if pending.client_id != durable_client
6666                || pending.original_submission != original_submission
6667                || pending.requested_policy_session_id.as_deref() != session_id.as_deref()
6668            {
6669                return Err(format!(
6670                    "proposal finalization pending for run_id `{run_id}`; only an exact retry of original submission `{}` may reconcile it",
6671                    pending.original_proposal_id
6672                ));
6673            }
6674            return finish_pending_proposal(
6675                session,
6676                state,
6677                &pending,
6678                &durable_client,
6679                &mut run_guard,
6680            )
6681            .await;
6682        }
6683        let marker = {
6684            let run_store = state.run_store.clone();
6685            let run_id = run_id.to_string();
6686            run_store_blocking("proposal execution marker lookup", move || {
6687                run_store
6688                    .execution_marker(&run_id)
6689                    .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))
6690            })
6691            .await?
6692        };
6693        if let Some(marker) = marker {
6694            return Err(format!(
6695                "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not redispatch automatically",
6696                marker.original_proposal_id
6697            ));
6698        }
6699        if durable_client != session.client_id {
6700            return Err(format!(
6701                "resumed run `{run_id}` accepts only an exact recovery submission; CAR will not dispatch new actions"
6702            ));
6703        }
6704    }
6705
6706    // Only a CAR-minted live policy session is authenticated enough to stamp
6707    // into the journal. An arbitrary request label is never copied. The
6708    // runtime preserves its existing unknown-session rejection semantics.
6709    let authenticated_policy_session = match session_id.as_deref() {
6710        Some(policy_session_id) if session.runtime.session_exists(policy_session_id).await => {
6711            Some(policy_session_id.to_string())
6712        }
6713        _ => None,
6714    };
6715    let execution_marker =
6716        current_run
6717            .as_deref()
6718            .map(|run_id| crate::run_store::ProposalExecutionMarker {
6719                run_id: run_id.to_string(),
6720                client_id: session.client_id.clone(),
6721                requested_policy_session_id: session_id.clone(),
6722                policy_session_id: authenticated_policy_session.clone(),
6723                original_proposal_id: submit.proposal.id.clone(),
6724                original_submission: original_submission.clone(),
6725                original_proposal: submitted_proposal_value.clone(),
6726                proposal_digest: submitted_proposal_digest.clone(),
6727            });
6728    let retry_rollback =
6729        execution_marker
6730            .as_ref()
6731            .map(|marker| crate::run_store::ProposalRetryRollback {
6732                run_id: marker.run_id.clone(),
6733                client_id: marker.client_id.clone(),
6734                requested_policy_session_id: marker.requested_policy_session_id.clone(),
6735                original_submission: original_submission.clone(),
6736            });
6737    if let (Some(marker), Some(rollback)) = (execution_marker.as_ref(), retry_rollback.as_ref()) {
6738        let run_store = state.run_store.clone();
6739        let marker = marker.clone();
6740        let rollback = rollback.clone();
6741        let expected_run_id = marker.run_id.clone();
6742        let reservation = run_lifecycle_durability_blocking(
6743            &mut run_guard,
6744            "proposal pre-execution reservation durability",
6745            move || {
6746                run_store
6747                    .claim_proposal_id(
6748                        &marker.run_id,
6749                        &marker.client_id,
6750                        &marker.original_proposal_id,
6751                        &marker.original_submission,
6752                    )
6753                    .map_err(|error| {
6754                        format!("proposal id could not be durably claimed: {error}")
6755                    })?;
6756                run_store
6757                    .write_proposal_retry_rollback(&rollback)
6758                    .map_err(|error| {
6759                        format!("proposal retry rollback could not be durably prepared: {error}")
6760                    })?;
6761                run_store
6762                    .reserve_proposal_retry_owner(
6763                        &marker.run_id,
6764                        &marker.client_id,
6765                        marker.requested_policy_session_id.as_deref(),
6766                        &marker.original_submission,
6767                    )
6768                    .map_err(|error| {
6769                        format!("proposal retry reservation could not be durably prepared: {error}")
6770                    })
6771            },
6772        )
6773        .await?;
6774        if let crate::run_store::ProposalRetryReservation::Existing {
6775            run_id: reserved_run,
6776            client_id: reserved_client,
6777        } = reservation
6778        {
6779            if reserved_run != expected_run_id || reserved_client != session.client_id {
6780                let run_store = state.run_store.clone();
6781                let rollback = retry_rollback
6782                    .as_ref()
6783                    .expect("active reservation has rollback authority")
6784                    .clone();
6785                run_lifecycle_durability_blocking(
6786                    &mut run_guard,
6787                    "losing proposal retry rollback cleanup",
6788                    move || {
6789                        run_store
6790                            .clear_proposal_retry_rollback(&rollback)
6791                            .map_err(|error| {
6792                                format!("losing retry rollback cleanup failed: {error}")
6793                            })
6794                    },
6795                )
6796                .await?;
6797                return Err(format!(
6798                    "{} proposal retry tuple belongs to run `{reserved_run}` / client `{reserved_client}`; concurrent submission cannot dispatch it",
6799                    car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
6800                ));
6801            }
6802        }
6803    }
6804    if let Some(marker) = execution_marker.as_ref() {
6805        let run_store = state.run_store.clone();
6806        let durable_marker = marker.clone();
6807        let write_result = run_lifecycle_durability_blocking(
6808            &mut run_guard,
6809            "proposal execution marker durability",
6810            move || {
6811                run_store
6812                    .write_execution_marker(&durable_marker)
6813                    .map_err(|error| {
6814                        format!("proposal execution could not be durably prepared: {error}")
6815                    })
6816            },
6817        )
6818        .await;
6819        if let Err(error) = write_result {
6820            rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6821                .await
6822                .map_err(|rollback| format!("{error}; exact guard rollback failed: {rollback}"))?;
6823            return Err(error);
6824        }
6825    }
6826    if current_run.is_some() {
6827        if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6828            if let Err(error) = session
6829                .runtime
6830                .event_log_handle()
6831                .lock()
6832                .await
6833                .bind_policy_session(policy_session_id)
6834            {
6835                if let Some(marker) = execution_marker.as_ref() {
6836                    rollback_pre_execution_guards(
6837                        state,
6838                        &mut run_guard,
6839                        marker,
6840                        &original_submission,
6841                    )
6842                    .await
6843                    .map_err(|rollback| {
6844                        format!("{error}; exact guard rollback failed: {rollback}")
6845                    })?;
6846                }
6847                return Err(error);
6848            }
6849        }
6850    }
6851
6852    if let Some(rollback) = retry_rollback.as_ref() {
6853        let run_store = state.run_store.clone();
6854        let rollback = rollback.clone();
6855        let clear_result = run_lifecycle_durability_blocking(
6856            &mut run_guard,
6857            "proposal retry rollback intent cleanup",
6858            move || {
6859                run_store
6860                    .clear_proposal_retry_rollback(&rollback)
6861                    .map_err(|error| format!("proposal retry rollback cleanup failed: {error}"))
6862            },
6863        )
6864        .await;
6865        if let Err(error) = clear_result {
6866            if let Some(policy_session_id) = authenticated_policy_session.as_deref() {
6867                session
6868                    .runtime
6869                    .event_log_handle()
6870                    .lock()
6871                    .await
6872                    .clear_policy_session(policy_session_id)
6873                    .map_err(|unbind| {
6874                        format!("{error}; policy rollback failed before dispatch: {unbind}")
6875                    })?;
6876            }
6877            if let Some(marker) = execution_marker.as_ref() {
6878                rollback_pre_execution_guards(state, &mut run_guard, marker, &original_submission)
6879                    .await
6880                    .map_err(|rollback| {
6881                        format!("{error}; exact guard rollback failed: {rollback}")
6882                    })?;
6883            }
6884            return Err(error);
6885        }
6886    }
6887
6888    let proposal_event_start = if current_run.is_some() {
6889        Some(session.runtime.event_log_handle().lock().await.len())
6890    } else {
6891        None
6892    };
6893
6894    let result = match (session_id.as_deref(), scope) {
6895        (Some(sid), None) => {
6896            if current_run.is_some() {
6897                session
6898                    .runtime
6899                    .execute_with_session_and_stable_replan_id(&submit.proposal, sid)
6900                    .await
6901            } else {
6902                session
6903                    .runtime
6904                    .execute_with_session(&submit.proposal, sid)
6905                    .await
6906            }
6907        }
6908        (None, Some(s)) => {
6909            if current_run.is_some() {
6910                session
6911                    .runtime
6912                    .execute_scoped_with_stable_replan_id(&submit.proposal, &s)
6913                    .await
6914            } else {
6915                session.runtime.execute_scoped(&submit.proposal, &s).await
6916            }
6917        }
6918        (None, None) => {
6919            if current_run.is_some() {
6920                session
6921                    .runtime
6922                    .execute_with_stable_replan_id(&submit.proposal)
6923                    .await
6924            } else {
6925                session.runtime.execute(&submit.proposal).await
6926            }
6927        }
6928        (Some(_), Some(_)) => unreachable!("combined execution context rejected before binding"),
6929    };
6930
6931    // The engine has already applied terminal semantics here: retry stopped,
6932    // the proposal aborted, and transactional state rolled back. Latch the
6933    // daemon-only session scope before any optional run-finalization work so a
6934    // terminal callback cannot be followed by another admitted proposal even
6935    // if durable finalization reports an error to this caller.
6936    if result.results.iter().any(|action| action.terminal) {
6937        session.halted.store(true, Ordering::Release);
6938    }
6939
6940    if current_run.is_some() {
6941        let proposal_result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
6942        let result_digest = canonical_sha256(&proposal_result_value)?;
6943        let final_proposal = result
6944            .final_proposal
6945            .clone()
6946            .ok_or_else(|| "active v3 runtime result is missing final_proposal".to_string())?;
6947        let event_start = proposal_event_start
6948            .expect("active run captured its proposal event boundary before dispatch");
6949        let accepted_proposal_preimages = {
6950            let log = session.runtime.event_log_handle();
6951            let log = log.lock().await;
6952            collect_accepted_proposal_preimages(
6953                &submit.proposal,
6954                &result,
6955                &log.events()[event_start..],
6956            )?
6957        };
6958        let run_id = current_run
6959            .as_deref()
6960            .expect("current run checked before proposal finalization");
6961        let pending = crate::run_store::PendingProposalFinalization {
6962            run_id: run_id.to_string(),
6963            client_id: session.client_id.clone(),
6964            requested_policy_session_id: session_id,
6965            policy_session_id: authenticated_policy_session.clone(),
6966            original_proposal_id: result.original_proposal_id.clone(),
6967            final_proposal_id: result.proposal_id.clone(),
6968            original_submission,
6969            original_proposal: submit.proposal.clone(),
6970            final_proposal,
6971            accepted_proposal_preimages,
6972            proposal_result: result.clone(),
6973            result_digest,
6974        };
6975        let run_store = state.run_store.clone();
6976        let durable_pending = pending.clone();
6977        run_lifecycle_durability_blocking(
6978            &mut run_guard,
6979            "proposal finalization outbox durability",
6980            move || {
6981                run_store
6982                    .write_pending_proposal(&durable_pending)
6983                    .map_err(|error| {
6984                        format!("proposal finalization could not be durably prepared: {error}")
6985                    })
6986            },
6987        )
6988        .await?;
6989        return finish_pending_proposal(
6990            session,
6991            state,
6992            &pending,
6993            &session.client_id,
6994            &mut run_guard,
6995        )
6996        .await;
6997    }
6998
6999    serde_json::to_value(result).map_err(|e| e.to_string())
7000}
7001
7002async fn rollback_pre_execution_guards(
7003    state: &Arc<ServerState>,
7004    run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
7005    marker: &crate::run_store::ProposalExecutionMarker,
7006    original_submission: &Value,
7007) -> Result<(), String> {
7008    let run_store = state.run_store.clone();
7009    let marker = marker.clone();
7010    let submission = original_submission.clone();
7011    let rollback = crate::run_store::ProposalRetryRollback {
7012        run_id: marker.run_id.clone(),
7013        client_id: marker.client_id.clone(),
7014        requested_policy_session_id: marker.requested_policy_session_id.clone(),
7015        original_submission: submission.clone(),
7016    };
7017    run_lifecycle_durability_blocking(
7018        run_guard,
7019        "proposal pre-execution guard rollback",
7020        move || {
7021            run_store
7022                .clear_execution_marker(&marker)
7023                .map_err(|error| format!("execution marker rollback failed: {error}"))?;
7024            run_store
7025                .release_proposal_retry_owner(
7026                    &marker.run_id,
7027                    &marker.client_id,
7028                    marker.requested_policy_session_id.as_deref(),
7029                    &submission,
7030                )
7031                .map_err(|error| format!("retry owner rollback failed: {error}"))?;
7032            run_store
7033                .clear_proposal_retry_rollback(&rollback)
7034                .map_err(|error| format!("retry rollback intent cleanup failed: {error}"))
7035        },
7036    )
7037    .await
7038}
7039
7040async fn finish_pending_proposal(
7041    session: &crate::session::ClientSession,
7042    state: &Arc<ServerState>,
7043    pending: &crate::run_store::PendingProposalFinalization,
7044    durable_client_id: &str,
7045    run_guard: &mut Option<tokio::sync::OwnedMutexGuard<()>>,
7046) -> Result<Value, String> {
7047    let (started, _marker) = {
7048        let run_store = state.run_store.clone();
7049        let pending = pending.clone();
7050        run_store_blocking("proposal finalization provenance lookup", move || {
7051            run_store.pending_provenance(&pending).map_err(|error| {
7052                format!("proposal finalization provenance validation failed: {error}")
7053            })
7054        })
7055        .await?
7056    };
7057    let durable_client = started
7058        .client_id
7059        .as_deref()
7060        .ok_or_else(|| "durable RunStarted is missing its client identity".to_string())?;
7061    let active_owner = state
7062        .run_owner_binding(&pending.run_id)
7063        .await
7064        .ok_or_else(|| {
7065            format!(
7066                "active run `{}` is absent from CAR's run registry",
7067                pending.run_id
7068            )
7069        })?;
7070    if durable_client != durable_client_id
7071        || active_owner.0 != durable_client_id
7072        || active_owner.1 != session.client_id
7073    {
7074        return Err(
7075            "proposal finalization owner does not match the authenticated run binding".into(),
7076        );
7077    }
7078    session.require_run_journal_binding(&pending.run_id).await?;
7079    let event_log = session.runtime.event_log_handle();
7080    let mut log = event_log.lock().await;
7081    let active_policy = log
7082        .active_run_binding()
7083        .and_then(|(_, _, policy_session_id)| policy_session_id.map(str::to_string));
7084    match (
7085        active_policy.as_deref(),
7086        pending.policy_session_id.as_deref(),
7087    ) {
7088        (Some(active), Some(expected)) if active == expected => {}
7089        (None, Some(expected)) => log.bind_policy_session(expected)?,
7090        (None, None) => {}
7091        _ => {
7092            return Err(
7093                "proposal finalization policy identity does not match the active journal binding"
7094                    .into(),
7095            );
7096        }
7097    }
7098    state.ensure_proposal_run_turns(pending).await?;
7099    match log
7100        .append_critical_async(
7101            car_eventlog::EventKind::ProposalCompleted,
7102            None,
7103            Some(&pending.final_proposal_id),
7104            pending.event_data(),
7105            PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT,
7106        )
7107        .await
7108    {
7109        Ok(_) => {}
7110        Err(car_eventlog::CriticalAppendError::DurabilityUnknown { reason }) => {
7111            return Err(format!(
7112                "proposal finalization durability is unknown; retry the exact proposal.submit safely: {reason}"
7113            ));
7114        }
7115        Err(car_eventlog::CriticalAppendError::Rejected { reason }) => {
7116            return Err(format!(
7117                "proposal finalization critical journal append rejected: {reason}"
7118            ));
7119        }
7120    }
7121    if let Some(policy_session_id) = pending.policy_session_id.as_deref() {
7122        log.clear_policy_session(policy_session_id)?;
7123    }
7124    drop(log);
7125    let run_store = state.run_store.clone();
7126    let pending = pending.clone();
7127    let pending_run_id = pending.run_id.clone();
7128    let (value, cleanup_error) = run_lifecycle_durability_blocking(
7129        run_guard,
7130        "completed proposal response durability",
7131        move || {
7132            let receipt = run_store
7133                .write_completed_proposal(&pending)
7134                .map_err(|error| {
7135                    format!("completed proposal response persistence failed: {error}")
7136                })?;
7137            let cleanup_error = run_store
7138                .cleanup_completed_proposal_guards(&receipt)
7139                .err()
7140                .map(|error| error.to_string());
7141            let value = run_store
7142                .completed_proposal_response_value(&receipt)
7143                .map_err(|error| {
7144                    format!("completed proposal response serialization failed: {error}")
7145                })?;
7146            Ok((value, cleanup_error))
7147        },
7148    )
7149    .await?;
7150    if let Some(error) = cleanup_error {
7151        tracing::warn!(run_id = %pending_run_id, %error, "completed proposal response is durable; guard cleanup remains pending");
7152    }
7153    Ok(value)
7154}
7155
7156fn collect_accepted_proposal_preimages(
7157    original: &car_ir::ActionProposal,
7158    result: &car_ir::ProposalResult,
7159    proposal_events: &[car_eventlog::Event],
7160) -> Result<Vec<crate::run_store::AcceptedProposalPreimage>, String> {
7161    let mut accepted = Vec::new();
7162    for lineage in &result.replan_lineage {
7163        if lineage.status != car_ir::ProposalLineageStatus::Accepted {
7164            continue;
7165        }
7166        if lineage.generation == 0 {
7167            accepted.push(crate::run_store::AcceptedProposalPreimage {
7168                generation: 0,
7169                proposal: original.clone(),
7170            });
7171            continue;
7172        }
7173        let matches: Vec<_> = proposal_events
7174            .iter()
7175            .filter(|event| {
7176                event.kind == car_eventlog::EventKind::ReplanProposalReceived
7177                    && event.proposal_id.as_deref() == Some(result.original_proposal_id.as_str())
7178                    && event.data.get("attempt").and_then(Value::as_u64)
7179                        == Some(u64::from(lineage.generation))
7180                    && event.data.get("proposal_id").and_then(Value::as_str)
7181                        == Some(lineage.proposal_id.as_str())
7182                    && event.data.get("proposal_digest").and_then(Value::as_str)
7183                        == lineage.proposal_digest.as_deref()
7184            })
7185            .collect();
7186        if matches.len() != 1 {
7187            return Err(format!(
7188                "accepted replan generation {} has {} exact proposal preimage events",
7189                lineage.generation,
7190                matches.len()
7191            ));
7192        }
7193        let proposal = matches[0].data.get("proposal").cloned().ok_or_else(|| {
7194            format!(
7195                "accepted replan generation {} is missing its proposal preimage",
7196                lineage.generation
7197            )
7198        })?;
7199        accepted.push(crate::run_store::AcceptedProposalPreimage {
7200            generation: lineage.generation,
7201            proposal: serde_json::from_value(proposal).map_err(|error| {
7202                format!(
7203                    "accepted replan generation {} has invalid proposal preimage: {error}",
7204                    lineage.generation
7205                )
7206            })?,
7207        });
7208    }
7209    Ok(accepted)
7210}
7211
7212#[cfg(test)]
7213mod proposal_finalization_tests {
7214    use super::*;
7215
7216    #[test]
7217    fn accepted_replan_uses_exact_authenticated_event_preimage() {
7218        let timestamp = chrono::Utc::now();
7219        let original: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7220            "id": "original",
7221            "source": "caller",
7222            "actions": [],
7223            "timestamp": timestamp,
7224            "context": {}
7225        }))
7226        .unwrap();
7227        let final_proposal: car_ir::ActionProposal = serde_json::from_value(serde_json::json!({
7228            "id": "accepted-replan",
7229            "source": "replanner",
7230            "actions": [],
7231            "timestamp": timestamp,
7232            "context": {"generation": 1}
7233        }))
7234        .unwrap();
7235        let original_digest = canonical_sha256(&original).unwrap();
7236        let final_digest = canonical_sha256(&final_proposal).unwrap();
7237        let result = car_ir::ProposalResult {
7238            proposal_id: final_proposal.id.clone(),
7239            original_proposal_id: original.id.clone(),
7240            final_proposal: Some(final_proposal.clone()),
7241            accepted_proposal_preimages: vec![],
7242            replan_lineage: vec![
7243                car_ir::ProposalLineageEntry {
7244                    generation: 0,
7245                    proposal_id: original.id.clone(),
7246                    proposal_digest: Some(original_digest),
7247                    status: car_ir::ProposalLineageStatus::Accepted,
7248                    rejection_reason: None,
7249                },
7250                car_ir::ProposalLineageEntry {
7251                    generation: 1,
7252                    proposal_id: final_proposal.id.clone(),
7253                    proposal_digest: Some(final_digest.clone()),
7254                    status: car_ir::ProposalLineageStatus::Accepted,
7255                    rejection_reason: None,
7256                },
7257            ],
7258            results: vec![],
7259            cost: car_ir::CostSummary::default(),
7260        };
7261        let event = car_eventlog::Event {
7262            kind: car_eventlog::EventKind::ReplanProposalReceived,
7263            run_id: Some("run".to_string()),
7264            client_id: Some("client".to_string()),
7265            policy_session_id: None,
7266            action_id: None,
7267            proposal_id: Some(original.id.clone()),
7268            data: HashMap::from([
7269                ("attempt".to_string(), Value::from(1)),
7270                (
7271                    "proposal_id".to_string(),
7272                    Value::from(final_proposal.id.clone()),
7273                ),
7274                ("proposal_digest".to_string(), Value::from(final_digest)),
7275                (
7276                    "proposal".to_string(),
7277                    serde_json::to_value(&final_proposal).unwrap(),
7278                ),
7279            ]),
7280            timestamp,
7281            prev_hash: None,
7282            hash: None,
7283        };
7284
7285        let accepted = collect_accepted_proposal_preimages(&original, &result, &[event]).unwrap();
7286        assert_eq!(accepted.len(), 2);
7287        assert_eq!(accepted[0].generation, 0);
7288        assert_eq!(accepted[0].proposal, original);
7289        assert_eq!(accepted[1].generation, 1);
7290        assert_eq!(accepted[1].proposal, final_proposal);
7291    }
7292}
7293
7294/// Lowercase SHA-256 of RFC 8785/JCS. Proposal terminal events use the exact
7295/// serialized `ProposalResult`; run terminal identity uses the exact
7296/// `RunTermination` via `session::run_completion_digest`.
7297fn canonical_sha256<T: serde::Serialize + ?Sized>(value: &T) -> Result<String, String> {
7298    let canonical = car_inference::catalog_identity::canonical_json(value)?;
7299    Ok(format!("{:x}", Sha256::digest(canonical.as_bytes())))
7300}
7301
7302fn proposal_durability_quarantine(
7303    run_id: &str,
7304    state_kind: &str,
7305    error: &std::io::Error,
7306) -> String {
7307    format!(
7308        "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}"
7309    )
7310}
7311
7312/// Keep proposal-terminal journal uncertainty inside the handler deadline.
7313/// This matches the run lifecycle acknowledgement policy in `session.rs`.
7314const PROPOSAL_TERMINAL_ACKNOWLEDGEMENT_TIMEOUT: std::time::Duration =
7315    std::time::Duration::from_secs(5);
7316
7317async fn handle_session_policy_open(
7318    session: &crate::session::ClientSession,
7319) -> Result<Value, String> {
7320    let id = session.runtime.open_session().await;
7321    Ok(serde_json::json!({ "session_id": id }))
7322}
7323
7324async fn handle_session_policy_close(
7325    req: &JsonRpcMessage,
7326    session: &crate::session::ClientSession,
7327) -> Result<Value, String> {
7328    let sid = req
7329        .params
7330        .get("session_id")
7331        .and_then(|v| v.as_str())
7332        .ok_or("missing 'session_id'")?;
7333    let closed = session.runtime.close_session(sid).await;
7334    Ok(serde_json::json!({ "closed": closed }))
7335}
7336
7337/// `policy.register` — register one policy against this WebSocket
7338/// session's runtime. Mirrors the `PolicyDefinition` shape used by
7339/// `session.init`. When `session_id` is present, the policy is scoped
7340/// to the named in-runtime session opened via `session.policy.open`;
7341/// otherwise it is global.
7342async fn handle_policy_register(
7343    req: &JsonRpcMessage,
7344    session: &crate::session::ClientSession,
7345) -> Result<Value, String> {
7346    let def: PolicyDefinition = serde_json::from_value(req.params.clone())
7347        .map_err(|e| format!("invalid policy params: {e}"))?;
7348    let session_id = req
7349        .params
7350        .get("session_id")
7351        .and_then(|v| v.as_str())
7352        .map(str::to_string);
7353    let check = build_policy_check(&def)
7354        .ok_or_else(|| format!("unsupported policy rule '{}'", def.rule))?;
7355    let denies_tool = blanket_denied_tool(&def);
7356    match session_id {
7357        Some(sid) => {
7358            let registered = match &denies_tool {
7359                Some(tool) => {
7360                    session
7361                        .runtime
7362                        .register_tool_deny_in_session(&sid, &def.name, tool, check, "")
7363                        .await
7364                }
7365                None => {
7366                    session
7367                        .runtime
7368                        .register_policy_in_session(&sid, &def.name, check, "")
7369                        .await
7370                }
7371            };
7372            registered.map(
7373                |_| serde_json::json!({ "registered": def.name, "scope": { "session_id": sid } }),
7374            )
7375        }
7376        None => {
7377            let mut policies = session.runtime.policies.write().await;
7378            match &denies_tool {
7379                Some(tool) => policies.register_tool_deny(&def.name, tool, check, ""),
7380                None => policies.register(&def.name, check, ""),
7381            }
7382            Ok(serde_json::json!({ "registered": def.name, "scope": "global" }))
7383        }
7384    }
7385}
7386
7387/// `policy.unregister` — remove a policy by name. `{ name, session_id? }`.
7388///
7389/// Counterpart to `policy.register`, which had none: a global policy, once
7390/// registered over the wire, could only be cleared by restarting the daemon, so
7391/// a mistyped or over-broad rule stayed in force and took every other piece of
7392/// in-memory state with it when you finally bounced the process
7393/// (Parslee-ai/car#623). Session-scoped policies could already be dropped
7394/// wholesale by closing the session; this removes one by name.
7395///
7396/// Returns `{ unregistered, removed, scope }`. `removed: 0` means nothing
7397/// matched — reported rather than treated as an error, so a client cleaning up
7398/// can call this unconditionally.
7399async fn handle_policy_unregister(
7400    req: &JsonRpcMessage,
7401    session: &crate::session::ClientSession,
7402) -> Result<Value, String> {
7403    let name = req
7404        .params
7405        .get("name")
7406        .and_then(|v| v.as_str())
7407        .ok_or("missing 'name'")?;
7408    let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7409    let removed = session
7410        .runtime
7411        .unregister_policy(name, session_id)
7412        .await
7413        .map_err(|e| e.to_string())?;
7414    Ok(serde_json::json!({
7415        "unregistered": name,
7416        "removed": removed,
7417        "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7418            .unwrap_or_else(|| Value::String("global".to_string())),
7419    }))
7420}
7421
7422/// `policy.list` — what is currently in force. `{ session_id? }`.
7423///
7424/// Without this a client could register a policy but never ask what was
7425/// enforced, so an action rejection could not be explained beyond its own
7426/// message (Parslee-ai/car#623).
7427async fn handle_policy_list(
7428    req: &JsonRpcMessage,
7429    session: &crate::session::ClientSession,
7430) -> Result<Value, String> {
7431    let session_id = req.params.get("session_id").and_then(|v| v.as_str());
7432    let policies = session
7433        .runtime
7434        .list_policies(session_id)
7435        .await
7436        .map_err(|e| e.to_string())?;
7437    Ok(serde_json::json!({
7438        "policies": policies
7439            .into_iter()
7440            .map(|(name, description)| serde_json::json!({
7441                "name": name,
7442                "description": description,
7443            }))
7444            .collect::<Vec<_>>(),
7445        "scope": session_id.map(|s| serde_json::json!({ "session_id": s }))
7446            .unwrap_or_else(|| Value::String("global".to_string())),
7447    }))
7448}
7449
7450async fn handle_verify(
7451    req: &JsonRpcMessage,
7452    session: &crate::session::ClientSession,
7453) -> Result<Value, String> {
7454    let vr: VerifyRequest =
7455        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7456    // Verify against the full registered schemas so tool_call
7457    // parameters are checked for type mismatches + missing required
7458    // fields, not just tool existence (register_tool_schema's
7459    // documented contract; car-releases#56). The read guard is held
7460    // across the synchronous verify call.
7461    let tools_guard = session.runtime.tools.read().await;
7462    let result = car_verify::verify_with_schemas(
7463        &vr.proposal,
7464        Some(&vr.initial_state),
7465        Some(&tools_guard),
7466        30,
7467    );
7468    drop(tools_guard);
7469    let evidence = serde_json::to_value(&result.evidence).unwrap_or(Value::Null);
7470    serde_json::to_value(VerifyResponse {
7471        valid: result.valid,
7472        issues: result
7473            .issues
7474            .iter()
7475            .map(|i| VerifyIssueProto {
7476                action_id: i.action_id.clone(),
7477                severity: i.severity.clone(),
7478                message: i.message.clone(),
7479                tier: i.tier.as_str().to_string(),
7480            })
7481            .collect(),
7482        simulated_state: result.simulated_state,
7483        execution_levels: result.execution_levels,
7484        conflicts: result.conflicts,
7485        evidence,
7486    })
7487    .map_err(|e| e.to_string())
7488}
7489
7490/// Default recency window for trajectory-derived success rates, in days.
7491///
7492/// A success rate is a claim about how a tool behaves *now*. Unbounded history
7493/// makes it progressively less responsive to the recent change an operator is
7494/// usually trying to see, and keeps an outage that was fixed months ago
7495/// depressing the rate forever.
7496const MONTE_CARLO_RATE_WINDOW_DAYS: u32 = 30;
7497
7498/// Params for `verify.monte_carlo`.
7499///
7500/// `tool_success_rates` is optional. When omitted, rates are derived from the
7501/// daemon's trajectory store over the last `rate_window_days` — the point of
7502/// wiring that store into every session's `Runtime`. An explicitly supplied map
7503/// always wins: a caller modelling a hypothetical ("what if this tool were 99%
7504/// reliable?") must be able to override observed history, and per-tool entries
7505/// merge over the derived ones rather than replacing the whole map.
7506#[derive(Debug, Deserialize)]
7507struct MonteCarloParams {
7508    proposal: car_ir::ActionProposal,
7509    #[serde(default)]
7510    initial_state: HashMap<String, Value>,
7511    #[serde(default)]
7512    tool_success_rates: HashMap<String, f64>,
7513    #[serde(default)]
7514    goal: Option<car_verify::GoalCondition>,
7515    #[serde(default)]
7516    config: car_verify::MonteCarloConfig,
7517    /// Recency window for derived rates. Defaults to
7518    /// [`MONTE_CARLO_RATE_WINDOW_DAYS`]. Set `0` to skip derivation entirely
7519    /// and use only what the caller passed.
7520    #[serde(default)]
7521    rate_window_days: Option<u32>,
7522}
7523
7524async fn handle_verify_monte_carlo(
7525    req: &JsonRpcMessage,
7526    session: &crate::session::ClientSession,
7527) -> Result<Value, String> {
7528    let p: MonteCarloParams =
7529        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
7530
7531    let window = p.rate_window_days.unwrap_or(MONTE_CARLO_RATE_WINDOW_DAYS);
7532    let derived = if window > 0 {
7533        session.runtime.tool_feedback(window)
7534    } else {
7535        None
7536    };
7537
7538    // Caller-supplied entries win per tool, so a hypothetical can override one
7539    // tool's observed rate without discarding the evidence for the others.
7540    let mut rates = derived
7541        .as_ref()
7542        .map(|f| f.tool_success_rates.clone())
7543        .unwrap_or_default();
7544    let overridden: Vec<String> = p
7545        .tool_success_rates
7546        .keys()
7547        .filter(|t| rates.contains_key(*t))
7548        .cloned()
7549        .collect();
7550    rates.extend(p.tool_success_rates.iter().map(|(k, v)| (k.clone(), *v)));
7551
7552    let result = car_verify::simulate_monte_carlo(
7553        &p.proposal,
7554        Some(&p.initial_state),
7555        &rates,
7556        p.goal.as_ref(),
7557        &p.config,
7558    );
7559
7560    // Where every rate actually came from. A probability with no provenance is
7561    // not actionable — the caller cannot tell 0.9-from-500-observations from
7562    // 0.9-because-nothing-was-recorded, and those warrant very different
7563    // confidence in the verdict.
7564    let counts = derived
7565        .as_ref()
7566        .map(|f| f.tool_dispatch_counts.clone())
7567        .unwrap_or_default();
7568    let mut tools: Vec<Value> = p
7569        .proposal
7570        .actions
7571        .iter()
7572        .filter(|a| a.action_type == car_ir::ActionType::ToolCall)
7573        .filter_map(|a| a.tool.clone())
7574        .collect::<std::collections::BTreeSet<_>>()
7575        .into_iter()
7576        .map(|tool| {
7577            let caller_set = p.tool_success_rates.contains_key(&tool);
7578            let observed = counts.get(&tool).copied();
7579            let source = if caller_set {
7580                "caller"
7581            } else if observed.is_some() {
7582                "trajectories"
7583            } else {
7584                "default"
7585            };
7586            serde_json::json!({
7587                "tool": tool,
7588                "rate": rates.get(&tool).copied()
7589                    .unwrap_or(p.config.default_success_rate),
7590                "source": source,
7591                "succeeded": observed.map(|(s, _)| s),
7592                "dispatched": observed.map(|(_, d)| d),
7593            })
7594        })
7595        .collect();
7596    tools.sort_by(|a, b| a["tool"].as_str().cmp(&b["tool"].as_str()));
7597
7598    let mut out = serde_json::to_value(&result).map_err(|e| e.to_string())?;
7599    if let Some(obj) = out.as_object_mut() {
7600        obj.insert(
7601            "rate_provenance".to_string(),
7602            serde_json::json!({
7603                "window_days": window,
7604                "trajectories_available": derived.is_some(),
7605                "overridden_by_caller": overridden,
7606                "tools": tools,
7607            }),
7608        );
7609    }
7610    Ok(out)
7611}
7612
7613// --- sync.* / lease.* : multi-device sync + execution lease (B6) ---
7614//
7615// Thin param-parsers over the daemon-held `SyncSubsystem` (one per daemon = one
7616// device). Each locks the subsystem's tokio mutex and calls a `&mut self`
7617// method; the sync mechanics, fold, fence, and lease coordination all live in
7618// `crate::sync`. See `docs/proposals/multi-device-sync.md` §B6.
7619
7620async fn handle_sync_status(state: &ServerState) -> Result<Value, String> {
7621    let sub = state.sync_subsystem()?;
7622    let mut s = sub.lock().await;
7623    s.status()
7624}
7625
7626/// `sync.knowledge` — the read side of the assistant's synced memory. Returns
7627/// the folded knowledge facts (`{subject, body}`, ascending by hlc). With
7628/// `pump: true` it pumps-then-reads under ONE lock acquisition, so recall sees a
7629/// fresh pull with no interleave window; the pump is best-effort (a relay hiccup
7630/// still returns the already-folded facts). Read-only otherwise.
7631async fn handle_sync_knowledge(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7632    let pump = req
7633        .params
7634        .get("pump")
7635        .and_then(Value::as_bool)
7636        .unwrap_or(false);
7637    // Personal knowledge first; release its guard before touching the org one.
7638    let personal = {
7639        let sub = state.sync_subsystem()?;
7640        let mut s = sub.lock().await;
7641        if pump {
7642            let _ = s.pump();
7643        }
7644        s.knowledge()
7645    };
7646    // Merge the shared org subsystem's knowledge when opted in (8b): personal ∪ org.
7647    // With org-scope OFF, `org_subsystem()` is None → personal-only, byte-identical.
7648    let org = match state.org_subsystem() {
7649        Some(org_sub) => {
7650            let mut s = org_sub.lock().await;
7651            if pump {
7652                let _ = s.pump();
7653            }
7654            Some(s.knowledge())
7655        }
7656        None => None,
7657    };
7658    Ok(serde_json::json!({ "facts": merge_knowledge(personal, org) }))
7659}
7660
7661/// Merge personal knowledge with the opted-in org knowledge (personal ∪ org).
7662/// `None` org → personal unchanged (the org-scope-off invariant). Pure, so it is
7663/// unit-testable without a daemon.
7664///
7665/// ORDER IS LOAD-BEARING (linus): each scope's `knowledge()` is ascending by
7666/// `(hlc, op_id)`, but the two are NOT globally HLC-ordered, and the recall
7667/// reducer (`car-cli reduce_newest_per_subject`) is POSITIONAL last-wins — it
7668/// keeps the LAST occurrence per subject, trusting ascending order. So this
7669/// concatenates ORG FIRST, PERSONAL LAST: within a scope newest still wins, and
7670/// for a subject present in BOTH scopes the personal entry lands last → PERSONAL
7671/// wins. That is deliberate — it matches the recall path's Option-B policy
7672/// ("a peer's newer edit to a locally-held subject is intentionally NOT applied";
7673/// personal/local is authoritative). Do NOT flip to personal-first (that would
7674/// silently make a staler org fact shadow a newer personal one).
7675fn merge_knowledge(personal: Vec<Value>, org: Option<Vec<Value>>) -> Vec<Value> {
7676    match org {
7677        Some(mut org) => {
7678            org.extend(personal);
7679            org
7680        }
7681        None => personal,
7682    }
7683}
7684
7685async fn handle_sync_append(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7686    let surface_str = req
7687        .params
7688        .get("surface")
7689        .and_then(Value::as_str)
7690        .ok_or("missing 'surface'")?;
7691    let surface = crate::sync::parse_surface(surface_str)?;
7692    let payload = req.params.get("payload").cloned().unwrap_or(Value::Null);
7693    let scope = crate::sync::parse_scope(&req.params);
7694    let sub = state.subsystem_for_scope(&scope)?;
7695    let mut s = sub.lock().await;
7696    s.append(scope, surface, payload)
7697}
7698
7699async fn handle_sync_record_turn(
7700    req: &JsonRpcMessage,
7701    state: &ServerState,
7702) -> Result<Value, String> {
7703    let conversation_id = req
7704        .params
7705        .get("conversation_id")
7706        .and_then(Value::as_str)
7707        .unwrap_or("");
7708    let role = req
7709        .params
7710        .get("role")
7711        .and_then(Value::as_str)
7712        .unwrap_or("user");
7713    let content = req
7714        .params
7715        .get("content")
7716        .and_then(Value::as_str)
7717        .unwrap_or("");
7718    let tool_calls = req
7719        .params
7720        .get("tool_calls")
7721        .and_then(Value::as_array)
7722        .cloned()
7723        .unwrap_or_default();
7724    let tool_use_id = req.params.get("tool_use_id").and_then(Value::as_str);
7725    let timestamp = req
7726        .params
7727        .get("timestamp")
7728        .and_then(Value::as_u64)
7729        .unwrap_or(0);
7730    let scope = crate::sync::parse_scope(&req.params);
7731    let sub = state.subsystem_for_scope(&scope)?;
7732    let mut s = sub.lock().await;
7733    s.record_turn(
7734        scope,
7735        conversation_id,
7736        role,
7737        content,
7738        tool_calls,
7739        tool_use_id,
7740        timestamp,
7741    )
7742}
7743
7744async fn handle_sync_record_intent(
7745    req: &JsonRpcMessage,
7746    state: &ServerState,
7747    session: &crate::session::ClientSession,
7748) -> Result<Value, String> {
7749    let agent_id = req
7750        .params
7751        .get("agent_id")
7752        .and_then(Value::as_str)
7753        .ok_or("missing 'agent_id'")?;
7754    require_sync_agent_or_host(session, state, "sync.record_intent", agent_id).await?;
7755    record_sync_intent(req, state, agent_id).await
7756}
7757
7758async fn record_sync_intent(
7759    req: &JsonRpcMessage,
7760    state: &ServerState,
7761    agent_id: &str,
7762) -> Result<Value, String> {
7763    let run_id = req
7764        .params
7765        .get("run_id")
7766        .and_then(Value::as_str)
7767        .ok_or("missing 'run_id'")?;
7768    let epoch = req
7769        .params
7770        .get("epoch")
7771        .and_then(Value::as_u64)
7772        .ok_or("missing 'epoch'")?;
7773    let status_str = req
7774        .params
7775        .get("status")
7776        .and_then(Value::as_str)
7777        .ok_or("missing 'status'")?;
7778    let status = crate::sync::parse_intent_status(status_str)?;
7779    let intent = car_sync::Intent::new(agent_id, run_id, epoch, status);
7780    let scope = crate::sync::parse_scope(&req.params);
7781    let sub = state.subsystem_for_scope(&scope)?;
7782    let mut s = sub.lock().await;
7783    s.record_intent(scope, &intent)
7784}
7785
7786async fn handle_sync_pump(state: &ServerState) -> Result<Value, String> {
7787    // Pump the personal subsystem first and RELEASE its guard before touching the
7788    // org one — never hold both `tokio::Mutex` subsystem guards across an await
7789    // (deadlock vector). A personal-pump failure is fatal, as before.
7790    let user_result = {
7791        let sub = state.sync_subsystem()?;
7792        let mut s = sub.lock().await;
7793        s.pump()?
7794    };
7795    // Then the shared org subsystem, if opted in (8a). Its leases are independent
7796    // of the personal scope, so there is no cross-scope ordering requirement. An
7797    // org-pump failure must NOT suppress the personal-pump result — surface both.
7798    let Some(org_sub) = state.org_subsystem() else {
7799        return Ok(user_result);
7800    };
7801    let org_result = {
7802        let mut s = org_sub.lock().await;
7803        s.pump()
7804    };
7805    match org_result {
7806        Ok(org) => Ok(serde_json::json!({ "user": user_result, "org": org })),
7807        Err(e) => Ok(serde_json::json!({ "user": user_result, "org_error": e })),
7808    }
7809}
7810
7811async fn handle_sync_checkpoint(state: &ServerState) -> Result<Value, String> {
7812    let sub = state.sync_subsystem()?;
7813    let mut s = sub.lock().await;
7814    s.checkpoint()
7815}
7816
7817async fn handle_sync_rebase(state: &ServerState) -> Result<Value, String> {
7818    let sub = state.sync_subsystem()?;
7819    let mut s = sub.lock().await;
7820    s.rebase()
7821}
7822
7823async fn handle_sync_transcript(
7824    req: &JsonRpcMessage,
7825    state: &ServerState,
7826) -> Result<Value, String> {
7827    let conversation_id = req
7828        .params
7829        .get("conversation_id")
7830        .and_then(Value::as_str)
7831        .unwrap_or("");
7832    let sub = state.sync_subsystem()?;
7833    let s = sub.lock().await;
7834    Ok(s.transcript(conversation_id))
7835}
7836
7837async fn handle_sync_resume(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
7838    let conversation_id = req
7839        .params
7840        .get("conversation_id")
7841        .and_then(Value::as_str)
7842        .unwrap_or("");
7843    let sub = state.sync_subsystem()?;
7844    let s = sub.lock().await;
7845    s.resume(conversation_id)
7846}
7847
7848async fn handle_sync_assistant_checkpoint_put(
7849    req: &JsonRpcMessage,
7850    state: &ServerState,
7851) -> Result<Value, String> {
7852    let raw = req
7853        .params
7854        .get("checkpoint")
7855        .cloned()
7856        .ok_or("missing 'checkpoint'")?;
7857    let checkpoint =
7858        serde_json::from_value(raw).map_err(|e| format!("invalid assistant checkpoint: {e}"))?;
7859    let sub = state.sync_subsystem()?;
7860    let mut s = sub.lock().await;
7861    s.assistant_checkpoint_put(checkpoint)
7862}
7863
7864async fn handle_sync_assistant_checkpoint_get(
7865    req: &JsonRpcMessage,
7866    state: &ServerState,
7867) -> Result<Value, String> {
7868    let session_id = req
7869        .params
7870        .get("session_id")
7871        .and_then(Value::as_str)
7872        .ok_or("missing 'session_id'")?;
7873    let sub = state.sync_subsystem()?;
7874    let s = sub.lock().await;
7875    serde_json::to_value(s.assistant_checkpoint_get(session_id)?)
7876        .map_err(|e| format!("serialize assistant checkpoint: {e}"))
7877}
7878
7879async fn handle_sync_assistant_action_put(
7880    req: &JsonRpcMessage,
7881    state: &ServerState,
7882) -> Result<Value, String> {
7883    let raw = req
7884        .params
7885        .get("record")
7886        .cloned()
7887        .ok_or("missing 'record'")?;
7888    let record = serde_json::from_value(raw)
7889        .map_err(|e| format!("invalid supervised action record: {e}"))?;
7890    let sub = state.sync_subsystem()?;
7891    let mut s = sub.lock().await;
7892    s.assistant_action_put(record)
7893}
7894
7895async fn handle_sync_assistant_action_get(
7896    req: &JsonRpcMessage,
7897    state: &ServerState,
7898) -> Result<Value, String> {
7899    let action_id = req
7900        .params
7901        .get("action_id")
7902        .and_then(Value::as_str)
7903        .ok_or("missing 'action_id'")?;
7904    let sub = state.sync_subsystem()?;
7905    let s = sub.lock().await;
7906    serde_json::to_value(s.assistant_action_get(action_id)?)
7907        .map_err(|e| format!("serialize supervised action record: {e}"))
7908}
7909
7910async fn handle_sync_fence_check(
7911    req: &JsonRpcMessage,
7912    state: &ServerState,
7913    session: &crate::session::ClientSession,
7914) -> Result<Value, String> {
7915    let agent_id = req
7916        .params
7917        .get("agent_id")
7918        .and_then(Value::as_str)
7919        .ok_or("missing 'agent_id'")?;
7920    require_sync_agent_or_host(session, state, "sync.fence_check", agent_id).await?;
7921    check_sync_fence(req, state, agent_id).await
7922}
7923
7924async fn check_sync_fence(
7925    req: &JsonRpcMessage,
7926    state: &ServerState,
7927    agent_id: &str,
7928) -> Result<Value, String> {
7929    let run_id = req
7930        .params
7931        .get("run_id")
7932        .and_then(Value::as_str)
7933        .ok_or("missing 'run_id'")?;
7934    let epoch = req
7935        .params
7936        .get("epoch")
7937        .and_then(Value::as_u64)
7938        .ok_or("missing 'epoch'")?;
7939    let sub = state.sync_subsystem()?;
7940    let mut s = sub.lock().await;
7941    s.fence_check(agent_id, run_id, epoch)
7942}
7943
7944async fn handle_lease_acquire(
7945    req: &JsonRpcMessage,
7946    state: &ServerState,
7947    session: &crate::session::ClientSession,
7948) -> Result<Value, String> {
7949    let agent_id = req
7950        .params
7951        .get("agent_id")
7952        .and_then(Value::as_str)
7953        .ok_or("missing 'agent_id'")?;
7954    require_sync_agent_or_host(session, state, "lease.acquire", agent_id).await?;
7955    acquire_lease(req, state, agent_id).await
7956}
7957
7958async fn acquire_lease(
7959    req: &JsonRpcMessage,
7960    state: &ServerState,
7961    agent_id: &str,
7962) -> Result<Value, String> {
7963    let ttl_ms = req
7964        .params
7965        .get("ttl_ms")
7966        .and_then(Value::as_u64)
7967        .unwrap_or(30_000);
7968    let sub = state.sync_subsystem()?;
7969    let mut s = sub.lock().await;
7970    s.lease_acquire(agent_id, ttl_ms)
7971}
7972
7973async fn handle_lease_renew(
7974    req: &JsonRpcMessage,
7975    state: &ServerState,
7976    session: &crate::session::ClientSession,
7977) -> Result<Value, String> {
7978    let agent_id = req
7979        .params
7980        .get("agent_id")
7981        .and_then(Value::as_str)
7982        .ok_or("missing 'agent_id'")?;
7983    require_sync_agent_or_host(session, state, "lease.renew", agent_id).await?;
7984    renew_lease(req, state, agent_id).await
7985}
7986
7987async fn renew_lease(
7988    req: &JsonRpcMessage,
7989    state: &ServerState,
7990    agent_id: &str,
7991) -> Result<Value, String> {
7992    let epoch = req
7993        .params
7994        .get("epoch")
7995        .and_then(Value::as_u64)
7996        .ok_or("missing 'epoch'")?;
7997    let ttl_ms = req
7998        .params
7999        .get("ttl_ms")
8000        .and_then(Value::as_u64)
8001        .unwrap_or(30_000);
8002    let sub = state.sync_subsystem()?;
8003    let mut s = sub.lock().await;
8004    s.lease_renew(agent_id, epoch, ttl_ms)
8005}
8006
8007async fn handle_lease_release(
8008    req: &JsonRpcMessage,
8009    state: &ServerState,
8010    session: &crate::session::ClientSession,
8011) -> Result<Value, String> {
8012    let agent_id = req
8013        .params
8014        .get("agent_id")
8015        .and_then(Value::as_str)
8016        .ok_or("missing 'agent_id'")?;
8017    require_sync_agent_or_host(session, state, "lease.release", agent_id).await?;
8018    release_lease(req, state, agent_id).await
8019}
8020
8021async fn release_lease(
8022    req: &JsonRpcMessage,
8023    state: &ServerState,
8024    agent_id: &str,
8025) -> Result<Value, String> {
8026    let epoch = req
8027        .params
8028        .get("epoch")
8029        .and_then(Value::as_u64)
8030        .ok_or("missing 'epoch'")?;
8031    let sub = state.sync_subsystem()?;
8032    let mut s = sub.lock().await;
8033    s.lease_release(agent_id, epoch)
8034}
8035
8036async fn handle_lease_status(
8037    req: &JsonRpcMessage,
8038    state: &ServerState,
8039    bound_agent: Option<&str>,
8040) -> Result<Value, String> {
8041    let agent_id = req
8042        .params
8043        .get("agent_id")
8044        .and_then(Value::as_str)
8045        .ok_or("missing 'agent_id'")?;
8046    // Reading a lease is the same identity parameter as taking one, and the
8047    // answer names the holder and its fencing epoch — everything needed to
8048    // decide when to try stealing it.
8049    require_own_agent(bound_agent, "lease.status", agent_id)?;
8050    let sub = state.sync_subsystem()?;
8051    let mut s = sub.lock().await;
8052    s.lease_status(agent_id)
8053}
8054
8055/// Host-only, aggregate process-lifetime secret-store activity. The public
8056/// value is intentionally the exact five-counter struct from `car-secrets`:
8057/// no service, key, path, value, identity, or credential state is available to
8058/// serialize here.
8059fn handle_secret_store_activity(
8060    session: &crate::session::ClientSession,
8061    state: &ServerState,
8062) -> Result<Value, String> {
8063    require_approval_authority(session, state)?;
8064    serde_json::to_value(car_secrets::secret_store_activity()).map_err(|error| error.to_string())
8065}
8066
8067// --- permission.* : per-session permission-tier gate (survey §3.4.3/§5.2.5) ---
8068
8069// The canonical value lives in the leaf `car-proto` crate so the lightweight
8070// daemon client can consume it without creating car-server-core →
8071// car-ffi-common → car-daemon-client → car-server-core. Re-exporting it beside
8072// the authority gate makes this the daemon's public authorization contract.
8073pub use car_proto::HOST_MANAGEMENT_METHODS;
8074
8075/// Authority gate for the *mutating* permission methods (set_tier, approve,
8076/// reject). When the daemon runs under a host token (the CarHost regime,
8077/// where there is a distinguished human-management client), only that host
8078/// connection may change a session's tier or record approvals — a
8079/// registered agent connection must not self-elevate or self-approve, which
8080/// would make the §5.2.5 gate theater (neo review #3). In pure-dev /
8081/// embedder mode (no host token configured) the connection governs its own
8082/// session, the developer being the authority.
8083fn require_approval_authority(
8084    session: &crate::session::ClientSession,
8085    state: &ServerState,
8086) -> Result<(), String> {
8087    if state.host_token.get().is_some()
8088        && !session.is_host.load(std::sync::atomic::Ordering::Acquire)
8089    {
8090        return Err("this operation requires the host-management role".into());
8091    }
8092    Ok(())
8093}
8094
8095/// Refuse a bound session acting on an agent that is not itself.
8096///
8097/// Whether a caller-supplied agent id is one this session may act as.
8098/// The bound identity WINS over the parameter — the rule `runs.start` already
8099/// applies to trace attribution (FIX 5). A session that authenticated as an
8100/// agent is authoritative about who it is, so an id naming a *different* agent
8101/// is a forgery attempt and is rejected loudly rather than honored. A matching
8102/// id is redundant and fine.
8103///
8104/// An UNBOUND session passes this identity-only helper. It remains appropriate
8105/// for `agents.wait`/`tail_log` and the lease/sync surfaces, where an unbound
8106/// operator may act on an explicit id. Mutating lifecycle methods use the
8107/// stronger host-aware helpers below: start/stop/restart admit self or host,
8108/// while upsert/install/remove require host authority.
8109///
8110/// Takes the RESOLVED bound id rather than the session: it is the whole input
8111/// the rule needs, and a `ClientSession` cannot be built outside a live
8112/// connection, so this keeps every caller exercisable.
8113///
8114/// Naming the id back is safe here, unlike [`authorize_run_access`]'s FIX 3 —
8115/// the caller supplied it, and the refusal happens before any lookup, so it
8116/// reveals nothing about whether that agent exists.
8117fn require_own_agent(bound: Option<&str>, method: &str, target: &str) -> Result<(), String> {
8118    match bound {
8119        Some(b) if b != target => Err(format!(
8120            "{method} `{target}` does not match this session's bound agent: a supervised \
8121             agent may only act on itself"
8122        )),
8123        _ => Ok(()),
8124    }
8125}
8126
8127/// Start, stop, or restart is available to the bound agent itself and to the
8128/// host authority. An unbound daemon-token client is accepted only in
8129/// dev/embedder mode, where no host token exists and `require_approval_authority`
8130/// deliberately treats the developer as the authority.
8131async fn require_own_agent_or_host(
8132    session: &crate::session::ClientSession,
8133    state: &ServerState,
8134    method: &str,
8135    target: &str,
8136) -> Result<(), String> {
8137    if session.is_host.load(Ordering::Acquire) {
8138        return Ok(());
8139    }
8140    let bound = session.agent_id.lock().await.clone();
8141    match bound.as_deref() {
8142        Some(_) => require_own_agent(bound.as_deref(), method, target),
8143        None if state.host_token.get().is_some() => {
8144            Err("this operation requires the host-management role".into())
8145        }
8146        None => Ok(()),
8147    }
8148}
8149
8150/// Authorize a lease/sync mutation under the connection's actual identity.
8151/// The identity and host rules intentionally match agent lifecycle mutations.
8152async fn require_sync_agent_or_host(
8153    session: &crate::session::ClientSession,
8154    state: &ServerState,
8155    method: &str,
8156    target: &str,
8157) -> Result<(), String> {
8158    require_own_agent_or_host(session, state, method, target).await
8159}
8160
8161/// Agent-definition and removal methods belong to host authority. Preserve
8162/// auth-disabled development mode for unbound callers, but never let a bound
8163/// supervised agent inherit that degraded-mode authority.
8164pub(crate) async fn require_host_lifecycle_authority(
8165    session: &crate::session::ClientSession,
8166    state: &ServerState,
8167) -> Result<(), String> {
8168    require_approval_authority(session, state)?;
8169    if session.agent_id.lock().await.is_some() && !session.is_host.load(Ordering::Acquire) {
8170        return Err("this operation requires the host-management role".into());
8171    }
8172    Ok(())
8173}
8174
8175/// Stronger boundary for daemon-wide agent policy. Even in auth-disabled
8176/// embedder mode, an authenticated lifecycle agent may not mutate or inspect
8177/// host-only exact grants for itself. Development clients remain compatible;
8178/// they are unbound and continue to govern their own daemon when no host token
8179/// is configured.
8180/// Gate for `agent_permissions.*`: approval authority, and not a bound agent.
8181///
8182/// **The second half is escapable and the first half is conditional — read both
8183/// before trusting this.** An agent controls its own environment, so it can
8184/// unset `CAR_AGENT_ID` and reconnect unbound (car#1297). What stops it there is
8185/// [`require_approval_authority`], which only bites when a host token exists:
8186/// on an ordinary desktop install CarHost holds one, `is_host` fails for the
8187/// agent, and the gate holds — `host_token` is a separate `0600` file never
8188/// served over `GET /auth-token`.
8189///
8190/// On a daemon with NO host token — headless, CI, `car-server` started directly
8191/// — approval authority passes for every authenticated client, and unbinding is
8192/// sufficient to reach this surface. That is tracked separately; see the
8193/// per-gate audit in `docs/proposals/agent-binding-trust-boundary.md`.
8194async fn require_agent_permissions_authority(
8195    session: &crate::session::ClientSession,
8196    state: &ServerState,
8197) -> Result<(), String> {
8198    require_approval_authority(session, state)?;
8199    if session.agent_id.lock().await.is_some() {
8200        return Err("this operation requires the host-management role".into());
8201    }
8202    Ok(())
8203}
8204
8205/// Resolve and freeze the callback identity currently attached to `agent_id`.
8206/// The returned lifecycle guard stays held through the policy write, so a
8207/// concurrent `tools.register` / `tools.unregister` / proposal cannot change
8208/// the schema between observation and persistence.
8209async fn live_agent_callback_registration(
8210    state: &ServerState,
8211    agent_id: &str,
8212    tool: &str,
8213) -> Result<
8214    (
8215        Arc<crate::session::ClientSession>,
8216        tokio::sync::OwnedMutexGuard<()>,
8217        String,
8218    ),
8219    String,
8220> {
8221    let client_id = state
8222        .attached_agents
8223        .lock()
8224        .await
8225        .get(agent_id)
8226        .cloned()
8227        .ok_or_else(|| format!("agent '{agent_id}' is not attached"))?;
8228    let target = state
8229        .sessions
8230        .lock()
8231        .await
8232        .get(&client_id)
8233        .cloned()
8234        .ok_or_else(|| format!("attached agent '{agent_id}' has no live session"))?;
8235    let guard = target.run_lifecycle_guard.clone().lock_owned().await;
8236
8237    let still_attached = state
8238        .attached_agents
8239        .lock()
8240        .await
8241        .get(agent_id)
8242        .is_some_and(|current| current == &target.client_id);
8243    let bound_agent = target.agent_id.lock().await.clone();
8244    if !still_attached
8245        || !target
8246            .authenticated
8247            .load(std::sync::atomic::Ordering::Acquire)
8248        || bound_agent.as_deref() != Some(agent_id)
8249    {
8250        return Err(format!(
8251            "agent '{agent_id}' callback session changed while authorizing tool '{tool}'"
8252        ));
8253    }
8254    if target.runtime.registry.get(tool).await.is_some() {
8255        return Err(format!(
8256            "tool '{tool}' is server-owned and is ineligible for agent standing authority"
8257        ));
8258    }
8259    let digest = target
8260        .callback_tool_schema_digests
8261        .read()
8262        .await
8263        .get(tool)
8264        .cloned()
8265        .ok_or_else(|| {
8266            format!(
8267                "tool '{tool}' has no eligible exact reverse-callback registration for agent '{agent_id}'"
8268            )
8269        })?;
8270    Ok((target, guard, digest))
8271}
8272
8273async fn handle_agent_permission_set_tool(
8274    req: &JsonRpcMessage,
8275    state: &ServerState,
8276) -> Result<Value, String> {
8277    let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8278    let mode = crate::agent_permissions::requested_tool_mode(&req.params)?;
8279    if mode == car_policy::ApprovalMode::AlwaysAllow {
8280        let (_target, _guard, digest) =
8281            live_agent_callback_registration(state, agent_id, tool).await?;
8282        crate::agent_permissions::handle_set_tool(req, Some(digest))
8283    } else {
8284        crate::agent_permissions::handle_set_tool(req, None)
8285    }
8286}
8287
8288async fn handle_agent_permission_evaluate_tool(
8289    req: &JsonRpcMessage,
8290    state: &ServerState,
8291) -> Result<Value, String> {
8292    let (agent_id, tool) = crate::agent_permissions::exact_tool_params(&req.params)?;
8293    let mut value = crate::agent_permissions::handle_evaluate_tool(req)?;
8294    let has_override = value
8295        .get("has_tool_override")
8296        .and_then(Value::as_bool)
8297        .unwrap_or(false);
8298    let mode = value.get("mode").and_then(Value::as_str);
8299    let stored_digest = value.get("schema_digest").and_then(Value::as_str);
8300    let live_digest = if mode == Some("always_allow") && has_override {
8301        live_agent_callback_registration(state, agent_id, tool)
8302            .await
8303            .ok()
8304            .map(|(_target, _guard, digest)| digest)
8305    } else {
8306        None
8307    };
8308    let schema_bound = stored_digest.is_some();
8309    let active = if mode == Some("always_allow") {
8310        stored_digest
8311            .zip(live_digest.as_deref())
8312            .is_some_and(|(stored, live)| stored == live)
8313    } else {
8314        has_override
8315    };
8316    let object = value
8317        .as_object_mut()
8318        .ok_or_else(|| "agent permission evaluation returned a non-object".to_string())?;
8319    object.insert("active".into(), active.into());
8320    object.insert("schema_bound".into(), schema_bound.into());
8321    object.insert("schema_digest_present".into(), schema_bound.into());
8322    Ok(value)
8323}
8324
8325/// The authenticated principal for this connection, stamped into approval
8326/// audit records server-side so the "who decided" field can't be forged by
8327/// the caller (neo review #5c). Prefers the bound agent identity, else the
8328/// connection id.
8329/// Public wrapper over [`session_principal`] for the peer-messaging module.
8330///
8331/// Exposed rather than duplicated: the whole value of the principal is that
8332/// exactly one place derives it, server-side, from the bound agent identity.
8333pub async fn session_principal_for_peers(session: &crate::session::ClientSession) -> String {
8334    session_principal(session).await
8335}
8336
8337async fn session_principal(session: &crate::session::ClientSession) -> String {
8338    if let Some(agent) = session.agent_id.lock().await.clone() {
8339        format!("agent:{agent}")
8340    } else {
8341        format!("conn:{}", session.client_id)
8342    }
8343}
8344
8345/// Return the session's current granted standing tier.
8346async fn handle_permission_get_tier(
8347    session: &crate::session::ClientSession,
8348) -> Result<Value, String> {
8349    let tier = session.permission_gate.read().await.granted_tier();
8350    Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8351}
8352
8353/// Set the session's granted standing tier. Params: `{ tier }`.
8354async fn handle_permission_set_tier(
8355    req: &JsonRpcMessage,
8356    session: &crate::session::ClientSession,
8357    state: &ServerState,
8358) -> Result<Value, String> {
8359    require_approval_authority(session, state)?;
8360    let tier_str = req
8361        .params
8362        .get("tier")
8363        .and_then(|v| v.as_str())
8364        .ok_or("missing 'tier'")?;
8365    let tier = car_policy::PermissionTier::from_str_opt(tier_str)
8366        .ok_or_else(|| format!("invalid tier '{tier_str}'"))?;
8367    session.permission_gate.write().await.set_granted_tier(tier);
8368    Ok(serde_json::json!({ "granted_tier": tier.as_str() }))
8369}
8370
8371// --- iMessage approval-transport config surface (`messaging.*`) ---
8372//
8373// The host/local-auth-gated config channel for the iMessage approval
8374// transport (Unit 3). These handlers are the ONLY allowlist/config-mutation
8375// path in the system. Every one of them rejects a caller that is not
8376// `session.is_host` and is not presenting the per-launch local auth token —
8377// the SAME trust root (`require_approval_authority`) that guards all config
8378// mutation today (permission-tier / approval changes). An inbound iMessage
8379// carries neither credential, so there is no inbound→config edge anywhere.
8380//
8381// DEGRADED-MODE CAVEAT (inherited platform behavior, NOT specific to this
8382// surface): `require_approval_authority` is a no-op when NO host token is
8383// configured (pure-dev / `--no-auth` / a failed boot token-write) — in that
8384// regime the connection governs its own session, the developer being the
8385// authority, so these `messaging.*` methods become callable by any local
8386// connection. This is identical to how `permission.*` (set_tier/approve/reject)
8387// already behaves and is the deliberate platform gate pattern; we do NOT
8388// diverge from it here. The core anti-injection invariant still holds
8389// regardless: an inbound iMessage carries no WS session at all, so even in the
8390// fail-open regime it can never reach these setters — the degraded mode only
8391// relaxes WHICH local connections are trusted, never opens an inbound→config
8392// edge.
8393//
8394// The store is constructed per-call from the CAR state root (the same pattern
8395// `models.register` uses for its per-call `models.json`), keeping these
8396// handlers state-free and side-effect-local.
8397
8398/// Build the durable messaging config store rooted at the daemon's state root.
8399/// `from_home` resolves it through `car_home` — `CAR_HOME` when set, otherwise
8400/// `~/.car` from `HOME`/`USERPROFILE`.
8401fn messaging_store() -> crate::messaging_config::MessagingConfigStore {
8402    crate::messaging_config::MessagingConfigStore::from_home()
8403}
8404
8405/// Resolve the optional `channel` string from a request's params to a
8406/// [`ChannelId`]. **Absent ⇒ iMessage** (back-compat for the #403 surface and
8407/// the bindings, which have no `channel` field). An unrecognized channel string
8408/// is an error rather than a silent default (fail-closed on a typo). Reads the
8409/// top-level `channel` field directly so it works for both the `set` request
8410/// (which has a typed field) and the param-light `get`/`pairing` methods.
8411fn messaging_channel_from_params(
8412    params: &Value,
8413) -> Result<crate::messaging_config::ChannelId, String> {
8414    match params.get("channel") {
8415        None | Some(Value::Null) => Ok(crate::messaging_config::ChannelId::IMessage),
8416        Some(Value::String(s)) => crate::messaging_config::ChannelId::from_str_opt(s)
8417            .ok_or_else(|| format!("unknown messaging channel '{s}'")),
8418        Some(other) => Err(format!("messaging channel must be a string, got {other}")),
8419    }
8420}
8421
8422/// Project a `MessagingConfig` load into the wire view for `channel` (without
8423/// leaking the active pairing code — that is `messaging.pairing.status` only).
8424///
8425/// Per-channel since Unit 2; an absent `channel` in the request defaults to
8426/// iMessage so the existing 4 methods and their bindings keep working unchanged.
8427fn messaging_config_view(
8428    store: &crate::messaging_config::MessagingConfigStore,
8429    channel: crate::messaging_config::ChannelId,
8430) -> Result<car_proto::MessagingConfigView, String> {
8431    let cfg = store.load()?.channel(channel);
8432    Ok(car_proto::MessagingConfigView {
8433        channel: channel.as_str().to_string(),
8434        enabled: cfg.enabled,
8435        allowlisted_handles: cfg.allowlisted_handles,
8436        pairing_active: cfg.active_pairing_code.is_some(),
8437    })
8438}
8439
8440/// `messaging.config.get` — return the current transport config view for the
8441/// (optional) channel (default iMessage). Host/local-auth gated (a read of the
8442/// trust state is host-only).
8443async fn handle_messaging_config_get(
8444    req: &JsonRpcMessage,
8445    session: &crate::session::ClientSession,
8446    state: &ServerState,
8447) -> Result<Value, String> {
8448    require_approval_authority(session, state)?;
8449    let channel = messaging_channel_from_params(&req.params)?;
8450    let view = messaging_config_view(&messaging_store(), channel)?;
8451    serde_json::to_value(view).map_err(|e| e.to_string())
8452}
8453
8454/// `messaging.config.set` — mutate the transport config (enabled flag and
8455/// allowlist) for the (optional) channel (default iMessage). Host/local-auth
8456/// gated; this is the ONLY allowlist/config mutation path. Params:
8457/// `MessagingConfigSetRequest`.
8458async fn handle_messaging_config_set(
8459    req: &JsonRpcMessage,
8460    session: &crate::session::ClientSession,
8461    state: &ServerState,
8462) -> Result<Value, String> {
8463    require_approval_authority(session, state)?;
8464    let set: car_proto::MessagingConfigSetRequest = serde_json::from_value(req.params.clone())
8465        .map_err(|e| format!("invalid messaging.config.set params: {e}"))?;
8466    // Resolve the channel through the shared `messaging_channel_from_params`
8467    // (used by all four messaging handlers) rather than `set.channel`: the
8468    // resolver is the single place that maps absent ⇒ iMessage AND rejects an
8469    // unknown/non-string `channel` with a clear error. `set.channel` stays a
8470    // documented part of the request shape (it is what the absent-⇒-iMessage
8471    // default is named after) but is not re-parsed here.
8472    let channel = messaging_channel_from_params(&req.params)?;
8473    let store = messaging_store();
8474    // Slack token provisioning (MC-9 + MC-6). The `require_approval_authority`
8475    // host-gate above has ALREADY fired (provisioning is host-only — an inbound
8476    // message carries no WS session, so it can never reach here). When BOTH
8477    // `bot_token` + `app_token` are present on the SLACK channel, write the
8478    // bearer values to the OS keychain via `provision_slack_tokens` and persist
8479    // ONLY the returned keychain key-REFERENCES into the config — the bearer
8480    // values never touch `messaging.json` and never echo back in the view.
8481    // Both-or-neither: a single token present (or the wrong channel) is ignored
8482    // here so the normal enable/allowlist set stays back-compatible.
8483    if let (Some(bot), Some(app)) = (set.bot_token.as_deref(), set.app_token.as_deref()) {
8484        if channel == crate::messaging_config::ChannelId::Slack {
8485            let secrets = car_secrets::SecretStore::new();
8486            let refs = crate::slack_adapter::provision_slack_tokens(&secrets, bot, app)
8487                .map_err(|e| format!("provision slack tokens: {e}"))?;
8488            store.set_slack_token_ref_for(
8489                channel,
8490                crate::messaging_config::SlackTokenRef {
8491                    bot_token_key: refs.bot_token_key,
8492                    app_token_key: refs.app_token_key,
8493                },
8494            )?;
8495        }
8496    }
8497    // Slack post-channel id (S1). CONFIG, not a secret: the host-gated call
8498    // persists it INTO `messaging.json` (never the keychain) so the boot path
8499    // can construct the adapter with the channel to post into. Slack channel
8500    // only; a non-empty value is required (an empty string would post to
8501    // `channel:""` → `channel_not_found`).
8502    if let Some(slack_channel) = set.slack_channel.as_deref() {
8503        if channel == crate::messaging_config::ChannelId::Slack && !slack_channel.is_empty() {
8504            store.set_slack_channel_id_for(channel, slack_channel)?;
8505        }
8506    }
8507    if let Some(enabled) = set.enabled {
8508        // U1: detect an OFF→ON transition so we can spawn the channel's watcher
8509        // immediately (no daemon/app restart). Read the prior flag BEFORE the
8510        // mutation; only an actual off→on edge triggers the spawn (an on→on
8511        // re-set is a no-op via the supervisor's idempotency guard anyway).
8512        let was_enabled = store.is_enabled_for(channel).unwrap_or(false);
8513        store.set_enabled_for(channel, enabled)?;
8514        if enabled && !was_enabled {
8515            if let Some(sup) = state.channel_supervisor.get() {
8516                // Spawn the watcher now. A spawn error (e.g. Slack runtime-enable,
8517                // which is unsupported in this build because its outbound prompt
8518                // driver is the boot-time FanoutCoordinator) does NOT hard-500 the
8519                // whole call: config is config, so the enabled flag stays
8520                // persisted (the user asked to enable it), but the supervisor
8521                // rolls back its live-set reservation so the channel is NOT marked
8522                // watcher_running. `messaging.status` then honestly reports
8523                // `watcher_running:false` — never claiming a channel is live when
8524                // its outbound cannot fire. The failure is logged, not swallowed
8525                // silently, and surfaced via `messaging.status` rather than as a
8526                // transport error on this config write.
8527                if let Err(e) = sup.ensure_spawned(channel) {
8528                    tracing::warn!(
8529                        channel = channel.as_str(),
8530                        error = %e,
8531                        "channel enabled in config but its runtime watcher could not start; \
8532                         messaging.status will report watcher_running:false"
8533                    );
8534                }
8535            }
8536        }
8537    }
8538    if let Some(handles) = set.allowlisted_handles {
8539        store.set_allowlist_for(channel, handles)?;
8540    }
8541    for handle in &set.add_handles {
8542        store.add_handle_for(channel, handle)?;
8543    }
8544    for handle in &set.remove_handles {
8545        store.remove_handle_for(channel, handle)?;
8546    }
8547    let view = messaging_config_view(&store, channel)?;
8548    serde_json::to_value(view).map_err(|e| e.to_string())
8549}
8550
8551/// `messaging.pairing.start` — mint a fresh high-entropy pairing code for the
8552/// (optional) channel (default iMessage), persist it as that channel's active
8553/// code, and return it for display ONLY in the local UI. Host/local-auth gated.
8554/// Rotates any prior active code on that channel.
8555async fn handle_messaging_pairing_start(
8556    req: &JsonRpcMessage,
8557    session: &crate::session::ClientSession,
8558    state: &ServerState,
8559) -> Result<Value, String> {
8560    require_approval_authority(session, state)?;
8561    let channel = messaging_channel_from_params(&req.params)?;
8562    let store = messaging_store();
8563    let code = store.mint_pairing_code_for(channel)?;
8564    let view = messaging_config_view(&store, channel)?;
8565    let resp = car_proto::MessagingPairingStartResponse {
8566        pairing_code: code,
8567        config: view,
8568    };
8569    serde_json::to_value(resp).map_err(|e| e.to_string())
8570}
8571
8572/// `messaging.pairing.status` — report whether a pairing is in flight on the
8573/// (optional) channel (default iMessage) and (host/local-auth gated only)
8574/// re-surface the active code so the local UI can re-display it after a reload.
8575/// Never exposed over any inbound channel.
8576async fn handle_messaging_pairing_status(
8577    req: &JsonRpcMessage,
8578    session: &crate::session::ClientSession,
8579    state: &ServerState,
8580) -> Result<Value, String> {
8581    require_approval_authority(session, state)?;
8582    let channel = messaging_channel_from_params(&req.params)?;
8583    let code = messaging_store().active_pairing_code_for(channel)?;
8584    let resp = car_proto::MessagingPairingStatusResponse {
8585        pairing_active: code.is_some(),
8586        pairing_code: code,
8587    };
8588    serde_json::to_value(resp).map_err(|e| e.to_string())
8589}
8590
8591/// Daemon-side Full Disk Access probe for `messaging.status` (U2, R-B). The
8592/// daemon is the chat.db reader, so this reflects the DAEMON's access — not the
8593/// host app's. iMessage probes the real Messages library; other channels carry
8594/// no chat.db dependency and are always "readable". On non-macOS there is no
8595/// chat.db, so iMessage is reported readable too (the channel reads nothing
8596/// there anyway, gated by `cfg(target_os = "macos")` in the adapter).
8597#[allow(unused_variables)]
8598fn messaging_fda_readable(channel: crate::messaging_config::ChannelId) -> bool {
8599    match channel {
8600        crate::messaging_config::ChannelId::IMessage => {
8601            #[cfg(target_os = "macos")]
8602            {
8603                car_ffi_common::integrations::messages_fda_readable()
8604            }
8605            #[cfg(not(target_os = "macos"))]
8606            {
8607                true
8608            }
8609        }
8610        crate::messaging_config::ChannelId::Slack => true,
8611    }
8612}
8613
8614/// `messaging.status` — return the real runtime liveness of the (optional)
8615/// channel (default iMessage) so the host UI can render a SINGLE readiness state
8616/// (U2). Host/local-auth gated EXACTLY like the other `messaging.*` methods — an
8617/// inbound message carries no host credential, so it can never call this.
8618///
8619/// `enabled` + `paired` come from the config store; `watcher_running` from the
8620/// runtime channel supervisor's live set (U1); `fda_readable` from a daemon-side
8621/// probe (R-B); `last_send_*` / `last_error` from the supervisor's per-channel
8622/// liveness (written by the send path, U3). When the supervisor is not installed
8623/// (an embedder that never booted the channel pollers), `watcher_running` is
8624/// `false` and the liveness fields are empty.
8625async fn handle_messaging_status(
8626    req: &JsonRpcMessage,
8627    session: &crate::session::ClientSession,
8628    state: &ServerState,
8629) -> Result<Value, String> {
8630    require_approval_authority(session, state)?;
8631    let channel = messaging_channel_from_params(&req.params)?;
8632    let store = messaging_store();
8633    let enabled = store.is_enabled_for(channel).unwrap_or(false);
8634    let paired = !store.allowlist_for(channel).unwrap_or_default().is_empty();
8635
8636    let (watcher_running, liveness) = match state.channel_supervisor.get() {
8637        Some(sup) => (sup.is_spawned(channel), sup.liveness_snapshot(channel)),
8638        None => (false, crate::channel_supervisor::ChannelLiveness::default()),
8639    };
8640
8641    let view = car_proto::MessagingStatusView {
8642        channel: channel.as_str().to_string(),
8643        enabled,
8644        paired,
8645        watcher_running,
8646        fda_readable: messaging_fda_readable(channel),
8647        last_send_at_ms: liveness.last_send_at_ms,
8648        last_send_ok: liveness.last_send_ok,
8649        last_error: liveness.last_error,
8650    };
8651    serde_json::to_value(view).map_err(|e| e.to_string())
8652}
8653
8654/// `messaging.test_send` — send a fixed, clearly-labeled self-test message to
8655/// the paired handle of the (optional) channel (default iMessage) and return a
8656/// synchronous `{ ok, error }` (U4). Host/local-auth gated EXACTLY like the
8657/// other `messaging.*` methods. The self-test mints NO approval/pairing mapping
8658/// and resolves nothing — it is purely a "does my Mac actually text my phone"
8659/// probe whose outcome is recorded into liveness (so a pass genuinely proves
8660/// Automation works and a failure surfaces identically to a real send).
8661///
8662/// Reuses the SAME `RealMessageSender` + liveness sink the boot/runtime adapters
8663/// use, so the test result and `messaging.status`'s `last_send_*` agree. Slack
8664/// is not yet wired here (the pane this serves is iMessage-only in v1); a Slack
8665/// test_send returns a clear "not supported on this channel yet" error.
8666async fn handle_messaging_test_send(
8667    req: &JsonRpcMessage,
8668    session: &crate::session::ClientSession,
8669    state: &ServerState,
8670) -> Result<Value, String> {
8671    require_approval_authority(session, state)?;
8672    let channel = messaging_channel_from_params(&req.params)?;
8673
8674    let result = match channel {
8675        crate::messaging_config::ChannelId::IMessage => {
8676            // Build a one-shot orchestrator over the SAME shared liveness the
8677            // status method reads, so a test send updates "last delivered".
8678            let liveness = state.channel_supervisor.get().map(|sup| sup.liveness());
8679            // The CAR state root, so a relocated daemon's test send writes its
8680            // liveness alongside the rest of its state rather than the primary's.
8681            let base_dir = car_home::root_or_relative();
8682            let orch = match liveness {
8683                Some(liveness) => {
8684                    crate::messaging_orchestrator::MessagingOrchestrator::with_liveness(
8685                        state.host.clone(),
8686                        messaging_store(),
8687                        std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8688                        base_dir,
8689                        liveness,
8690                    )
8691                }
8692                None => crate::messaging_orchestrator::MessagingOrchestrator::new(
8693                    state.host.clone(),
8694                    messaging_store(),
8695                    std::sync::Arc::new(crate::messaging_orchestrator::RealMessageSender),
8696                    base_dir,
8697                ),
8698            };
8699            orch.send_test().await
8700        }
8701        crate::messaging_config::ChannelId::Slack => {
8702            Err("Test send is not supported on the Slack channel yet.".to_string())
8703        }
8704    };
8705
8706    let resp = match result {
8707        Ok(()) => car_proto::MessagingTestSendResponse {
8708            ok: true,
8709            error: None,
8710        },
8711        Err(e) => car_proto::MessagingTestSendResponse {
8712            ok: false,
8713            error: Some(e),
8714        },
8715    };
8716    serde_json::to_value(resp).map_err(|e| e.to_string())
8717}
8718
8719/// Parse a proposal from `params.proposal`.
8720fn proposal_from_params(req: &JsonRpcMessage) -> Result<car_ir::ActionProposal, String> {
8721    let p = req.params.get("proposal").ok_or("missing 'proposal'")?;
8722    serde_json::from_value(p.clone()).map_err(|e| format!("invalid proposal: {e}"))
8723}
8724
8725/// Classify each action in a proposal on both authorization-adjacent axes.
8726/// Params: `{ proposal }`. Returns `{ classifications: [{ action_id, tool,
8727/// required_tier, reversibility, missing_compensation }],
8728/// declared_rollback_contract }`. The envelope is what distinguishes this from
8729/// the FFI `permission_classify`, which returns the row array bare and
8730/// therefore carries no batch-level roll-up.
8731///
8732/// `required_tier` answers *who may authorize this*; `reversibility`
8733/// (`car_policy::classify_reversibility`) answers the independent question
8734/// *can this be undone*. They used to be one field, and collapsed they cannot
8735/// distinguish a `git push` from a charged card — both land on `full_access`.
8736/// The tier comes from the **session's** classifier (which may carry custom
8737/// rules); the reversibility classifier is stateless and has no such hook yet.
8738///
8739/// `missing_compensation` flags the one incoherent IR combination —
8740/// `reversibility: "compensable"` declared with no `compensation` — and reads
8741/// the action's *declared* field, so it stays `false` for proposals that never
8742/// opted into the axis.
8743///
8744/// `declared_rollback_contract` is `ActionProposal::rollback_contract()`: the
8745/// least recoverable contract any action **declares**, because a plan is only
8746/// as recoverable as its worst step and partial execution is a real outcome.
8747/// It is named for what it reads and is deliberately *not* a roll-up of the
8748/// `reversibility` column beside it — that column is the classifier's
8749/// independent guess from tool names, this one is what the author committed
8750/// to. For a proposal that never set the field it reads `"irreversible"`,
8751/// which is the serde default and exactly what the runtime should believe
8752/// about an unclassified plan.
8753///
8754/// Nothing here gates on the second axis — it is classified and reported, not
8755/// enforced. See `docs/proposals/shepherd-substrate-adoption.md`.
8756async fn handle_permission_classify(
8757    req: &JsonRpcMessage,
8758    session: &crate::session::ClientSession,
8759) -> Result<Value, String> {
8760    let proposal = proposal_from_params(req)?;
8761    let gate = session.permission_gate.read().await;
8762    let rows: Vec<Value> = proposal
8763        .actions
8764        .iter()
8765        // Same row shape as the FFI `permission_classify`, from one
8766        // definition — but built against the SESSION's classifier, which may
8767        // carry custom rules a fresh `RiskClassifier` would not have.
8768        .map(|a| car_ffi_common::permgate::classification_row(gate.classifier(), a))
8769        .collect();
8770    Ok(serde_json::json!({
8771        "classifications": rows,
8772        "declared_rollback_contract": proposal.rollback_contract().as_str(),
8773    }))
8774}
8775
8776/// Evaluate each action against the session's gate (consulting its durable
8777/// ledger). Params: `{ proposal }`. Returns `[{ action_id, fingerprint,
8778/// decision, reversibility, ... }]`.
8779///
8780/// `reversibility` is on every row, `allow` included, and is orthogonal to
8781/// `decision`: the gate's verdict says whether the action may run, not whether
8782/// it could be taken back afterwards. An approval UI that shows both can tell
8783/// the operator which of two identically-escalated actions is the one there is
8784/// no undoing.
8785async fn handle_permission_evaluate(
8786    req: &JsonRpcMessage,
8787    session: &crate::session::ClientSession,
8788    state: &ServerState,
8789) -> Result<Value, String> {
8790    let proposal = proposal_from_params(req)?;
8791    let ceiling = resolve_skill_ceiling(req, session).await;
8792    let agent_id = authenticated_bound_agent_id(session).await;
8793    let policy = crate::agent_permissions::load_policy();
8794    let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8795        &session.callback_tool_schema_digests,
8796        &session.runtime.registry,
8797    )
8798    .await;
8799    let gate = session.permission_gate.read().await;
8800    // Prior decisions come from the SHARED daemon ledger (C1) — an approval
8801    // recorded on another connection must be honoured here.
8802    let ledger = state.approval_ledger.read().await;
8803    let rows = evaluate_actions(
8804        &gate,
8805        &policy,
8806        agent_id.as_deref(),
8807        &callback_tools,
8808        &proposal,
8809        ceiling,
8810        &ledger,
8811    );
8812    let mut resp = serde_json::json!({ "decisions": rows });
8813    if let Some(c) = ceiling {
8814        resp.as_object_mut()
8815            .unwrap()
8816            .insert("skill_ceiling".into(), serde_json::json!(c.as_str()));
8817    }
8818    Ok(resp)
8819}
8820
8821/// Like evaluate but returns only the actions that need a human decision —
8822/// the work queue for an approval UI. Rows carry `reversibility` for the same
8823/// reason they do there, and it matters most here: this queue is where a human
8824/// decides, and "can this be undone?" is the question they are actually
8825/// weighing.
8826async fn handle_permission_pending(
8827    req: &JsonRpcMessage,
8828    session: &crate::session::ClientSession,
8829    state: &ServerState,
8830) -> Result<Value, String> {
8831    let proposal = proposal_from_params(req)?;
8832    let ceiling = resolve_skill_ceiling(req, session).await;
8833    let agent_id = authenticated_bound_agent_id(session).await;
8834    let policy = crate::agent_permissions::load_policy();
8835    let callback_tools = crate::permission_gate::eligible_callback_tool_digests(
8836        &session.callback_tool_schema_digests,
8837        &session.runtime.registry,
8838    )
8839    .await;
8840    let gate = session.permission_gate.read().await;
8841    let ledger = state.approval_ledger.read().await;
8842    let pending: Vec<Value> = evaluate_actions(
8843        &gate,
8844        &policy,
8845        agent_id.as_deref(),
8846        &callback_tools,
8847        &proposal,
8848        ceiling,
8849        &ledger,
8850    )
8851    .into_iter()
8852    .filter(|r| r.get("decision").and_then(|v| v.as_str()) == Some("needs_approval"))
8853    .collect();
8854    Ok(serde_json::json!({ "pending": pending }))
8855}
8856
8857/// Resolve the optional skill-deployment ceiling for a permission evaluation: if
8858/// the request names a `skill`, look up its persisted `deployment_tier` (the
8859/// ceiling the skill-trust gate stamped at load time) so the action-level gate
8860/// caps standing authority at it. `None` when no skill is named, the skill is
8861/// absent, or it carries no governed tier — leaving evaluation unchanged.
8862async fn resolve_skill_ceiling(
8863    req: &JsonRpcMessage,
8864    session: &crate::session::ClientSession,
8865) -> Option<car_policy::PermissionTier> {
8866    let skill = req.params.get("skill").and_then(|v| v.as_str())?;
8867    let engine = session.memgine.lock().await;
8868    engine.skill_meta(skill).and_then(|m| m.deployment_tier)
8869}
8870
8871fn evaluate_actions(
8872    gate: &car_policy::PermissionGate,
8873    policy: &car_policy::AgentPermissionPolicy,
8874    agent_id: Option<&str>,
8875    callback_tools: &HashMap<String, String>,
8876    proposal: &car_ir::ActionProposal,
8877    ceiling: Option<car_policy::PermissionTier>,
8878    ledger: &car_policy::ApprovalLedger,
8879) -> Vec<Value> {
8880    proposal
8881        .actions
8882        .iter()
8883        .map(|a| {
8884            // Both axes from one flatten of the parameters (Parslee-ai/car#856)
8885            // — this runs per action over a whole batch.
8886            let evaluation = crate::permission_gate::evaluate_action(
8887                gate,
8888                policy,
8889                agent_id,
8890                a,
8891                a.tool
8892                    .as_deref()
8893                    .and_then(|tool| callback_tools.get(tool))
8894                    .map(String::as_str),
8895                ceiling,
8896                ledger,
8897            );
8898            let mut obj = serde_json::to_value(&evaluation.axes.decision).unwrap_or(Value::Null);
8899            if let Some(map) = obj.as_object_mut() {
8900                map.insert("action_id".into(), serde_json::json!(a.id));
8901                map.insert(
8902                    "fingerprint".into(),
8903                    serde_json::json!(car_policy::action_fingerprint(a)),
8904                );
8905                if let Some(source) = evaluation.authorization_source {
8906                    map.insert("authorization_source".into(), source.into());
8907                }
8908                if let Some(digest) = evaluation.schema_digest {
8909                    map.insert("authorization_schema_digest".into(), digest.into());
8910                }
8911            }
8912            // The second axis rides on every row, `allow` included — an action
8913            // the gate waved through still has a rollback contract, and that is
8914            // the row an incident review reads first. Shared with the NAPI/PyO3
8915            // projection so the two cannot drift (project convention #2).
8916            car_ffi_common::permgate::stamp_reversibility(&mut obj, evaluation.axes.reversibility);
8917            obj
8918        })
8919        .collect()
8920}
8921
8922/// Which agent id a newly created approval should be attributed to.
8923///
8924/// A session **bound to an agent** is that agent, full stop: its binding was
8925/// proven against the supervisor-minted per-agent token, so a claim to be some
8926/// other agent is a forgery and is discarded. A session with no binding is a
8927/// host client — CarHost, `car-host approve`, the CLI — and those legitimately
8928/// raise approvals on an agent's behalf, so their claim is kept.
8929///
8930/// Pure so the rule is testable without a live session.
8931fn stamped_requester(bound: Option<String>, claimed: Option<String>) -> Option<String> {
8932    bound.or(claimed)
8933}
8934
8935async fn authenticated_bound_agent_id(session: &crate::session::ClientSession) -> Option<String> {
8936    if !session
8937        .authenticated
8938        .load(std::sync::atomic::Ordering::Acquire)
8939    {
8940        return None;
8941    }
8942    session.agent_id.lock().await.clone()
8943}
8944
8945/// Record a durable human-in-the-loop decision. Params either
8946/// `{ fingerprint, required_tier, reviewer, reason, evidence? }` (from a
8947/// prior `permission.evaluate`) or `{ action, reviewer, reason, evidence? }`.
8948/// Also audited to the session event log as `ApprovalRecorded`.
8949async fn handle_permission_decision(
8950    req: &JsonRpcMessage,
8951    session: &crate::session::ClientSession,
8952    state: &ServerState,
8953    approve: bool,
8954) -> Result<Value, String> {
8955    require_approval_authority(session, state)?;
8956    // The reviewer is the server-stamped principal, NOT a caller string —
8957    // an audit log whose "who approved" is forgeable by the approver
8958    // undercuts §5.2.5 (neo review #5c). The caller's free-text note is
8959    // kept as the reason.
8960    let reviewer = session_principal(session).await;
8961    let reason = req
8962        .params
8963        .get("reason")
8964        .and_then(|v| v.as_str())
8965        .unwrap_or("");
8966    let evidence = req
8967        .params
8968        .get("evidence")
8969        .and_then(|v| v.as_str())
8970        .map(str::to_string);
8971
8972    // Decisions land on the SHARED daemon ledger (kernel review C1): the
8973    // approver is typically a host connection while the runner that surfaced
8974    // the fingerprint is another — a per-session ledger would strand the
8975    // decision where the runner never reads it. The session gate contributes
8976    // only its classifier (for the action-shaped variant).
8977    let decision = if approve {
8978        car_policy::ApprovalDecision::Approved
8979    } else {
8980        car_policy::ApprovalDecision::Rejected
8981    };
8982    let record = if let Some(action_val) = req.params.get("action") {
8983        let action: car_ir::Action = serde_json::from_value(action_val.clone())
8984            .map_err(|e| format!("invalid action: {e}"))?;
8985        let rec = {
8986            let gate = session.permission_gate.read().await;
8987            gate.decision_record(&action, decision, &reviewer, reason, evidence)
8988        };
8989        state
8990            .approval_ledger
8991            .write()
8992            .await
8993            .record(rec.clone())
8994            // Journal write failure = decision NOT durable; propagate instead
8995            // of emitting a false ApprovalRecorded audit event (review A7).
8996            .map_err(|e| format!("failed to persist approval decision: {e}"))?;
8997        rec
8998    } else {
8999        let fingerprint = req
9000            .params
9001            .get("fingerprint")
9002            .and_then(|v| v.as_str())
9003            .ok_or("missing 'fingerprint' (or 'action')")?;
9004        let required_tier = req
9005            .params
9006            .get("required_tier")
9007            .and_then(|v| v.as_str())
9008            .and_then(car_policy::PermissionTier::from_str_opt)
9009            .unwrap_or(car_policy::PermissionTier::FullAccess);
9010        state
9011            .approval_ledger
9012            .write()
9013            .await
9014            .record_decision(
9015                fingerprint,
9016                required_tier,
9017                decision,
9018                &reviewer,
9019                reason,
9020                evidence,
9021            )
9022            .map_err(|e| format!("failed to persist approval decision: {e}"))?
9023    };
9024
9025    // Audit the durable transition (§5.2.5) to the session event log — at
9026    // parity with car-engine's TierPermissionHandler emission (required_tier
9027    // + evidence; approval derived from the stored decision, not the request).
9028    let mut data = std::collections::HashMap::new();
9029    data.insert(
9030        "fingerprint".to_string(),
9031        Value::from(record.fingerprint.clone()),
9032    );
9033    data.insert(
9034        "approval".to_string(),
9035        Value::from(match record.decision {
9036            car_policy::ApprovalDecision::Approved => "approved",
9037            car_policy::ApprovalDecision::Rejected => "rejected",
9038        }),
9039    );
9040    data.insert(
9041        "required_tier".to_string(),
9042        Value::from(record.required_tier.as_str()),
9043    );
9044    data.insert("reviewer".to_string(), Value::from(record.reviewer.clone()));
9045    data.insert("reason".to_string(), Value::from(record.reason.clone()));
9046    if let Some(ev) = &record.evidence {
9047        data.insert("evidence".to_string(), Value::from(ev.clone()));
9048    }
9049    session.runtime.log.lock().await.append(
9050        car_eventlog::EventKind::ApprovalRecorded,
9051        None,
9052        None,
9053        data,
9054    );
9055
9056    serde_json::to_value(&record).map_err(|e| e.to_string())
9057}
9058
9059/// Validate a tenant id (linus review C-4). `:` is the namespace
9060/// separator in `tenant:<id>:<key>`, so an id containing it nests
9061/// inside (or envelops) another tenant's namespace — `"acme:sub"`
9062/// would read acme's keys and be captured by acme's scoped
9063/// snapshot/restore/reap. Reject at the boundary.
9064fn validate_tenant_id(id: &str) -> Result<(), String> {
9065    if id.contains(':') {
9066        return Err(format!(
9067            "invalid tenant_id '{id}': ':' is reserved as the namespace separator"
9068        ));
9069    }
9070    Ok(())
9071}
9072
9073/// Resolve the tenant identity for a tenant-scoped handler
9074/// (Parslee-ai/car#187 phase 3-E, hardened per linus review C-4).
9075///
9076/// The session's bound tenant (from `session.auth { tenant_id }`) is
9077/// authoritative: when bound, a per-request `tenant_id` may restate it
9078/// but a mismatch is an error — request params are caller-controlled
9079/// and must not hop namespaces. An unbound session keeps the legacy
9080/// per-request behavior (validated). Absent/empty → unscoped.
9081async fn effective_tenant(
9082    req: &JsonRpcMessage,
9083    session: &crate::session::ClientSession,
9084) -> Result<Option<String>, String> {
9085    let param = req
9086        .params
9087        .get("tenant_id")
9088        .and_then(|v| v.as_str())
9089        .filter(|s| !s.is_empty())
9090        .map(str::to_string);
9091    if let Some(p) = &param {
9092        validate_tenant_id(p)?;
9093    }
9094    let bound = session.tenant.lock().await.clone();
9095    match (bound, param) {
9096        (Some(b), Some(p)) if b != p => Err(format!(
9097            "tenant_id '{p}' conflicts with this session's bound tenant '{b}'"
9098        )),
9099        (Some(b), _) => Ok(Some(b)),
9100        (None, p) => Ok(p),
9101    }
9102}
9103
9104/// Refuse unscoped access to a tenant-namespaced key (linus review
9105/// C-4): without this, `state.get {key: "tenant:acme:secret"}` with no
9106/// `tenant_id` read (and `state.set` wrote) straight through another
9107/// tenant's namespace — enforcement existed only on `keys`/`snapshot`.
9108fn reject_unscoped_tenant_key(tenant: &Option<String>, key: &str) -> Result<(), String> {
9109    if tenant.is_none() && key.starts_with("tenant:") {
9110        return Err(format!(
9111            "unscoped access to tenant-namespaced key '{key}' is not permitted; \
9112             authenticate with the owning tenant_id"
9113        ));
9114    }
9115    Ok(())
9116}
9117
9118fn handle_capabilities_list(bound_agent: Option<&str>, is_host: bool) -> Value {
9119    let caller_role = if is_host {
9120        crate::wire_schema::CapabilityRole::Host
9121    } else if bound_agent.is_some() {
9122        crate::wire_schema::CapabilityRole::Agent
9123    } else {
9124        crate::wire_schema::CapabilityRole::Operator
9125    };
9126    let methods: Vec<crate::wire_schema::CapabilityMethodRow> =
9127        crate::generated_rpc_capabilities::RPC_CAPABILITIES
9128            .iter()
9129            .filter(|(_, role)| match *role {
9130                // Ungated methods are callable by every authenticated client.
9131                "operator" => true,
9132                // Owner methods authorize the concrete resource at call time.
9133                "owner" => is_host || bound_agent.is_some(),
9134                "agent" => bound_agent.is_some(),
9135                "host" => is_host,
9136                _ => false,
9137            })
9138            .map(|(method, role)| crate::wire_schema::CapabilityMethodRow {
9139                method: (*method).to_string(),
9140                role: crate::wire_schema::CapabilityRole::from_manifest(role),
9141            })
9142            .collect();
9143    serde_json::to_value(crate::wire_schema::CapabilitiesListResult {
9144        caller_role,
9145        count: methods.len(),
9146        methods,
9147    })
9148    .expect("capabilities.list result serializes")
9149}
9150
9151#[cfg(test)]
9152mod capability_discovery_tests {
9153    use super::handle_capabilities_list;
9154
9155    #[test]
9156    fn agent_discovery_excludes_host_methods_and_preserves_roles() {
9157        let result = handle_capabilities_list(Some("agent-a"), false);
9158        let methods = result["methods"].as_array().expect("method rows");
9159        assert_eq!(result["caller_role"], "agent");
9160        assert!(methods
9161            .iter()
9162            .any(|row| row["method"] == "capabilities.list"));
9163        assert!(methods.iter().any(|row| row["role"] == "agent"));
9164        assert!(methods.iter().any(|row| row["role"] == "owner"));
9165        assert!(methods.iter().any(|row| row["role"] == "operator"));
9166        assert!(!methods.iter().any(|row| row["role"] == "host"));
9167    }
9168
9169    #[test]
9170    fn unbound_discovery_contains_only_ungated_methods() {
9171        let result = handle_capabilities_list(None, false);
9172        let methods = result["methods"].as_array().expect("method rows");
9173        assert_eq!(result["caller_role"], "operator");
9174        assert!(methods.iter().all(|row| row["role"] == "operator"));
9175    }
9176}
9177
9178async fn handle_state_get(
9179    req: &JsonRpcMessage,
9180    session: &crate::session::ClientSession,
9181) -> Result<Value, String> {
9182    let key = require_str(&req.params, "key")?;
9183    let tenant = effective_tenant(req, session).await?;
9184    reject_unscoped_tenant_key(&tenant, key)?;
9185    Ok(session
9186        .runtime
9187        .state
9188        .scoped(tenant.as_deref())
9189        .get(key)
9190        .unwrap_or(Value::Null))
9191}
9192
9193async fn handle_state_set(
9194    req: &JsonRpcMessage,
9195    session: &crate::session::ClientSession,
9196) -> Result<Value, String> {
9197    let key = require_str(&req.params, "key")?;
9198    let value = req.params.get("value").cloned().unwrap_or(Value::Null);
9199    let tenant = effective_tenant(req, session).await?;
9200    reject_unscoped_tenant_key(&tenant, key)?;
9201    session
9202        .runtime
9203        .state
9204        .scoped(tenant.as_deref())
9205        .set(key, value, "client");
9206    serde_json::to_value(crate::wire_schema::StateSetResult::Ok)
9207        .map_err(|e| format!("serialize state.set result: {e}"))
9208}
9209
9210/// `state.exists` — true if the key is set in this session's state
9211/// store, false otherwise. Cheaper than `state.get` + null-check on
9212/// the client side because it doesn't serialize the value.
9213async fn handle_state_exists(
9214    req: &JsonRpcMessage,
9215    session: &crate::session::ClientSession,
9216) -> Result<Value, String> {
9217    let key = require_str(&req.params, "key")?;
9218    let tenant = effective_tenant(req, session).await?;
9219    reject_unscoped_tenant_key(&tenant, key)?;
9220    serde_json::to_value(crate::wire_schema::StateExistsResult(
9221        session.runtime.state.scoped(tenant.as_deref()).exists(key),
9222    ))
9223    .map_err(|e| format!("serialize state.exists result: {e}"))
9224}
9225
9226/// `state.keys` — list every key currently set in this session's
9227/// state store. Returns a JSON array of strings.
9228async fn handle_state_keys(
9229    req: &JsonRpcMessage,
9230    session: &crate::session::ClientSession,
9231) -> Result<Value, String> {
9232    let tenant = effective_tenant(req, session).await?;
9233    serde_json::to_value(crate::wire_schema::StateKeysResult(
9234        session.runtime.state.scoped(tenant.as_deref()).keys(),
9235    ))
9236    .map_err(|e| format!("serialize state.keys result: {e}"))
9237}
9238
9239/// `state.snapshot` — return the entire session state store as a
9240/// JSON object (`{ key: value, ... }`). Equivalent to iterating
9241/// `state.keys` + `state.get` but in a single round-trip; for
9242/// inspectors/dashboards.
9243///
9244/// Tenant-scoped variant: when `tenant_id` is set, only that
9245/// tenant's keys are returned (prefix stripped on the way out).
9246/// `state.snapshot` with no `tenant_id` returns only unscoped
9247/// keys; consistent with `state.keys`'s filter behaviour and the
9248/// strict-isolation contract from phase 3-B.
9249async fn handle_state_snapshot(
9250    req: &JsonRpcMessage,
9251    session: &crate::session::ClientSession,
9252) -> Result<Value, String> {
9253    let tenant = effective_tenant(req, session).await?;
9254    // One locked snapshot, not keys()-then-get(): each of those calls
9255    // acquires the state lock separately, so a concurrent mutation batch
9256    // could commit between two get()s and the response would mix old and
9257    // new values of a single callback's key set (Parslee-ai/car#1140).
9258    let map: serde_json::Map<String, Value> = session
9259        .runtime
9260        .state
9261        .scoped(tenant.as_deref())
9262        .snapshot_stripped()
9263        .into_iter()
9264        .collect();
9265    serde_json::to_value(crate::wire_schema::StateSnapshotResult(map))
9266        .map_err(|e| format!("serialize state.snapshot result: {e}"))
9267}
9268
9269// --- Per-agent persistent memgine (#170) ---
9270
9271/// `~/.car/memory/agents/<id>.json` — the per-agent snapshot file.
9272/// Mirrors the existing `memory.persist` shape (flat JSON array of
9273/// fact objects) so the same loader path works.
9274fn agent_memgine_snapshot_path(agent_id: &str) -> Result<std::path::PathBuf, String> {
9275    let base = car_ffi_common::memory_path::ensure_base()
9276        .map_err(|e| format!("memory base unavailable: {e}"))?;
9277    let dir = base.join("agents");
9278    std::fs::create_dir_all(&dir).map_err(|e| format!("create agents dir: {e}"))?;
9279    Ok(dir.join(format!("{agent_id}.json")))
9280}
9281
9282/// Acquire (or lazy-create + load from disk) the daemon-owned
9283/// persistent memgine for `agent_id`. First call per id reads
9284/// `~/.car/memory/agents/<id>.json` if it exists; subsequent calls
9285/// share the in-memory engine across sessions. Caller stores the
9286/// returned `Arc` on `ClientSession.bound_memgine` so memory.*
9287/// handlers route through it via [`ClientSession::effective_memgine`](crate::session::ClientSession::effective_memgine).
9288/// Resolve the [`car_memgine::MemgineConfig`] the daemon seeds its engines with:
9289/// discover the `.car/` project from the **project anchor** and apply its
9290/// `config.toml` overrides (e.g. utility-aware retrieval). The anchor is
9291/// `$CAR_PROJECT_DIR` when set, else the process cwd.
9292///
9293/// The env var is load-bearing for the shipped macOS path: the
9294/// `launchd`/CarHost-supervised `car-server` inherits the app bundle (or `/`)
9295/// as cwd, which has no `.car/` ancestor — so cwd-only discovery would silently
9296/// no-op there. A host that wants project tuning points `CAR_PROJECT_DIR` at the
9297/// workspace. With neither anchor resolvable, returns the default config
9298/// (utility retrieval off) — a safe no-op, never a panic.
9299pub fn seed_memgine_config() -> car_memgine::MemgineConfig {
9300    let anchor = std::env::var_os("CAR_PROJECT_DIR")
9301        .map(std::path::PathBuf::from)
9302        .or_else(|| std::env::current_dir().ok());
9303    match anchor {
9304        Some(dir) => {
9305            car_memgine::project::resolve_config(&dir, car_memgine::MemgineConfig::default())
9306        }
9307        None => car_memgine::MemgineConfig::default(),
9308    }
9309}
9310
9311/// Read the operator's **trusted skill-signer keyring** from the `.car/`
9312/// project's `config.toml` (`trusted_skill_signers`), discovered from the same
9313/// anchor as [`seed_memgine_config`]: `$CAR_PROJECT_DIR` when set, else the
9314/// process cwd. Absent key, absent file, or no project → an empty keyring.
9315///
9316/// This is the operator half of skill-trust governance (arXiv 2602.12430
9317/// "Agent Skills"; `docs/proposals/skill-trust-governance.md`). It decides
9318/// whether a bundle's ed25519 signature confers `signer_trusted` on the
9319/// resulting `SkillProvenance`.
9320///
9321/// Be precise about what an empty keyring costs, because it is narrower than
9322/// it sounds: `signer_trusted` separates **only** `Official` from `Verified`
9323/// in [`car_policy::skill_trust::classify_trust`]. A signed + scanned pack
9324/// with an empty keyring lands `Verified` → `sandbox_edit`, not `Community`
9325/// — that tier requires `scanned && !signed`, so a signed pack can never
9326/// reach it. What the keyring withholds is `Official`/`full_access`, nothing
9327/// else. An unscanned pack is `Untrusted` → denied regardless of signature,
9328/// and `scanned` is caller-supplied on every path, so a self-signed manifest
9329/// declared scanned still reaches `sandbox_edit` here.
9330///
9331/// The keyring comes ONLY from operator config, never from the JSON-RPC
9332/// request. A caller that could name its own trusted key ids would be
9333/// self-certifying: it would sign a pack, declare its own key trusted, and
9334/// take the `manifest` path straight to `Official`. Note this constrains the
9335/// `manifest` path only — `skill.adopt_pack` also accepts a caller-assembled
9336/// `provenance` at face value (see [`resolve_adopt_provenance`]).
9337pub fn seed_trusted_skill_signers() -> Vec<String> {
9338    let Some(anchor) = std::env::var_os("CAR_PROJECT_DIR")
9339        .map(std::path::PathBuf::from)
9340        .or_else(|| std::env::current_dir().ok())
9341    else {
9342        return Vec::new();
9343    };
9344    let Some(car_dir) = car_memgine::project::discover_project(&anchor) else {
9345        return Vec::new();
9346    };
9347    car_memgine::project::load_config_overrides(&car_dir)
9348        .and_then(|o| o.trusted_skill_signers)
9349        .unwrap_or_default()
9350}
9351
9352/// Snapshot path for a host-declared memory namespace.
9353///
9354/// Deliberately NOT `agents/`: a namespace is a different axis from an agent
9355/// id (Parslee-ai/car-releases#79), so one agent may work across several
9356/// namespaces and two hosts may share a namespace without sharing an identity.
9357/// Colliding the two directories would also let a namespace masquerade as a
9358/// supervised agent's snapshot.
9359///
9360/// Encoded — not sanitized — because a namespace is arbitrary host-supplied
9361/// text that becomes a filename, and the mapping from namespace to filename
9362/// must be **injective**. An `agent_id` is safe by construction (`session.auth`
9363/// rejects one the supervisor does not know); nothing validates a namespace, so
9364/// `../../…` here would escape the memory base.
9365///
9366/// The old mapping replaced `/ \ : NUL` with `_` and trimmed `.`/whitespace,
9367/// which is lossy: `proj/x`, `proj:x`, `proj\x`, `proj_x` and `proj_x ` all
9368/// landed on `proj_x.json`. The in-memory registry is keyed on the raw
9369/// namespace, so nothing looked wrong while the daemon ran — but the loader
9370/// reads whatever sits at the path with no namespace check, so on the next
9371/// snapshot LOAD (daemon restart, or `memory.load`) whichever namespace
9372/// resolved second inherited the first one's persisted graph. That is
9373/// cross-project memory leakage through a filename (#891).
9374///
9375/// The mapping here is a lowercase percent-encoding of the namespace's UTF-8
9376/// bytes: `a`-`z`, `0`-`9`, `-`, `_` and `.` pass through, every other byte
9377/// becomes `%` plus two lowercase hex digits (`/` → `%2f`, `:` → `%3a`,
9378/// ` ` → `%20`), and `%` itself becomes `%25`. Because `%` is always escaped,
9379/// every `%` in the output starts an escape, so distinct namespaces cannot
9380/// produce the same filename. Uppercase letters and uppercase hex are escaped
9381/// rather than passed through **because APFS is case-insensitive by default**:
9382/// letting `A` through would let `Proj` and `proj` collide again on the very
9383/// platform CarHost ships on.
9384///
9385/// Nothing is trimmed — trimming is lossy — and nothing needs to be: `/` is
9386/// escaped, so `.` and `..` are ordinary filenames here and no traversal is
9387/// possible.
9388fn namespace_memgine_snapshot_path(namespace: &str) -> Result<std::path::PathBuf, String> {
9389    if namespace.is_empty() {
9390        return Err("memory_namespace must contain at least one usable character".into());
9391    }
9392    const HEX: &[u8; 16] = b"0123456789abcdef";
9393    let mut encoded = String::with_capacity(namespace.len());
9394    for &b in namespace.as_bytes() {
9395        match b {
9396            b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' => encoded.push(b as char),
9397            _ => {
9398                encoded.push('%');
9399                encoded.push(HEX[(b >> 4) as usize] as char);
9400                encoded.push(HEX[(b & 0x0f) as usize] as char);
9401            }
9402        }
9403    }
9404    let file_name = format!("{encoded}.json");
9405    // Encoding can triple the length, so a namespace well under any filesystem
9406    // limit can still encode past one. Fail with the namespace named rather
9407    // than letting the OS return an opaque ENAMETOOLONG on the first write.
9408    if file_name.len() > 255 {
9409        return Err(format!(
9410            "memory_namespace is too long: {:?} encodes to a {}-byte filename \
9411             (limit 255)",
9412            namespace,
9413            file_name.len()
9414        ));
9415    }
9416    let base = car_ffi_common::memory_path::ensure_base()
9417        .map_err(|e| format!("memory base unavailable: {e}"))?;
9418    let dir = base.join("memory-namespaces");
9419    std::fs::create_dir_all(&dir).map_err(|e| format!("create namespaces dir: {e}"))?;
9420    Ok(dir.join(file_name))
9421}
9422
9423async fn mirror_stored_identity(
9424    state: &ServerState,
9425    engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9426) {
9427    match state.identity_store.load() {
9428        Ok(identity) => {
9429            let mut engine = engine.lock().await;
9430            crate::session::mirror_identity_into_memgine(&mut engine, &identity);
9431        }
9432        Err(error) => tracing::warn!(
9433            error = %error,
9434            "identity profile could not be mirrored into a loaded memory engine"
9435        ),
9436    }
9437}
9438
9439/// Acquire (or lazy-create + load) the daemon-owned memgine for a namespace.
9440///
9441/// Mirrors [`get_or_load_agent_memgine`]; the registries are separate so the
9442/// two axes cannot collide.
9443async fn get_or_load_namespace_memgine(
9444    state: &Arc<ServerState>,
9445    namespace: &str,
9446) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9447    {
9448        let map = state.namespace_memgines.lock().await;
9449        if let Some(eng) = map.get(namespace) {
9450            let eng = eng.clone();
9451            drop(map);
9452            mirror_stored_identity(state, &eng).await;
9453            return Ok(eng);
9454        }
9455    }
9456    let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9457        Some(seed_memgine_config()),
9458    )));
9459    let path = namespace_memgine_snapshot_path(namespace)?;
9460    if path.exists() {
9461        // Same on-disk shape as the per-agent snapshot: a flat array of facts.
9462        let content = std::fs::read_to_string(&path)
9463            .map_err(|e| format!("read {}: {}", path.display(), e))?;
9464        let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9465        let mut g = engine.lock().await;
9466        for (loaded, fact) in facts.iter().enumerate() {
9467            ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9468        }
9469    }
9470    mirror_stored_identity(state, &engine).await;
9471    let mut map = state.namespace_memgines.lock().await;
9472    Ok(map.entry(namespace.to_string()).or_insert(engine).clone())
9473}
9474
9475/// Bind a session to a namespace graph AND record which namespace it is.
9476///
9477/// These two must happen together. Binding without recording is what made
9478/// namespace memory non-durable (car-releases#82): the daemon knew which graph
9479/// the session used but not what to call it, so nothing could write it back to
9480/// disk. Keeping them in one helper means a future bind site cannot do half of
9481/// it — there were four call sites, and a fifth would have been easy to get
9482/// wrong.
9483async fn bind_memory_namespace(
9484    state: &Arc<ServerState>,
9485    session: &crate::session::ClientSession,
9486    namespace: &str,
9487) -> Result<(), String> {
9488    let eng = get_or_load_namespace_memgine(state, namespace).await?;
9489    *session.bound_memgine.lock().await = Some(eng);
9490    *session.memory_namespace.lock().await = Some(namespace.to_string());
9491    Ok(())
9492}
9493
9494/// Write a namespace's graph to
9495/// `~/.car/memory/memory-namespaces/<encoded-ns>.json` (see
9496/// [`namespace_memgine_snapshot_path`] for the encoding).
9497///
9498/// Mirrors [`persist_agent_memgine`] exactly, including its lock discipline:
9499/// the shared memgine lock is released BEFORE serialize+write, and the write
9500/// runs on the blocking pool. Holding it across a blocking write would
9501/// serialize every namespace's memory ops and one stuck write would wedge them
9502/// all.
9503///
9504/// The on-disk shape is the flat fact array `get_or_load_namespace_memgine`
9505/// already reads — that loader existed from the start, reading a file nothing
9506/// ever wrote.
9507async fn persist_namespace_memgine(
9508    namespace: &str,
9509    engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9510) -> Result<(), String> {
9511    let path = namespace_memgine_snapshot_path(namespace)?;
9512    let g = engine.lock().await;
9513    let facts = memory_snapshot_facts(&g);
9514    drop(g);
9515    let path_owned = path.clone();
9516    tokio::task::spawn_blocking(move || -> Result<(), String> {
9517        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9518        atomic_write_sync(&path_owned, json.as_bytes())
9519            .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9520    })
9521    .await
9522    .map_err(|e| format!("persist_namespace_memgine join: {e}"))?
9523}
9524
9525/// One flat JSON row per persisted graph node, in the shape the snapshot
9526/// loaders (`get_or_load_namespace_memgine`, `get_or_load_agent_memgine`,
9527/// `handle_memory_load`) read back.
9528///
9529/// The row format is deliberately lossy and always has been: non-Fact kinds
9530/// collapse onto the three flat labels (`constraint`/`pattern`/`outcome`) and
9531/// reload as plain Fact nodes (a Conversation's `outcome` row becomes a
9532/// non-constraint Fact; Skill/Conclusion/FactSuperseded rows fall into the
9533/// `_` arm), while Identity/Environment nodes are not written at all. Identity
9534/// is stored in `identity.json` and re-mirrored when each
9535/// engine loads; persisting it here too would let an old profile overwrite a
9536/// newer role/focus selection after restart. Only Fact rows round-trip
9537/// `fact_id`/`tags`/`source` verbatim — the metadata preservation the docs
9538/// promise holds for facts, not for the whole graph.
9539fn memory_snapshot_facts(engine: &car_memgine::MemgineEngine) -> Vec<Value> {
9540    engine
9541        .graph
9542        .inner
9543        .node_indices()
9544        .filter_map(|nix| {
9545            let node = engine.graph.inner.node_weight(nix)?;
9546            if !node.is_valid() {
9547                return None;
9548            }
9549            if node.kind == car_memgine::MemKind::Identity
9550                || node.kind == car_memgine::MemKind::Environment
9551            {
9552                return None;
9553            }
9554            Some(serde_json::json!({
9555                "fact_id": node.fact_id,
9556                "subject": node.key,
9557                "body": node.value,
9558                "kind": match node.kind {
9559                    car_memgine::MemKind::Fact if node.is_constraint => "constraint",
9560                    car_memgine::MemKind::Fact => "pattern",
9561                    car_memgine::MemKind::Conversation => "outcome",
9562                    _ => "pattern",
9563                },
9564                "confidence": 0.5,
9565                "content_type": node.content_type.as_label(),
9566                "tags": node.metadata.tags,
9567                "source": node
9568                    .metadata
9569                    .provenance
9570                    .first()
9571                    .map(|entry| entry.source.as_str())
9572                    .unwrap_or(""),
9573            }))
9574        })
9575        .collect()
9576}
9577
9578fn ingest_snapshot_fact(
9579    engine: &mut car_memgine::MemgineEngine,
9580    fact: &Value,
9581    fallback_id: String,
9582) {
9583    let subject = fact.get("subject").and_then(Value::as_str).unwrap_or("");
9584    let body = fact.get("body").and_then(Value::as_str).unwrap_or("");
9585    let kind = fact
9586        .get("kind")
9587        .and_then(Value::as_str)
9588        .unwrap_or("pattern");
9589    let fact_id = fact
9590        .get("fact_id")
9591        .and_then(Value::as_str)
9592        .unwrap_or(&fallback_id);
9593    let tags = fact
9594        .get("tags")
9595        .cloned()
9596        .and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
9597        .unwrap_or_default();
9598    let source = fact
9599        .get("source")
9600        .and_then(Value::as_str)
9601        .unwrap_or_default();
9602    let nix = engine.ingest_fact(
9603        fact_id,
9604        subject,
9605        body,
9606        "user",
9607        "peer",
9608        chrono::Utc::now(),
9609        "global",
9610        None,
9611        vec![],
9612        kind == "constraint",
9613    );
9614    if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9615        node.metadata.tags = tags;
9616        if !source.is_empty() {
9617            node.metadata.provenance = vec![car_memgine::Provenance {
9618                source: source.to_string(),
9619                ..Default::default()
9620            }];
9621        }
9622    }
9623}
9624
9625async fn get_or_load_agent_memgine(
9626    state: &Arc<ServerState>,
9627    agent_id: &str,
9628) -> Result<Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>, String> {
9629    {
9630        let map = state.agent_memgines.lock().await;
9631        if let Some(eng) = map.get(agent_id) {
9632            let eng = eng.clone();
9633            drop(map);
9634            mirror_stored_identity(state, &eng).await;
9635            return Ok(eng);
9636        }
9637    }
9638    // Build a fresh engine and try to load from disk. Seed its config from the
9639    // `.car/` project (like the daemon's shared engine) so a bound agent's
9640    // retrieval honors the same team-shared tuning (e.g. utility-aware
9641    // retrieval) — otherwise the knob could never reach the per-agent engine
9642    // (#170 bound_memgine path).
9643    let engine = Arc::new(tokio::sync::Mutex::new(car_memgine::MemgineEngine::new(
9644        Some(seed_memgine_config()),
9645    )));
9646    let path = agent_memgine_snapshot_path(agent_id)?;
9647    if path.exists() {
9648        let content = std::fs::read_to_string(&path)
9649            .map_err(|e| format!("read {}: {}", path.display(), e))?;
9650        let facts: Vec<Value> = serde_json::from_str(&content).unwrap_or_default();
9651        let mut g = engine.lock().await;
9652        for (loaded, fact) in facts.iter().enumerate() {
9653            ingest_snapshot_fact(&mut g, fact, format!("loaded-{loaded}"));
9654        }
9655    }
9656    mirror_stored_identity(state, &engine).await;
9657    let mut map = state.agent_memgines.lock().await;
9658    let stored = map.entry(agent_id.to_string()).or_insert(engine).clone();
9659    Ok(stored)
9660}
9661
9662/// Crash- and concurrency-safe write: serialize to a UNIQUE temp in the same
9663/// directory, then atomically rename over the target. A crash mid-write or a
9664/// concurrent writer to the same (client-chosen) path can no longer leave a
9665/// partial or interleaved file — `rename(2)` is atomic on the same filesystem,
9666/// and the per-call temp name (pid + monotonic seq) means two writers never
9667/// collide on the temp. Synchronous — call inside spawn_blocking.
9668fn atomic_write_sync(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
9669    use std::sync::atomic::{AtomicU64, Ordering};
9670    static SEQ: AtomicU64 = AtomicU64::new(0);
9671    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
9672    let mut tmp_os = path.as_os_str().to_owned();
9673    tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
9674    let tmp = std::path::PathBuf::from(tmp_os);
9675    std::fs::write(&tmp, bytes)?;
9676    std::fs::rename(&tmp, path)
9677}
9678
9679/// Snapshot the agent's memgine to its disk file. Same on-wire shape
9680/// as `memory.persist` so manual snapshots and the daemon-owned
9681/// persistence stay interoperable.
9682async fn persist_agent_memgine(
9683    agent_id: &str,
9684    engine: &Arc<tokio::sync::Mutex<car_memgine::MemgineEngine>>,
9685) -> Result<(), String> {
9686    let path = agent_memgine_snapshot_path(agent_id)?;
9687    let g = engine.lock().await;
9688    let facts = memory_snapshot_facts(&g);
9689    // Release the SHARED memgine lock before serialize+write, then do that work
9690    // on the blocking pool: holding the lock across a blocking write serializes
9691    // every agent's memory ops and a slow/stuck write would wedge them all
9692    // (the incident). spawn_blocking also keeps the write off the tokio worker
9693    // (no thread-pinning) and lets the per-request deadline preempt at the await.
9694    drop(g);
9695    let path_owned = path.clone();
9696    tokio::task::spawn_blocking(move || -> Result<(), String> {
9697        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
9698        atomic_write_sync(&path_owned, json.as_bytes())
9699            .map_err(|e| format!("write {}: {}", path_owned.display(), e))
9700    })
9701    .await
9702    .map_err(|e| format!("persist_agent_memgine join: {e}"))?
9703}
9704
9705// --- Memory handlers ---
9706
9707/// `memory.fact_count` — return `valid_fact_count()` of the
9708/// session's memgine. Used by FFI bindings to mirror their
9709/// embedded `fact_count()` accessor without round-tripping a full
9710/// query. No params.
9711async fn handle_memory_fact_count(
9712    session: &crate::session::ClientSession,
9713) -> Result<Value, String> {
9714    let engine_arc = session.effective_memgine().await;
9715    let engine = engine_arc.lock().await;
9716    Ok(Value::from(engine.valid_fact_count()))
9717}
9718
9719/// Parse a `CommitAuthority` from its wire label.
9720///
9721/// Matched exhaustively against the enum's own `as_str` labels rather than
9722/// hand-written strings, so a new authority variant cannot silently become
9723/// unparseable here (project convention #2).
9724fn parse_commit_authority(s: &str) -> Result<car_verify::CommitAuthority, String> {
9725    use car_verify::CommitAuthority as A;
9726    for candidate in [
9727        A::Execution,
9728        A::Review,
9729        A::Coordination,
9730        A::Scheduling,
9731        A::SystemConfig,
9732    ] {
9733        if candidate.as_str() == s {
9734            return Ok(candidate);
9735        }
9736    }
9737    Err(format!(
9738        "unknown commit authority `{s}` (expected one of: execution, review, coordination, scheduling, system_config)"
9739    ))
9740}
9741
9742/// `memory.set_admission_table` — install or clear the durable-state
9743/// admission rules for this session's memgine.
9744///
9745/// Params: `{ table: OwnershipTable | null }`. `null` (or an absent `table`)
9746/// clears the rules, turning the gate **off** — which is the default and what
9747/// every deployment gets until this is called. Off is not the same as an empty
9748/// table: an empty table is fail-closed and refuses every externally-authored
9749/// fact, which is the core's documented behaviour.
9750async fn handle_memory_set_admission_table(
9751    req: &JsonRpcMessage,
9752    session: &crate::session::ClientSession,
9753) -> Result<Value, String> {
9754    let table: Option<car_verify::OwnershipTable> = match req.params.get("table") {
9755        None | Some(Value::Null) => None,
9756        Some(v) => {
9757            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid table: {e}"))?)
9758        }
9759    };
9760
9761    // Name the surfaces whose rule imposes no real constraint, so an operator
9762    // installing a table that only looks governed hears about it now rather
9763    // than discovering it after something unreviewed became durable.
9764    let ungated: Vec<&'static str> = table
9765        .as_ref()
9766        .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect())
9767        .unwrap_or_default();
9768    let enabled = table.is_some();
9769
9770    let engine_arc = session.effective_memgine().await;
9771    engine_arc.lock().await.set_admission_table(table);
9772
9773    Ok(serde_json::json!({
9774        "enabled": enabled,
9775        "ungated_surfaces": ungated,
9776    }))
9777}
9778
9779/// `memory.admission_table` — read back the installed rules.
9780///
9781/// Returns `{ enabled, table, ungated_surfaces }`. `enabled: false` with a null
9782/// `table` means the gate is off.
9783async fn handle_memory_admission_table(
9784    _req: &JsonRpcMessage,
9785    session: &crate::session::ClientSession,
9786) -> Result<Value, String> {
9787    let engine_arc = session.effective_memgine().await;
9788    let engine = engine_arc.lock().await;
9789    let table = engine.admission_table();
9790    Ok(serde_json::json!({
9791        "enabled": table.is_some(),
9792        "table": table,
9793        "ungated_surfaces": table
9794            .map(|t| t.ungated_surfaces().iter().map(|s| s.as_str()).collect::<Vec<_>>())
9795            .unwrap_or_default(),
9796    }))
9797}
9798
9799async fn handle_memory_add_fact(
9800    req: &JsonRpcMessage,
9801    session: &crate::session::ClientSession,
9802) -> Result<Value, String> {
9803    let subject = req
9804        .params
9805        .get("subject")
9806        .and_then(|v| v.as_str())
9807        .ok_or("missing subject")?;
9808    let body = req
9809        .params
9810        .get("body")
9811        .and_then(|v| v.as_str())
9812        .ok_or("missing body")?;
9813    let kind = req
9814        .params
9815        .get("kind")
9816        .and_then(|v| v.as_str())
9817        .unwrap_or("pattern");
9818    let tags = match req.params.get("tags") {
9819        Some(value) => serde_json::from_value::<Vec<String>>(value.clone())
9820            .map_err(|_| "tags must be an array of strings".to_string())?,
9821        None => vec![],
9822    };
9823    let source = match req.params.get("source") {
9824        Some(Value::String(value)) => value.clone(),
9825        Some(_) => return Err("source must be a string".to_string()),
9826        None => "user".to_string(),
9827    };
9828    // Route through `effective_memgine` so connections bound to a
9829    // lifecycle agent (#169) write into the daemon-owned per-agent
9830    // memgine instead of the per-WS ephemeral one (#170).
9831    // Durable-state admission (car#1031 core, wired here). This is the
9832    // externally-authored write path — a peer hands us a subject and body — so
9833    // it goes through `try_ingest_fact` rather than the raw ingest the engine
9834    // uses for its own derived facts.
9835    //
9836    // `produced_by` is fixed to `Execution` and is deliberately NOT
9837    // caller-settable: a peer calling this surface *is* the execution path,
9838    // and letting it name its own producer would let it satisfy any rule by
9839    // declaring itself whatever the rule expects.
9840    //
9841    // `committed_by` IS caller-supplied, and the limitation is worth stating
9842    // plainly rather than implying more than is true: this surface does not yet
9843    // authenticate the claim. So with a table configured, the gate enforces
9844    // **evidence** (a caller cannot conjure a passing verifier verdict) and
9845    // structure, but not committer **identity**. Binding the committer to the
9846    // authenticated session is follow-up work; until then treat this half as
9847    // bookkeeping, not a security boundary.
9848    let committed_by = req
9849        .params
9850        .get("committed_by")
9851        .and_then(|v| v.as_str())
9852        .map(parse_commit_authority)
9853        .transpose()?;
9854    let verdicts: Vec<car_verify::VerifierVerdict> = match req.params.get("verdicts") {
9855        Some(v) => {
9856            serde_json::from_value(v.clone()).map_err(|e| format!("invalid verdicts: {e}"))?
9857        }
9858        None => Vec::new(),
9859    };
9860
9861    let engine_arc = session.effective_memgine().await;
9862    let count = {
9863        let mut engine = engine_arc.lock().await;
9864        // A caller-supplied id is identity, not a hint. A duplicate errors —
9865        // except when it re-states the fact already stored under it (same
9866        // subject and body): that is the at-least-once retry / daemon-reload
9867        // replay case, which resolves to the existing fact instead of failing
9868        // or silently duplicating. Same id, different content is a genuine
9869        // collision and still errors.
9870        let fid = match req.params.get("fact_id") {
9871            Some(Value::String(value)) if !value.is_empty() => {
9872                let fid = value.clone();
9873                if let Some((_, existing)) = engine.graph.get_by_fact_id(&fid) {
9874                    if existing.key == subject && existing.value == body {
9875                        return Ok(Value::from(engine.valid_fact_count()));
9876                    }
9877                    return Err(format!("fact_id `{fid}` already exists"));
9878                }
9879                fid
9880            }
9881            Some(Value::String(_)) => return Err("fact_id must not be empty".to_string()),
9882            Some(_) => return Err("fact_id must be a string".to_string()),
9883            // Auto-minted `ws-<count>` ids must dodge collisions too: a caller
9884            // may legally pass a literal `ws-7`, and deletions or a reload can
9885            // shrink the count past it. Bump past any id already in the graph
9886            // rather than letting ingest_fact silently rewrite to `ws-7-2`.
9887            None => {
9888                let mut n = engine.valid_fact_count();
9889                loop {
9890                    let candidate = format!("ws-{n}");
9891                    if engine.graph.get_by_fact_id(&candidate).is_none() {
9892                        break candidate;
9893                    }
9894                    n += 1;
9895                }
9896            }
9897        };
9898        let nix = engine
9899            .try_ingest_fact(
9900                &fid,
9901                subject,
9902                body,
9903                "user",
9904                "peer",
9905                chrono::Utc::now(),
9906                "global",
9907                None,
9908                vec![],
9909                kind == "constraint",
9910                car_verify::CommitAuthority::Execution,
9911                committed_by,
9912                verdicts,
9913            )
9914            .map_err(|decision| {
9915                // Surface the refusal reasons rather than a bare "denied" —
9916                // the caller needs to know which half it failed.
9917                format!(
9918                    "memory admission refused for `{}`: {}",
9919                    decision.id,
9920                    serde_json::to_string(&decision.refusals)
9921                        .unwrap_or_else(|_| "<unserialisable>".into())
9922                )
9923            })?;
9924        if let Some(node) = engine.graph.inner.node_weight_mut(nix) {
9925            node.metadata.tags = tags;
9926            node.metadata.provenance = vec![car_memgine::Provenance {
9927                source,
9928                ..Default::default()
9929            }];
9930        }
9931        engine.valid_fact_count()
9932    };
9933    // Persist after every add when the session is bound to a durable graph —
9934    // a supervised agent OR a memory namespace. This went through the agent
9935    // branch only, which is why namespace facts were never written (#82).
9936    persist_bound_agent_memory(session, &engine_arc, "memory.add_fact").await;
9937    Ok(Value::from(count))
9938}
9939
9940/// Flush the session's bound graph after a mutation.
9941///
9942/// Covers BOTH durable axes. It used to handle only `agent_id`, so a
9943/// namespace-bound session accumulated facts that were never written anywhere
9944/// and vanished on daemon restart (car-releases#82) — CarHost restarts on every
9945/// Sparkle update, so that was routine data loss, not an edge case.
9946///
9947/// A session binds at most one of the two: `session.auth` picks the namespace
9948/// graph over the agent graph when both are supplied, and `memory_namespace`
9949/// is recorded on exactly the paths that make that choice. Persisting whichever
9950/// is set therefore writes the graph the session is actually using.
9951async fn persist_bound_agent_memory(
9952    session: &crate::session::ClientSession,
9953    engine_arc: &Arc<Mutex<car_memgine::MemgineEngine>>,
9954    op: &str,
9955) {
9956    if let Some(ns) = session.memory_namespace.lock().await.clone() {
9957        if let Err(e) = persist_namespace_memgine(&ns, engine_arc).await {
9958            tracing::warn!(memory_namespace = %ns, error = %e, op = %op,
9959                "namespace memgine persist failed; in-memory state is canonical");
9960        }
9961        return;
9962    }
9963    if let Some(id) = session.agent_id.lock().await.clone() {
9964        if let Err(e) = persist_agent_memgine(&id, engine_arc).await {
9965            tracing::warn!(agent_id = %id, error = %e, op = %op,
9966                "agent memgine persist failed; in-memory state is canonical");
9967        }
9968    }
9969}
9970
9971#[derive(Debug, Deserialize)]
9972struct MemoryUpdateStatusParams {
9973    body: String,
9974    #[serde(default)]
9975    tenant_id: Option<String>,
9976}
9977
9978async fn handle_memory_update_status(
9979    req: &JsonRpcMessage,
9980    session: &crate::session::ClientSession,
9981) -> Result<Value, String> {
9982    let params: MemoryUpdateStatusParams = typed_params(&req.params)?;
9983    let engine_arc = session.effective_memgine().await;
9984    let status = {
9985        let mut engine = engine_arc.lock().await;
9986        engine.update_proactive_status(params.body, params.tenant_id)
9987    };
9988    serde_json::to_value(status).map_err(|e| e.to_string())
9989}
9990
9991async fn handle_memory_maintain(
9992    req: &JsonRpcMessage,
9993    session: &crate::session::ClientSession,
9994) -> Result<Value, String> {
9995    let params: car_memgine::ProactiveMaintenanceRequest = typed_params(&req.params)?;
9996    let events = {
9997        let log = session.runtime.log.lock().await;
9998        log.events().to_vec()
9999    };
10000    let engine_arc = session.effective_memgine().await;
10001    let report = {
10002        let mut engine = engine_arc.lock().await;
10003        engine.maintain_proactive_memory_from_events(&events, &params)
10004    };
10005    if !report.saved.is_empty() {
10006        persist_bound_agent_memory(session, &engine_arc, "memory.maintain").await;
10007    }
10008    {
10009        let mut log = session.runtime.log.lock().await;
10010        log.append(
10011            car_eventlog::EventKind::ProactiveMemoryMaintained,
10012            None,
10013            None,
10014            proactive_maintenance_event_data(&report),
10015        );
10016    }
10017    serde_json::to_value(report).map_err(|e| e.to_string())
10018}
10019
10020async fn handle_memory_save_knowledge(
10021    req: &JsonRpcMessage,
10022    session: &crate::session::ClientSession,
10023) -> Result<Value, String> {
10024    let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
10025    let engine_arc = session.effective_memgine().await;
10026    let saved = {
10027        let mut engine = engine_arc.lock().await;
10028        engine.save_proactive_knowledge(save)
10029    };
10030    persist_bound_agent_memory(session, &engine_arc, "memory.save_knowledge").await;
10031    serde_json::to_value(saved).map_err(|e| e.to_string())
10032}
10033
10034async fn handle_memory_save_procedural(
10035    req: &JsonRpcMessage,
10036    session: &crate::session::ClientSession,
10037) -> Result<Value, String> {
10038    let save: car_memgine::ProactiveMemorySave = typed_params(&req.params)?;
10039    let engine_arc = session.effective_memgine().await;
10040    let saved = {
10041        let mut engine = engine_arc.lock().await;
10042        engine.save_proactive_procedural(save)
10043    };
10044    persist_bound_agent_memory(session, &engine_arc, "memory.save_procedural").await;
10045    serde_json::to_value(saved).map_err(|e| e.to_string())
10046}
10047
10048#[derive(Debug, Deserialize)]
10049struct MemoryDeleteParams {
10050    id: String,
10051}
10052
10053async fn handle_memory_delete(
10054    req: &JsonRpcMessage,
10055    session: &crate::session::ClientSession,
10056) -> Result<Value, String> {
10057    let params: MemoryDeleteParams = typed_params(&req.params)?;
10058    let engine_arc = session.effective_memgine().await;
10059    let deleted = {
10060        let mut engine = engine_arc.lock().await;
10061        engine.delete_proactive_memory(&params.id)
10062    };
10063    if deleted.deleted
10064        && !matches!(
10065            deleted.kind,
10066            Some(car_memgine::ProactiveMemoryEntryKind::Status)
10067        )
10068    {
10069        persist_bound_agent_memory(session, &engine_arc, "memory.delete").await;
10070    }
10071    serde_json::to_value(deleted).map_err(|e| e.to_string())
10072}
10073
10074async fn handle_memory_query(
10075    req: &JsonRpcMessage,
10076    session: &crate::session::ClientSession,
10077) -> Result<Value, String> {
10078    let query = req
10079        .params
10080        .get("query")
10081        .and_then(|v| v.as_str())
10082        .ok_or("missing query")?;
10083    let k = req.params.get("k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
10084    let engine_arc = session.effective_memgine().await;
10085    let engine = engine_arc.lock().await;
10086    let seeds = engine.graph.find_seeds(query, 5);
10087    // FFI parity with NAPI `query_facts` — both use Personalized PageRank so
10088    // transport choice doesn't shift ranking semantics. The bindings proxy this
10089    // result directly, including caller-authored identity and provenance.
10090    let hits = if !seeds.is_empty() {
10091        engine.graph.retrieve_ppr(&seeds, None, 0.5, k)
10092    } else {
10093        vec![]
10094    };
10095    let results: Vec<Value> = hits
10096        .iter()
10097        .filter_map(|hit| {
10098            let node = engine.graph.inner.node_weight(hit.node_ix)?;
10099            Some(serde_json::json!({
10100                "fact_id": node.fact_id,
10101                "subject": node.key,
10102                "body": node.value,
10103                "kind": format!("{:?}", node.kind).to_lowercase(),
10104                "confidence": hit.activation,
10105                "tags": node.metadata.tags,
10106                "source": node
10107                    .metadata
10108                    .provenance
10109                    .first()
10110                    .map(|entry| entry.source.as_str())
10111                    .unwrap_or(""),
10112            }))
10113        })
10114        .collect();
10115    serde_json::to_value(results).map_err(|e| e.to_string())
10116}
10117
10118/// `memory.intervene` — proactive memory control-loop hook. Returns either a
10119/// concise transient reminder grounded in the graph, or an explicit silence
10120/// decision when no memory is strong enough to interrupt the next action.
10121async fn handle_memory_intervene(
10122    req: &JsonRpcMessage,
10123    session: &crate::session::ClientSession,
10124) -> Result<Value, String> {
10125    let request: car_memgine::ProactiveMemoryRequest = typed_params(&req.params)?;
10126    let engine_arc = session.effective_memgine().await;
10127    let decision = {
10128        let mut engine = engine_arc.lock().await;
10129        engine.proactive_intervention(&request)
10130    };
10131    {
10132        let mut log = session.runtime.log.lock().await;
10133        log.append(
10134            car_eventlog::EventKind::ProactiveMemoryIntervention,
10135            None,
10136            None,
10137            proactive_intervention_event_data(&decision),
10138        );
10139    }
10140    serde_json::to_value(decision).map_err(|e| e.to_string())
10141}
10142
10143/// `memory.evaluate` — offline calibration hook for proactive memory. Runs
10144/// labeled next-action cases against the current memory graph and compares the
10145/// selective selector against always-inject, passive-retrieval, and no-memory
10146/// baselines.
10147async fn handle_memory_evaluate(
10148    req: &JsonRpcMessage,
10149    session: &crate::session::ClientSession,
10150) -> Result<Value, String> {
10151    let request: car_memgine::ProactiveEvaluationRequest = typed_params(&req.params)?;
10152    let engine_arc = session.effective_memgine().await;
10153    let engine = engine_arc.lock().await;
10154    let report = engine.evaluate_proactive_memory(&request);
10155    serde_json::to_value(report).map_err(|e| e.to_string())
10156}
10157
10158async fn maybe_apply_proactive_memory(
10159    msg: &JsonRpcMessage,
10160    session: &crate::session::ClientSession,
10161    req: &mut car_inference::GenerateRequest,
10162) -> Result<(), String> {
10163    let tier = session.permission_gate.read().await.granted_tier();
10164    let mut request = match proactive_memory_activation(&msg.params, req, tier)? {
10165        ProactiveMemoryActivation::Disabled => return Ok(()),
10166        ProactiveMemoryActivation::Default => car_memgine::ProactiveMemoryRequest::default(),
10167        ProactiveMemoryActivation::Request(request) => request,
10168    };
10169
10170    if request.query.trim().is_empty() {
10171        request.query = msg
10172            .params
10173            .get("context_query")
10174            .and_then(|v| v.as_str())
10175            .filter(|s| !s.trim().is_empty())
10176            .unwrap_or(&req.prompt)
10177            .to_string();
10178    }
10179    if request.recent.is_empty() && !req.prompt.trim().is_empty() {
10180        request.recent.push(req.prompt.clone());
10181    }
10182    let events = {
10183        let log = session.runtime.log.lock().await;
10184        log.events().to_vec()
10185    };
10186    let engine_arc = session.effective_memgine().await;
10187    let maintenance = {
10188        let mut engine = engine_arc.lock().await;
10189        engine.maintain_proactive_memory_from_events(
10190            &events,
10191            &car_memgine::ProactiveMaintenanceRequest {
10192                max_recent: 32,
10193                tenant_id: request.tenant_id.clone(),
10194            },
10195        )
10196    };
10197    if !maintenance.saved.is_empty() {
10198        persist_bound_agent_memory(session, &engine_arc, "memory_intervention.maintain").await;
10199    }
10200    {
10201        let mut log = session.runtime.log.lock().await;
10202        log.append(
10203            car_eventlog::EventKind::ProactiveMemoryMaintained,
10204            None,
10205            None,
10206            proactive_maintenance_event_data(&maintenance),
10207        );
10208    }
10209    let mut derived_trigger = maintenance.trigger;
10210    if tier == car_policy::PermissionTier::FullAccess
10211        || req.intent.as_ref().is_some_and(|intent| intent.high_stakes)
10212    {
10213        derived_trigger.high_risk_action = true;
10214    }
10215    request.trigger.merge(derived_trigger);
10216
10217    let decision = {
10218        let mut engine = engine_arc.lock().await;
10219        engine.proactive_intervention(&request)
10220    };
10221    {
10222        let mut log = session.runtime.log.lock().await;
10223        log.append(
10224            car_eventlog::EventKind::ProactiveMemoryIntervention,
10225            None,
10226            None,
10227            proactive_intervention_event_data(&decision),
10228        );
10229    }
10230    if let car_memgine::ProactiveMemoryDecision::Inject { reminder, .. } = decision {
10231        append_context_block(req, "Proactive Memory", &reminder);
10232    }
10233    Ok(())
10234}
10235
10236enum ProactiveMemoryActivation {
10237    Disabled,
10238    Default,
10239    Request(car_memgine::ProactiveMemoryRequest),
10240}
10241
10242fn proactive_memory_activation(
10243    params: &Value,
10244    req: &car_inference::GenerateRequest,
10245    tier: car_policy::PermissionTier,
10246) -> Result<ProactiveMemoryActivation, String> {
10247    match params.get("memory_intervention") {
10248        Some(raw) if raw.as_bool() == Some(false) || raw.is_null() => {
10249            Ok(ProactiveMemoryActivation::Disabled)
10250        }
10251        Some(raw) if raw.as_bool() == Some(true) => Ok(ProactiveMemoryActivation::Default),
10252        Some(raw) => serde_json::from_value(raw.clone())
10253            .map(ProactiveMemoryActivation::Request)
10254            .map_err(|e| format!("invalid memory_intervention: {e}")),
10255        None if should_auto_apply_proactive_memory(req, tier) => {
10256            Ok(ProactiveMemoryActivation::Default)
10257        }
10258        None => Ok(ProactiveMemoryActivation::Disabled),
10259    }
10260}
10261
10262fn should_auto_apply_proactive_memory(
10263    req: &car_inference::GenerateRequest,
10264    tier: car_policy::PermissionTier,
10265) -> bool {
10266    if tier == car_policy::PermissionTier::FullAccess {
10267        return true;
10268    }
10269    if req.tools.as_ref().is_some_and(|tools| !tools.is_empty()) {
10270        return true;
10271    }
10272    let Some(intent) = req.intent.as_ref() else {
10273        return false;
10274    };
10275    if intent.high_stakes {
10276        return true;
10277    }
10278    matches!(
10279        intent.task,
10280        Some(car_inference::TaskHint::Code | car_inference::TaskHint::Reasoning)
10281    )
10282}
10283
10284fn proactive_maintenance_event_data(
10285    report: &car_memgine::ProactiveMaintenanceReport,
10286) -> HashMap<String, Value> {
10287    let mut data = proactive_trigger_event_data(&report.trigger);
10288    data.insert(
10289        "saved_count".to_string(),
10290        Value::from(report.saved.len() as u64),
10291    );
10292    data.insert(
10293        "skipped_existing".to_string(),
10294        Value::from(report.skipped_existing as u64),
10295    );
10296    data.insert(
10297        "status_updated".to_string(),
10298        Value::from(report.status.is_some()),
10299    );
10300    data
10301}
10302
10303fn proactive_intervention_event_data(
10304    decision: &car_memgine::ProactiveMemoryDecision,
10305) -> HashMap<String, Value> {
10306    let mut data = HashMap::new();
10307    match decision {
10308        car_memgine::ProactiveMemoryDecision::Inject {
10309            selected,
10310            candidates,
10311            bank,
10312            ..
10313        } => {
10314            data.insert("decision".to_string(), Value::from("inject"));
10315            data.insert("selected_id".to_string(), Value::from(selected.id.clone()));
10316            data.insert(
10317                "selected_kind".to_string(),
10318                Value::from(format!("{:?}", selected.kind).to_ascii_lowercase()),
10319            );
10320            data.insert(
10321                "candidate_count".to_string(),
10322                Value::from(candidates.len() as u64),
10323            );
10324            data.insert(
10325                "bank_knowledge".to_string(),
10326                Value::from(bank.knowledge as u64),
10327            );
10328            data.insert(
10329                "bank_procedural".to_string(),
10330                Value::from(bank.procedural as u64),
10331            );
10332            data.insert(
10333                "bank_open_subgoals".to_string(),
10334                Value::from(bank.open_subgoals as u64),
10335            );
10336        }
10337        car_memgine::ProactiveMemoryDecision::Silent {
10338            reason,
10339            candidates,
10340            bank,
10341        } => {
10342            data.insert("decision".to_string(), Value::from("silent"));
10343            data.insert("reason".to_string(), Value::from(reason.clone()));
10344            data.insert(
10345                "candidate_count".to_string(),
10346                Value::from(candidates.len() as u64),
10347            );
10348            data.insert(
10349                "bank_knowledge".to_string(),
10350                Value::from(bank.knowledge as u64),
10351            );
10352            data.insert(
10353                "bank_procedural".to_string(),
10354                Value::from(bank.procedural as u64),
10355            );
10356            data.insert(
10357                "bank_open_subgoals".to_string(),
10358                Value::from(bank.open_subgoals as u64),
10359            );
10360        }
10361    }
10362    data
10363}
10364
10365fn proactive_trigger_event_data(
10366    trigger: &car_memgine::ProactiveMemoryTrigger,
10367) -> HashMap<String, Value> {
10368    HashMap::from([
10369        (
10370            "repeated_failures".to_string(),
10371            Value::from(trigger.repeated_failures as u64),
10372        ),
10373        ("tool_error".to_string(), Value::from(trigger.tool_error)),
10374        (
10375            "explicit_uncertainty".to_string(),
10376            Value::from(trigger.explicit_uncertainty),
10377        ),
10378        (
10379            "high_risk_action".to_string(),
10380            Value::from(trigger.high_risk_action),
10381        ),
10382        (
10383            "context_shift".to_string(),
10384            Value::from(trigger.context_shift),
10385        ),
10386    ])
10387}
10388
10389fn append_context_block(req: &mut car_inference::GenerateRequest, title: &str, body: &str) {
10390    let block = format!("## {title}\n{body}");
10391    req.context = Some(match req.context.take() {
10392        Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{block}"),
10393        _ => block,
10394    });
10395}
10396
10397#[cfg(test)]
10398mod proactive_memory_activation_tests {
10399    use super::{proactive_memory_activation, ProactiveMemoryActivation};
10400    use car_inference::{GenerateRequest, IntentHint, TaskHint};
10401    use car_policy::PermissionTier;
10402    use serde_json::json;
10403
10404    fn req() -> GenerateRequest {
10405        GenerateRequest {
10406            prompt: "finish the task".to_string(),
10407            ..Default::default()
10408        }
10409    }
10410
10411    fn is_disabled(activation: ProactiveMemoryActivation) -> bool {
10412        matches!(activation, ProactiveMemoryActivation::Disabled)
10413    }
10414
10415    fn is_default(activation: ProactiveMemoryActivation) -> bool {
10416        matches!(activation, ProactiveMemoryActivation::Default)
10417    }
10418
10419    #[test]
10420    fn explicit_false_disables_even_when_auto_eligible() {
10421        let mut req = req();
10422        req.tools = Some(vec![json!({"name": "edit_file"})]);
10423        let activation = proactive_memory_activation(
10424            &json!({"memory_intervention": false}),
10425            &req,
10426            PermissionTier::FullAccess,
10427        )
10428        .unwrap();
10429        assert!(is_disabled(activation));
10430    }
10431
10432    #[test]
10433    fn explicit_true_uses_default_request() {
10434        let activation = proactive_memory_activation(
10435            &json!({"memory_intervention": true}),
10436            &req(),
10437            PermissionTier::ReadOnly,
10438        )
10439        .unwrap();
10440        assert!(is_default(activation));
10441    }
10442
10443    #[test]
10444    fn explicit_object_is_preserved() {
10445        let activation = proactive_memory_activation(
10446            &json!({
10447                "memory_intervention": {
10448                    "query": "pricing rollout",
10449                    "tenant_id": "tenant-a"
10450                }
10451            }),
10452            &req(),
10453            PermissionTier::ReadOnly,
10454        )
10455        .unwrap();
10456        let ProactiveMemoryActivation::Request(request) = activation else {
10457            panic!("expected explicit request");
10458        };
10459        assert_eq!(request.query, "pricing rollout");
10460        assert_eq!(request.tenant_id.as_deref(), Some("tenant-a"));
10461    }
10462
10463    #[test]
10464    fn ordinary_chat_is_not_auto_enabled() {
10465        let mut req = req();
10466        req.intent = Some(IntentHint {
10467            task: Some(TaskHint::Chat),
10468            ..Default::default()
10469        });
10470        let activation =
10471            proactive_memory_activation(&json!({}), &req, PermissionTier::ReadOnly).unwrap();
10472        assert!(is_disabled(activation));
10473    }
10474
10475    #[test]
10476    fn tool_code_reasoning_and_high_stakes_requests_auto_enable() {
10477        let mut tool_req = req();
10478        tool_req.tools = Some(vec![json!({"name": "shell"})]);
10479        assert!(is_default(
10480            proactive_memory_activation(&json!({}), &tool_req, PermissionTier::ReadOnly).unwrap()
10481        ));
10482
10483        let mut code_req = req();
10484        code_req.intent = Some(IntentHint {
10485            task: Some(TaskHint::Code),
10486            ..Default::default()
10487        });
10488        assert!(is_default(
10489            proactive_memory_activation(&json!({}), &code_req, PermissionTier::ReadOnly).unwrap()
10490        ));
10491
10492        let mut reasoning_req = req();
10493        reasoning_req.intent = Some(IntentHint {
10494            task: Some(TaskHint::Reasoning),
10495            ..Default::default()
10496        });
10497        assert!(is_default(
10498            proactive_memory_activation(&json!({}), &reasoning_req, PermissionTier::ReadOnly)
10499                .unwrap()
10500        ));
10501
10502        assert!(is_default(
10503            proactive_memory_activation(&json!({}), &req(), PermissionTier::FullAccess).unwrap()
10504        ));
10505    }
10506}
10507
10508async fn handle_memory_build_context(
10509    req: &JsonRpcMessage,
10510    session: &crate::session::ClientSession,
10511) -> Result<Value, String> {
10512    let query = req
10513        .params
10514        .get("query")
10515        .and_then(|v| v.as_str())
10516        .unwrap_or("");
10517    // FFI parity with NAPI `build_context(query, model_context_window)`.
10518    // When supplied, sizes the assembly budget against the model's window
10519    // instead of the fixed 8K default.
10520    let model_context_window = req
10521        .params
10522        .get("model_context_window")
10523        .and_then(|v| v.as_u64())
10524        .map(|w| w as usize);
10525    // `effective_memgine`: context assembly must read the graph the session's
10526    // facts actually went into. Reading the ephemeral graph while a namespace
10527    // was bound meant every namespace fact was stored, isolated, queryable —
10528    // and invisible to the model, which is the only consumer that matters.
10529    let engine_arc = session.effective_memgine().await;
10530    let mut engine = engine_arc.lock().await;
10531    Ok(Value::from(
10532        engine.build_context_for_model(query, model_context_window),
10533    ))
10534}
10535
10536/// `memory.build_context_fast` — Fast-mode context assembly for
10537/// latency-sensitive paths (voice, real-time). Skips embedding flush,
10538/// skill lookup, PPR-based scoring, inline repairs, known-unknowns
10539/// extraction. Keeps identity, constraints, facts (creation order),
10540/// conversation, environment.
10541async fn handle_memory_build_context_fast(
10542    req: &JsonRpcMessage,
10543    session: &crate::session::ClientSession,
10544) -> Result<Value, String> {
10545    let query = req
10546        .params
10547        .get("query")
10548        .and_then(|v| v.as_str())
10549        .unwrap_or("");
10550    let model_context_window = req
10551        .params
10552        .get("model_context_window")
10553        .and_then(|v| v.as_u64())
10554        .map(|w| w as usize);
10555    // Same binding fix as build_context above (#82).
10556    let engine_arc = session.effective_memgine().await;
10557    let mut engine = engine_arc.lock().await;
10558    Ok(Value::from(engine.build_context_with_options(
10559        query,
10560        model_context_window,
10561        car_memgine::ContextMode::Fast,
10562        None,
10563    )))
10564}
10565
10566/// `memory.persist` — write the session's memgine to a JSON file
10567/// at `path`. Mirrors NAPI `persist_memory` (car-ffi-napi/src/lib.rs:797)
10568/// so daemon-mode clients can drive checkpoint/restore symmetrically
10569/// with embedded mode. Returns the number of facts written.
10570///
10571/// Filesystem caveat: `path` is interpreted on the daemon's filesystem,
10572/// not the caller's. Since the 2026-05 audit, `path` is also
10573/// sandboxed under `~/.car/memory/` via
10574/// [`car_ffi_common::memory_path::resolve`] — relative paths land
10575/// under the base, absolute paths must already be under the base,
10576/// `..` segments are rejected, symlinks pointing out are rejected.
10577/// Pre-2026-05 the path was passed straight to `std::fs::write` and
10578/// became an arbitrary file-write primitive. The base64-blob escape
10579/// hatch tracked in `Parslee-ai/car-releases#31` will plug into the
10580/// same resolver when it lands.
10581struct ProtocolHandshakeError {
10582    code: i32,
10583    message: String,
10584}
10585
10586/// `server.schema` — return this release's machine-readable wire schema.
10587///
10588/// Read-only, authenticated, and answerable before protocol negotiation: a
10589/// consumer that must validate CAR's journal or RPC results needs the contract
10590/// in order to decide whether it can talk to this daemon at all, so gating it
10591/// behind the handshake would be circular. The document is the exact artifact
10592/// compiled into this binary, never regenerated at runtime, and it carries a
10593/// SHA-256 digest a consumer can pin per accepted release.
10594pub(crate) fn handle_server_schema() -> Result<Value, String> {
10595    crate::wire_schema::committed_payload()
10596}
10597
10598/// Negotiate this connection's daemon JSON-RPC protocol.
10599///
10600/// Protocol v3 is exact-version only. Capability negotiation follows transport
10601/// authentication and rejects any unsupported mandatory capability. The
10602/// negotiated version and capabilities are stored on
10603/// `ClientSession`, never `ServerState`, so a transport reconnect cannot inherit
10604/// another socket's compatibility proof. Repeating the same handshake is
10605/// intentionally idempotent.
10606fn handle_server_handshake(
10607    req: &JsonRpcMessage,
10608    session: &crate::session::ClientSession,
10609) -> Result<Value, ProtocolHandshakeError> {
10610    let client_protocol = req
10611        .params
10612        .get("protocol_version")
10613        .and_then(Value::as_u64)
10614        .ok_or_else(|| ProtocolHandshakeError {
10615            code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10616            message: format!(
10617                "{} `server.handshake` requires an unsigned numeric \
10618                 `protocol_version`; this daemon requires v{}",
10619                car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10620                car_proto::PROTOCOL_VERSION,
10621            ),
10622        })?;
10623
10624    if client_protocol != u64::from(car_proto::PROTOCOL_VERSION) {
10625        return Err(ProtocolHandshakeError {
10626            code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10627            message: format!(
10628                "{} client requested v{}, but this daemon requires v{}; \
10629                 update/restart CarHost and car-server so their wire protocols match",
10630                car_proto::PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX,
10631                client_protocol,
10632                car_proto::PROTOCOL_VERSION,
10633            ),
10634        });
10635    }
10636
10637    fn string_array(params: &Value, field: &str) -> Result<Vec<String>, ProtocolHandshakeError> {
10638        let Some(value) = params.get(field) else {
10639            return Ok(Vec::new());
10640        };
10641        let values = value.as_array().ok_or_else(|| ProtocolHandshakeError {
10642            code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10643            message: format!(
10644                "{} `{field}` must be an array of capability strings",
10645                car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10646            ),
10647        })?;
10648        values
10649            .iter()
10650            .map(|value| {
10651                value
10652                    .as_str()
10653                    .filter(|capability| !capability.trim().is_empty())
10654                    .map(str::to_string)
10655                    .ok_or_else(|| ProtocolHandshakeError {
10656                        code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10657                        message: format!(
10658                            "{} `{field}` entries must be non-empty strings",
10659                            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10660                        ),
10661                    })
10662            })
10663            .collect()
10664    }
10665
10666    let required_capabilities = string_array(&req.params, "required_capabilities")?;
10667    let optional_capabilities = string_array(&req.params, "optional_capabilities")?;
10668    let negotiated_capabilities =
10669        car_proto::negotiate_capabilities(&required_capabilities, &optional_capabilities).map_err(
10670            |missing| ProtocolHandshakeError {
10671                code: car_proto::PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE,
10672                message: format!(
10673            "{} unsupported mandatory capabilities: {}; update/restart CarHost and car-server",
10674            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
10675            missing.join(", "),
10676        ),
10677            },
10678        )?;
10679
10680    session.negotiated_protocol_version.store(
10681        car_proto::PROTOCOL_VERSION,
10682        std::sync::atomic::Ordering::Release,
10683    );
10684    *session
10685        .negotiated_capabilities
10686        .write()
10687        .expect("negotiated capability lock poisoned") =
10688        negotiated_capabilities.iter().cloned().collect();
10689
10690    // The assistant's identity rides on the handshake every host already
10691    // performs, so a host knows what to call the assistant before it renders a
10692    // single label. `load_or_default` deliberately keeps a damaged optional
10693    // identity record from locking every host out of the daemon; the dedicated
10694    // identity RPC remains the surface that reports that read error.
10695    let identity = car_identity::IdentityStore::from_home().load_or_default();
10696
10697    // Echo the client's own build version back. The Rust clients have always
10698    // sent it and this daemon always threw it away, so the only place a
10699    // `car-runtime` package's version ever appeared was inside the skew
10700    // warning's prose — and `car --version` answers for the bundled CLI, a
10701    // different component (Parslee-ai/car#1050). Echoing makes it a readable
10702    // field on the handshake every client already performs. Additive, so no
10703    // PROTOCOL_VERSION bump.
10704    //
10705    // `unknown` is the honest answer, not a placeholder to read past: the
10706    // native CarHost clients (macOS, iOS, Android) and the dashboard send no
10707    // `client_version`, so they land here. Do not treat this log line or field
10708    // as identifying every connect — only the ones that report.
10709    //
10710    // Truncated because it is client-controlled and otherwise unbounded. A
10711    // semver plus pre-release metadata fits well inside this; anything longer
10712    // is not a version, and it should not reach the log or the reply at full
10713    // length.
10714    const MAX_CLIENT_VERSION: usize = 64;
10715    let reported = req
10716        .params
10717        .get("client_version")
10718        .and_then(Value::as_str)
10719        .unwrap_or("unknown");
10720    let client_version = match reported.char_indices().nth(MAX_CLIENT_VERSION) {
10721        Some((cut, _)) => &reported[..cut],
10722        None => reported,
10723    };
10724    tracing::debug!(
10725        target: "car_server_core::handler",
10726        client_version,
10727        server_version = env!("CARGO_PKG_VERSION"),
10728        "server.handshake negotiated"
10729    );
10730
10731    // Built through `ServerHandshakeResult`, not an inline `json!`, because
10732    // `docs/wire-schema.json` is generated from that type: a literal here could
10733    // gain a field without the published schema or its digest changing, which
10734    // is the 0.53.0 handshake break the schema exists to prevent.
10735    serde_json::to_value(crate::wire_schema::ServerHandshakeResult {
10736        protocol_version: car_proto::PROTOCOL_VERSION,
10737        server_version: env!("CARGO_PKG_VERSION").to_string(),
10738        client_protocol_version: client_protocol,
10739        client_version: client_version.to_string(),
10740        negotiated_capabilities,
10741        assistant_name: identity.name.clone(),
10742        assistant_aliases: identity.aliases(),
10743        assistant_brand: car_identity::BRAND_NAME.to_string(),
10744    })
10745    .map_err(|error| ProtocolHandshakeError {
10746        code: car_proto::PROTOCOL_VERSION_MISMATCH_ERROR_CODE,
10747        message: format!("server.handshake reply does not serialize: {error}"),
10748    })
10749}
10750
10751async fn handle_memory_persist(
10752    req: &JsonRpcMessage,
10753    session: &crate::session::ClientSession,
10754) -> Result<Value, String> {
10755    let path = req
10756        .params
10757        .get("path")
10758        .and_then(|v| v.as_str())
10759        .ok_or("missing path")?;
10760    let resolved = car_ffi_common::memory_path::resolve(path)
10761        .map_err(|e| format!("memory.persist rejected path {path:?}: {e}"))?;
10762    // `effective_memgine`, not the ephemeral `memgine`: with a namespace or an
10763    // agent bound, reading the ephemeral graph serialized an EMPTY array and
10764    // reported 0 records while the real facts sat in the bound graph
10765    // (car-releases#82). A durability call that reports success having written
10766    // nothing is worse than one that fails.
10767    let engine_arc = session.effective_memgine().await;
10768    let engine = engine_arc.lock().await;
10769    let facts = memory_snapshot_facts(&engine);
10770    let count = facts.len();
10771    // Release the shared memgine lock, then serialize+write on the blocking pool
10772    // (see persist_agent_memgine): never hold the lock across blocking I/O, keep
10773    // the write off the tokio worker, and let the deadline preempt at the await.
10774    drop(engine);
10775    let resolved_owned = resolved.clone();
10776    tokio::task::spawn_blocking(move || -> Result<(), String> {
10777        let json = serde_json::to_string(&facts).map_err(|e| e.to_string())?;
10778        atomic_write_sync(&resolved_owned, json.as_bytes())
10779            .map_err(|e| format!("failed to write {}: {}", resolved_owned.display(), e))
10780    })
10781    .await
10782    .map_err(|e| format!("memory.persist join: {e}"))??;
10783    Ok(Value::from(count as u64))
10784}
10785
10786/// `memory.load` — replace the session's memgine with facts from the
10787/// JSON file at `path`. Mirrors NAPI `load_memory`
10788/// (car-ffi-napi/src/lib.rs:121). Same `~/.car/memory/` sandboxing
10789/// as `memory.persist` since the 2026-05 audit — relative paths
10790/// land under the base, anything that escapes is rejected.
10791async fn handle_memory_load(
10792    req: &JsonRpcMessage,
10793    session: &crate::session::ClientSession,
10794) -> Result<Value, String> {
10795    let path = req
10796        .params
10797        .get("path")
10798        .and_then(|v| v.as_str())
10799        .ok_or("missing path")?;
10800    let resolved = car_ffi_common::memory_path::resolve(path)
10801        .map_err(|e| format!("memory.load rejected path {path:?}: {e}"))?;
10802    let content = std::fs::read_to_string(&resolved)
10803        .map_err(|e| format!("failed to read {}: {}", resolved.display(), e))?;
10804    let facts: Vec<Value> =
10805        serde_json::from_str(&content).map_err(|e| format!("invalid JSON: {}", e))?;
10806    // Symmetric with memory.persist above. This one was worse than a no-op:
10807    // `reset()` cleared the EPHEMERAL graph and loaded into it, so a
10808    // namespace-bound caller restoring a snapshot silently left the bound
10809    // graph untouched and believed it had been restored.
10810    let engine_arc = session.effective_memgine().await;
10811    let mut engine = engine_arc.lock().await;
10812    engine.reset();
10813    let mut count: u32 = 0;
10814    for fact in &facts {
10815        ingest_snapshot_fact(&mut engine, fact, format!("loaded-{count}"));
10816        count += 1;
10817    }
10818    Ok(Value::from(count))
10819}
10820
10821// --- Skill handlers ---
10822
10823async fn handle_skill_ingest(
10824    req: &JsonRpcMessage,
10825    session: &crate::session::ClientSession,
10826) -> Result<Value, String> {
10827    let name = req
10828        .params
10829        .get("name")
10830        .and_then(|v| v.as_str())
10831        .ok_or("missing name")?;
10832    let code = req
10833        .params
10834        .get("code")
10835        .and_then(|v| v.as_str())
10836        .ok_or("missing code")?;
10837    let platform = req
10838        .params
10839        .get("platform")
10840        .and_then(|v| v.as_str())
10841        .unwrap_or("unknown");
10842    let persona = req
10843        .params
10844        .get("persona")
10845        .and_then(|v| v.as_str())
10846        .unwrap_or("");
10847    let url_pattern = req
10848        .params
10849        .get("url_pattern")
10850        .and_then(|v| v.as_str())
10851        .unwrap_or("");
10852    let description = req
10853        .params
10854        .get("description")
10855        .and_then(|v| v.as_str())
10856        .unwrap_or("");
10857    let supersedes = opt_str(&req.params, "supersedes");
10858    let keywords: Vec<String> = req
10859        .params
10860        .get("task_keywords")
10861        .and_then(|v| v.as_array())
10862        .map(|arr| {
10863            arr.iter()
10864                .filter_map(|v| v.as_str().map(String::from))
10865                .collect()
10866        })
10867        .unwrap_or_default();
10868
10869    let trigger = car_memgine::SkillTrigger {
10870        persona: persona.into(),
10871        url_pattern: url_pattern.into(),
10872        task_keywords: keywords,
10873        structured: None,
10874    };
10875    let mut engine = session.memgine.lock().await;
10876    let node = engine.ingest_skill(
10877        name,
10878        code,
10879        platform,
10880        trigger,
10881        description,
10882        supersedes,
10883        vec![],
10884        vec![],
10885    );
10886    Ok(Value::from(node.index() as u64))
10887}
10888
10889async fn handle_skill_find(
10890    req: &JsonRpcMessage,
10891    session: &crate::session::ClientSession,
10892) -> Result<Value, String> {
10893    let persona = req
10894        .params
10895        .get("persona")
10896        .and_then(|v| v.as_str())
10897        .unwrap_or("");
10898    let url = str_or(&req.params, "url", "");
10899    let task = req
10900        .params
10901        .get("task")
10902        .and_then(|v| v.as_str())
10903        .unwrap_or("");
10904    let max = req
10905        .params
10906        .get("max_results")
10907        .and_then(|v| v.as_u64())
10908        .unwrap_or(1) as usize;
10909    // Tenant-scoped matching (linus review C-3): a bound/param tenant
10910    // sees only its own skills; an unscoped caller sees only unscoped
10911    // skills. Strict isolation over the WS boundary, consistent with
10912    // state.keys/state.snapshot — the legacy see-everything find_skill
10913    // remains available to in-process embedders only.
10914    let tenant = effective_tenant(req, session).await?;
10915    let engine = session.memgine.lock().await;
10916    let results = engine.find_skill_scoped(persona, url, task, max, tenant.as_deref());
10917    let json: Vec<Value> = results
10918        .iter()
10919        .map(|(m, s)| {
10920            serde_json::json!({
10921                "name": m.name, "code": m.code, "platform": m.platform,
10922                "description": m.description, "stats": m.stats, "match_score": s,
10923            })
10924        })
10925        .collect();
10926    serde_json::to_value(json).map_err(|e| e.to_string())
10927}
10928
10929async fn handle_skill_report(
10930    req: &JsonRpcMessage,
10931    session: &crate::session::ClientSession,
10932) -> Result<Value, String> {
10933    let name = req
10934        .params
10935        .get("skill_name")
10936        .and_then(|v| v.as_str())
10937        .ok_or("missing skill_name")?;
10938    let outcome_str = req
10939        .params
10940        .get("outcome")
10941        .and_then(|v| v.as_str())
10942        .ok_or("missing outcome")?;
10943    let outcome = match outcome_str {
10944        "success" => car_memgine::SkillOutcome::Success,
10945        _ => car_memgine::SkillOutcome::Fail,
10946    };
10947    // Tenant-exact resolution (linus review): one tenant's outcome
10948    // reports must not degrade another tenant's same-named skill.
10949    let tenant = effective_tenant(req, session).await?;
10950    let mut engine = session.memgine.lock().await;
10951    let stats = engine
10952        .report_outcome_scoped(name, outcome, tenant.as_deref())
10953        .ok_or(format!("skill '{}' not found", name))?;
10954    serde_json::to_value(stats).map_err(|e| e.to_string())
10955}
10956
10957/// Gate a skill's deployment capability against its provenance, folding the
10958/// named skill's **live** track record into the decision (arXiv 2602.12430
10959/// "Agent Skills"; `docs/proposals/skill-trust-governance.md`). The host
10960/// supplies the static `provenance` (signature/scan/source — typically built
10961/// from a `car-bundle` manifest via `assess_signature_trust`) and the
10962/// `requested_tier`; the engine overrides the lifecycle counts with the skill's
10963/// real `success_count`/`fail_count`, so a skill failing in the field is denied
10964/// despite an official signature. Returns the `SkillDeploymentDecision` JSON.
10965async fn handle_skill_gate_deployment(
10966    req: &JsonRpcMessage,
10967    session: &crate::session::ClientSession,
10968) -> Result<Value, String> {
10969    let name = req
10970        .params
10971        .get("skill_name")
10972        .and_then(|v| v.as_str())
10973        .ok_or("missing skill_name")?;
10974    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
10975        req.params
10976            .get("provenance")
10977            .cloned()
10978            .unwrap_or_else(|| serde_json::json!({})),
10979    )
10980    .map_err(|e| format!("invalid provenance: {e}"))?;
10981    let tier_str = req
10982        .params
10983        .get("requested_tier")
10984        .and_then(|v| v.as_str())
10985        .ok_or("missing requested_tier")?;
10986    let requested =
10987        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
10988            format!(
10989                "invalid requested_tier '{tier_str}' \
10990                 (expected read_only|sandbox_edit|full_access)"
10991            )
10992        })?;
10993    let engine = session.memgine.lock().await;
10994    let decision = engine.gate_skill_deployment(name, provenance, requested);
10995    serde_json::to_value(decision).map_err(|e| e.to_string())
10996}
10997
10998/// Enforce a skill's deployment at load time against the session's durable
10999/// `ApprovalLedger` — the HITL bridge (arXiv 2602.12430 "Agent Skills" Slice 4;
11000/// `docs/proposals/skill-trust-governance.md`). Gates the skill (folding its
11001/// live track record), then resolves the verdict against standing operator
11002/// decisions: `Allow`/`Downgrade` deploy autonomously (a downgrade is the gate's
11003/// own safe mitigation), a `Deny` is overridden if an operator previously
11004/// approved it, blocked if rejected, or surfaced as a `pending_approval` the host
11005/// routes through the existing `permission.approve`/`permission.reject` flow by
11006/// the returned `fingerprint`. Returns `{ decision, enforcement, pending_approval? }`.
11007async fn handle_skill_enforce_deployment(
11008    req: &JsonRpcMessage,
11009    session: &crate::session::ClientSession,
11010    state: &ServerState,
11011) -> Result<Value, String> {
11012    let name = req
11013        .params
11014        .get("skill_name")
11015        .and_then(|v| v.as_str())
11016        .ok_or("missing skill_name")?;
11017    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
11018        req.params
11019            .get("provenance")
11020            .cloned()
11021            .unwrap_or_else(|| serde_json::json!({})),
11022    )
11023    .map_err(|e| format!("invalid provenance: {e}"))?;
11024    let tier_str = req
11025        .params
11026        .get("requested_tier")
11027        .and_then(|v| v.as_str())
11028        .ok_or("missing requested_tier")?;
11029    let requested =
11030        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11031            format!(
11032                "invalid requested_tier '{tier_str}' \
11033                 (expected read_only|sandbox_edit|full_access)"
11034            )
11035        })?;
11036
11037    // Gate first (folds the skill's live success/fail record into the verdict).
11038    let decision = {
11039        let engine = session.memgine.lock().await;
11040        engine.gate_skill_deployment(name, provenance, requested)
11041    };
11042    // Then enforce against the daemon's SHARED durable HITL ledger — the same
11043    // ledger `permission.*` records decisions to, from ANY connection (C1).
11044    let enforcement = {
11045        let ledger = state.approval_ledger.read().await;
11046        car_policy::skill_trust::enforce_deployment(&decision, name, requested, &ledger)
11047    };
11048
11049    // Extract the pending fingerprint before the values are moved into the
11050    // response (so the host can resolve it via permission.approve/reject).
11051    let pending_json = enforcement.pending.as_ref().map(|p| {
11052        serde_json::json!({
11053            "fingerprint": p.fingerprint,
11054            "skill_name": p.skill_name,
11055            "requested_tier": requested.as_str(),
11056        })
11057    });
11058    let mut resp = serde_json::json!({ "decision": decision, "enforcement": enforcement });
11059    if let Some(pj) = pending_json {
11060        resp.as_object_mut()
11061            .unwrap()
11062            .insert("pending_approval".into(), pj);
11063    }
11064    Ok(resp)
11065}
11066
11067/// Ingest a skill **through the deployment gate** (arXiv 2602.12430 "Agent
11068/// Skills" — the loader integration). Gates the skill's provenance against the
11069/// requested capability, enforces the verdict against the session's durable
11070/// `ApprovalLedger`, and **only adds the skill to the graph when deployment is
11071/// permitted** — stamping the granted ceiling onto the skill so later execution
11072/// honours it. A denied skill is not ingested; an unseen deny surfaces a
11073/// `pending_approval` resolved through `permission.approve`/`permission.reject`.
11074/// Same flat skill fields as `skill.ingest`, plus `provenance?` and
11075/// `requested_tier`. Returns `{ ingested, node?, decision, enforcement,
11076/// pending_approval? }`.
11077async fn handle_skill_ingest_governed(
11078    req: &JsonRpcMessage,
11079    session: &crate::session::ClientSession,
11080    state: &ServerState,
11081) -> Result<Value, String> {
11082    let name = req
11083        .params
11084        .get("name")
11085        .and_then(|v| v.as_str())
11086        .ok_or("missing name")?;
11087    let code = req
11088        .params
11089        .get("code")
11090        .and_then(|v| v.as_str())
11091        .ok_or("missing code")?;
11092    let platform = req
11093        .params
11094        .get("platform")
11095        .and_then(|v| v.as_str())
11096        .unwrap_or("unknown");
11097    let persona = req
11098        .params
11099        .get("persona")
11100        .and_then(|v| v.as_str())
11101        .unwrap_or("");
11102    let url_pattern = req
11103        .params
11104        .get("url_pattern")
11105        .and_then(|v| v.as_str())
11106        .unwrap_or("");
11107    let description = req
11108        .params
11109        .get("description")
11110        .and_then(|v| v.as_str())
11111        .unwrap_or("");
11112    let supersedes = opt_str(&req.params, "supersedes");
11113    let keywords: Vec<String> = req
11114        .params
11115        .get("task_keywords")
11116        .and_then(|v| v.as_array())
11117        .map(|arr| {
11118            arr.iter()
11119                .filter_map(|v| v.as_str().map(String::from))
11120                .collect()
11121        })
11122        .unwrap_or_default();
11123    let trigger = car_memgine::SkillTrigger {
11124        persona: persona.into(),
11125        url_pattern: url_pattern.into(),
11126        task_keywords: keywords,
11127        structured: None,
11128    };
11129
11130    let provenance: car_policy::skill_trust::SkillProvenance = serde_json::from_value(
11131        req.params
11132            .get("provenance")
11133            .cloned()
11134            .unwrap_or_else(|| serde_json::json!({})),
11135    )
11136    .map_err(|e| format!("invalid provenance: {e}"))?;
11137    let tier_str = req
11138        .params
11139        .get("requested_tier")
11140        .and_then(|v| v.as_str())
11141        .ok_or("missing requested_tier")?;
11142    let requested =
11143        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11144            format!(
11145                "invalid requested_tier '{tier_str}' \
11146                 (expected read_only|sandbox_edit|full_access)"
11147            )
11148        })?;
11149
11150    // Gate + enforce against the daemon's SHARED durable ledger (C1),
11151    // ingesting only on a deploy verdict. Holding the ledger read-lock across
11152    // the engine ingest is safe: no handler holds the engine and
11153    // approval-ledger locks in the opposite order, so there is no inversion
11154    // to deadlock against.
11155    let ledger = state.approval_ledger.read().await;
11156    let out = {
11157        let mut engine = session.memgine.lock().await;
11158        engine.ingest_skill_governed(
11159            name,
11160            code,
11161            platform,
11162            trigger,
11163            description,
11164            supersedes,
11165            vec![],
11166            vec![],
11167            provenance,
11168            requested,
11169            &ledger,
11170        )
11171    };
11172    drop(ledger);
11173
11174    let pending_json = out.enforcement.pending.as_ref().map(|p| {
11175        serde_json::json!({
11176            "fingerprint": p.fingerprint,
11177            "skill_name": p.skill_name,
11178            "requested_tier": requested.as_str(),
11179        })
11180    });
11181    let mut resp = serde_json::json!({
11182        "ingested": out.ingested.is_some(),
11183        "node": out.ingested.map(|n| n.index()),
11184        "decision": out.decision,
11185        "enforcement": out.enforcement,
11186    });
11187    if let Some(pj) = pending_json {
11188        resp.as_object_mut()
11189            .unwrap()
11190            .insert("pending_approval".into(), pj);
11191    }
11192    Ok(resp)
11193}
11194
11195/// Resolve the [`car_policy::skill_trust::SkillProvenance`] a `skill.adopt_pack`
11196/// call is governed under, from the three mutually-exclusive shapes a caller may
11197/// send. Pure (no I/O, no locks) so the precedence is unit-testable:
11198///
11199/// 1. `manifest` — the signed `car-bundle` manifest the pack shipped in. The
11200///    **derived** path: `signed`/`signer_trusted` are computed from it against
11201///    the operator's `trusted` keyring; the caller cannot assert either flag.
11202/// 2. `provenance` — a caller-assembled `SkillProvenance`, taken at **face
11203///    value**: nothing here clamps or cross-checks it, so any caller that can
11204///    reach this method can assert any tier, up to `official` + `full_access`.
11205///    It is a trusted-caller escape hatch for hosts carrying their own
11206///    attestation, inherited verbatim from `skill.ingest_governed`, and it is
11207///    why the operator-keyring rule constrains the `manifest` path only.
11208/// 3. Neither — unsigned and untrusted, with only the scan/source hints the
11209///    caller passed. The conservative default: the gate sees no signature.
11210///
11211/// Sending both `manifest` and `provenance` is an **error**, not a precedence
11212/// question. Silently preferring one would drop a security-relevant input the
11213/// caller believed was in force.
11214fn resolve_adopt_provenance(
11215    params: &Value,
11216    trusted: &[String],
11217) -> Result<car_policy::skill_trust::SkillProvenance, String> {
11218    let scanned = params
11219        .get("scanned")
11220        .and_then(|v| v.as_bool())
11221        .unwrap_or(false);
11222    let vulnerabilities = params
11223        .get("vulnerabilities")
11224        .and_then(|v| v.as_u64())
11225        .unwrap_or(0);
11226    let source_str = params
11227        .get("source")
11228        .and_then(|v| v.as_str())
11229        .unwrap_or("unknown");
11230    let source: car_policy::skill_trust::SkillSource =
11231        serde_json::from_value(Value::String(source_str.to_string())).map_err(|_| {
11232            format!(
11233                "invalid source '{source_str}' \
11234                 (expected official|first_party|community|unknown)"
11235            )
11236        })?;
11237
11238    let manifest_val = params.get("manifest");
11239    let provenance_val = params.get("provenance");
11240    if manifest_val.is_some() && provenance_val.is_some() {
11241        return Err(
11242            "pass either 'manifest' (signature derived from the bundle) or \
11243                    'provenance' (caller-assembled), not both"
11244                .into(),
11245        );
11246    }
11247
11248    if let Some(mv) = manifest_val {
11249        let manifest: car_registry::manifest::AgentManifest =
11250            serde_json::from_value(mv.clone()).map_err(|e| format!("invalid manifest: {e}"))?;
11251        return Ok(car_memgine::MemgineEngine::skill_provenance_from_bundle(
11252            &manifest,
11253            trusted,
11254            scanned,
11255            vulnerabilities,
11256            source,
11257        ));
11258    }
11259
11260    if let Some(pv) = provenance_val {
11261        return serde_json::from_value(pv.clone()).map_err(|e| format!("invalid provenance: {e}"));
11262    }
11263
11264    Ok(car_policy::skill_trust::SkillProvenance {
11265        scanned,
11266        vulnerabilities,
11267        source,
11268        ..Default::default()
11269    })
11270}
11271
11272/// Adopt an installed **skill pack** through the skill-trust deployment gate —
11273/// the daemon call-site for governed pack adoption (arXiv 2602.12430 "Agent
11274/// Skills"; `Parslee-ai/car` `docs/proposals/skill-trust-governance.md`).
11275/// Until this method existed, `ApprovedSkillPack::materialize_into_memgine_governed`
11276/// was reachable only by an in-process Rust consumer supplying its own ledger,
11277/// so a daemon host could not adopt a pack under governance at all.
11278///
11279/// Governance is **unconditional**: there is no ungoverned mode on this method.
11280/// Every skill in the pack is ingested through the gate under one provenance
11281/// (packs are signed and scanned as a unit) and one `requested_tier`, and a
11282/// `Deny` skill never enters the graph.
11283///
11284/// **Adoption is per-skill, so a pack can adopt partially.** The verdict is
11285/// computed per skill (the approval fingerprint is a function of skill name +
11286/// requested tier), so the skills that deploy land in `loaded` while the rest
11287/// surface in `pending`/`refused`; the host resolves those and re-adopts the
11288/// same pack. All-or-nothing would let one unresolved skill block the pack.
11289///
11290/// Provenance comes from the bundle `manifest`, whose signature is verified
11291/// against the operator's `.car/config.toml` `trusted_skill_signers` keyring
11292/// ([`seed_trusted_skill_signers`] — never from the request), or from a
11293/// caller-assembled `provenance`, defaulting conservatively to
11294/// unsigned/unscanned when neither is sent. Only the `manifest` path derives
11295/// its signature flags; a `provenance` object is taken at face value, so a
11296/// caller that can reach this method can assert any trust tier (the
11297/// `skill.ingest_governed` contract, unchanged here) — see
11298/// [`resolve_adopt_provenance`].
11299///
11300/// Params: `pack` (an `ApprovedSkillPack`), `requested_tier?` (default
11301/// `read_only`), and either `manifest?` or `provenance?`, plus optional
11302/// `scanned?`/`vulnerabilities?`/`source?`. Returns `{ loaded, pending, refused,
11303/// requested_tier, provenance, trusted_signers }`; an unseen deny surfaces in
11304/// `pending` with a `fingerprint` the host resolves via
11305/// `permission.approve`/`permission.reject` and then re-adopts.
11306async fn handle_skill_adopt_pack(
11307    req: &JsonRpcMessage,
11308    session: &crate::session::ClientSession,
11309    state: &ServerState,
11310) -> Result<Value, String> {
11311    let pack_val = req.params.get("pack").ok_or("missing 'pack' parameter")?;
11312    let pack: car_memgine::ApprovedSkillPack =
11313        serde_json::from_value(pack_val.clone()).map_err(|e| format!("invalid pack: {e}"))?;
11314    let tier_str = req
11315        .params
11316        .get("requested_tier")
11317        .and_then(|v| v.as_str())
11318        .unwrap_or("read_only");
11319    let requested =
11320        car_policy::permission::PermissionTier::from_str_opt(tier_str).ok_or_else(|| {
11321            format!(
11322                "invalid requested_tier '{tier_str}' \
11323                 (expected read_only|sandbox_edit|full_access)"
11324            )
11325        })?;
11326
11327    // The keyring is operator config, never request input — see
11328    // `seed_trusted_skill_signers`.
11329    let trusted = seed_trusted_skill_signers();
11330    let provenance = resolve_adopt_provenance(&req.params, &trusted)?;
11331
11332    // Gate + enforce against the daemon's SHARED durable ledger (C1). Holding
11333    // the ledger read-lock across the engine materialization is safe: no
11334    // handler holds the engine and approval-ledger locks in the opposite
11335    // order, so there is no inversion to deadlock against.
11336    let ledger = state.approval_ledger.read().await;
11337    let out = {
11338        let mut engine = session.memgine.lock().await;
11339        pack.materialize_into_memgine_governed(&mut engine, &provenance, requested, &ledger)
11340    };
11341    drop(ledger);
11342
11343    let pending: Vec<Value> = out
11344        .pending
11345        .iter()
11346        .map(|(skill_id, fingerprint)| {
11347            serde_json::json!({ "skill_id": skill_id, "fingerprint": fingerprint })
11348        })
11349        .collect();
11350    let refused: Vec<Value> = out
11351        .refused
11352        .iter()
11353        .map(|(skill_id, reason)| serde_json::json!({ "skill_id": skill_id, "reason": reason }))
11354        .collect();
11355
11356    Ok(serde_json::json!({
11357        "loaded": out.loaded,
11358        "pending": pending,
11359        "refused": refused,
11360        "requested_tier": requested.as_str(),
11361        "provenance": provenance,
11362        // The COUNT, deliberately — never the key ids. It answers the one
11363        // question an operator debugging a downgrade has ("your keyring is
11364        // empty, that is why a signed pack stopped at Verified") without
11365        // echoing configured identifiers back over the wire to whoever asked.
11366        "trusted_signers": trusted.len(),
11367    }))
11368}
11369
11370// ---------------------------------------------------------------------------
11371// Multi-agent coordination handlers
11372//
11373// The WsAgentRunner sends a `multi.run_agent` JSON-RPC request to the client.
11374// The client runs the model loop and responds with AgentOutput JSON.
11375// ---------------------------------------------------------------------------
11376
11377/// AgentRunner backed by WebSocket callback to the client.
11378struct WsAgentRunner {
11379    channel: Arc<WsChannel>,
11380    host: Arc<crate::host::HostState>,
11381    client_id: String,
11382}
11383
11384#[async_trait::async_trait]
11385impl car_multi::AgentRunner for WsAgentRunner {
11386    async fn run(
11387        &self,
11388        spec: &car_multi::AgentSpec,
11389        task: &str,
11390        _runtime: &car_engine::Runtime,
11391        _mailbox: &car_multi::Mailbox,
11392    ) -> std::result::Result<car_multi::AgentOutput, car_multi::MultiError> {
11393        use futures::SinkExt;
11394
11395        let request_id = self.channel.next_request_id();
11396        let agent_id = agent_id_for_run(&self.client_id, &spec.name, &request_id);
11397        let agent = self
11398            .host
11399            .register_agent(
11400                &self.client_id,
11401                RegisterHostAgentRequest {
11402                    id: Some(agent_id.clone()),
11403                    name: spec.name.clone(),
11404                    kind: "callback".to_string(),
11405                    capabilities: spec.tools.clone(),
11406                    project: spec
11407                        .metadata
11408                        .get("project")
11409                        .and_then(|v| v.as_str())
11410                        .map(str::to_string),
11411                    pid: None,
11412                    display: serde_json::from_value(
11413                        spec.metadata
11414                            .get("display")
11415                            .cloned()
11416                            .unwrap_or(serde_json::Value::Null),
11417                    )
11418                    .unwrap_or_default(),
11419                    metadata: serde_json::to_value(&spec.metadata).unwrap_or(Value::Null),
11420                },
11421            )
11422            .await
11423            .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e))?;
11424        let _ = self
11425            .host
11426            .set_status(
11427                &self.client_id,
11428                SetHostAgentStatusRequest {
11429                    agent_id: agent.id.clone(),
11430                    status: HostAgentStatus::Running,
11431                    current_task: Some(task.to_string()),
11432                    message: Some(format!("{} started", spec.name)),
11433                    payload: serde_json::json!({ "task": task }),
11434                },
11435            )
11436            .await;
11437
11438        let rpc_request = serde_json::json!({
11439            "jsonrpc": "2.0",
11440            "method": "multi.run_agent",
11441            "params": {
11442                "spec": spec,
11443                "task": task,
11444            },
11445            "id": request_id,
11446        });
11447
11448        // Create oneshot channel for the response
11449        let (tx, rx) = tokio::sync::oneshot::channel();
11450        self.channel
11451            .pending
11452            .lock()
11453            .await
11454            .insert(request_id.clone(), tx);
11455
11456        let msg = Message::Text(
11457            serde_json::to_string(&rpc_request)
11458                .map_err(|e| car_multi::MultiError::AgentFailed(spec.name.clone(), e.to_string()))?
11459                .into(),
11460        );
11461        if let Err(e) = self.channel.write.lock().await.send(msg).await {
11462            let _ = self
11463                .host
11464                .set_status(
11465                    &self.client_id,
11466                    SetHostAgentStatusRequest {
11467                        agent_id: agent_id.clone(),
11468                        status: HostAgentStatus::Errored,
11469                        current_task: None,
11470                        message: Some(format!("{} failed to start", spec.name)),
11471                        payload: serde_json::json!({ "error": e.to_string() }),
11472                    },
11473                )
11474                .await;
11475            return Err(car_multi::MultiError::AgentFailed(
11476                spec.name.clone(),
11477                format!("ws send error: {}", e),
11478            ));
11479        }
11480
11481        // Wait for client response (5 min timeout for model loops)
11482        let response = match tokio::time::timeout(std::time::Duration::from_secs(300), rx).await {
11483            Ok(Ok(response)) => response,
11484            Ok(Err(_)) => {
11485                let _ = self
11486                    .host
11487                    .set_status(
11488                        &self.client_id,
11489                        SetHostAgentStatusRequest {
11490                            agent_id: agent_id.clone(),
11491                            status: HostAgentStatus::Errored,
11492                            current_task: None,
11493                            message: Some(format!("{} callback channel closed", spec.name)),
11494                            payload: Value::Null,
11495                        },
11496                    )
11497                    .await;
11498                return Err(car_multi::MultiError::AgentFailed(
11499                    spec.name.clone(),
11500                    "agent callback channel closed".into(),
11501                ));
11502            }
11503            Err(_) => {
11504                let _ = self
11505                    .host
11506                    .set_status(
11507                        &self.client_id,
11508                        SetHostAgentStatusRequest {
11509                            agent_id: agent_id.clone(),
11510                            status: HostAgentStatus::Errored,
11511                            current_task: None,
11512                            message: Some(format!("{} timed out", spec.name)),
11513                            payload: Value::Null,
11514                        },
11515                    )
11516                    .await;
11517                return Err(car_multi::MultiError::AgentFailed(
11518                    spec.name.clone(),
11519                    "agent callback timed out (300s)".into(),
11520                ));
11521            }
11522        };
11523
11524        if let Some(err) = response.error {
11525            let _ = self
11526                .host
11527                .set_status(
11528                    &self.client_id,
11529                    SetHostAgentStatusRequest {
11530                        agent_id: agent_id.clone(),
11531                        status: HostAgentStatus::Errored,
11532                        current_task: None,
11533                        message: Some(format!("{} errored", spec.name)),
11534                        payload: serde_json::json!({ "error": err }),
11535                    },
11536                )
11537                .await;
11538            return Err(car_multi::MultiError::AgentFailed(spec.name.clone(), err));
11539        }
11540
11541        let output_value = response.output.unwrap_or(Value::Null);
11542        let output: car_multi::AgentOutput = serde_json::from_value(output_value).map_err(|e| {
11543            car_multi::MultiError::AgentFailed(
11544                spec.name.clone(),
11545                format!("invalid AgentOutput: {}", e),
11546            )
11547        })?;
11548        let status = if output.error.is_some() {
11549            HostAgentStatus::Errored
11550        } else {
11551            HostAgentStatus::Completed
11552        };
11553        let message = if output.error.is_some() {
11554            format!("{} errored", spec.name)
11555        } else {
11556            format!("{} completed", spec.name)
11557        };
11558        let _ = self
11559            .host
11560            .set_status(
11561                &self.client_id,
11562                SetHostAgentStatusRequest {
11563                    agent_id,
11564                    status,
11565                    current_task: None,
11566                    message: Some(message),
11567                    payload: serde_json::to_value(&output).unwrap_or(Value::Null),
11568                },
11569            )
11570            .await;
11571
11572        Ok(output)
11573    }
11574}
11575
11576fn agent_id_for_run(client_id: &str, name: &str, request_id: &str) -> String {
11577    let safe_name: String = name
11578        .chars()
11579        .map(|c| {
11580            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
11581                c
11582            } else {
11583                '-'
11584            }
11585        })
11586        .collect();
11587    format!("{}:{}:{}", client_id, safe_name, request_id)
11588}
11589
11590/// Build a [`car_multi::SharedInfra`], attaching a coordination budget when the
11591/// request carries a `budget` object (a `car_multi::BudgetLimits`, e.g.
11592/// `{"max_total_tokens": 200000, "max_agents": 12}`). Omitted fields are
11593/// unbounded; absent `budget` means no limits.
11594fn multi_infra_with_budget(
11595    req: &JsonRpcMessage,
11596    session: &crate::session::ClientSession,
11597) -> Result<car_multi::SharedInfra, String> {
11598    // Concurrency-anomaly gating (A5) is on for the daemon coordination path:
11599    // an isolated parallel swarm's cross-agent commit barrier is gated (stale
11600    // generation rejects the offending commit, reorder is serialized). The
11601    // infra SHARES the session runtime's state/log/policies (linus review
11602    // C-6): a fresh per-request store made `parent_keys_before` empty, so a
11603    // swarm write over a pre-existing session key could never be classified
11604    // as the read-modify-write that makes it a lost-update hazard — the
11605    // stale-generation check was structurally dead. Sharing also lands the
11606    // gate's audit events in the session log, matching the foreman path.
11607    let infra = car_multi::SharedInfra::with_shared(
11608        std::sync::Arc::clone(&session.runtime.state),
11609        std::sync::Arc::clone(&session.runtime.log),
11610        std::sync::Arc::clone(&session.runtime.policies),
11611    )
11612    .with_concurrency_gating();
11613    match req.params.get("budget") {
11614        None | Some(Value::Null) => Ok(infra),
11615        Some(v) => {
11616            let limits: car_multi::BudgetLimits =
11617                serde_json::from_value(v.clone()).map_err(|e| format!("invalid budget: {}", e))?;
11618            Ok(infra.with_budget(limits))
11619        }
11620    }
11621}
11622
11623async fn handle_multi_swarm(
11624    req: &JsonRpcMessage,
11625    session: &crate::session::ClientSession,
11626) -> Result<Value, String> {
11627    let mode_str = req
11628        .params
11629        .get("mode")
11630        .and_then(|v| v.as_str())
11631        .ok_or("missing 'mode'")?;
11632    let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11633    let task = req
11634        .params
11635        .get("task")
11636        .and_then(|v| v.as_str())
11637        .ok_or("missing 'task'")?;
11638
11639    let swarm_mode: car_multi::SwarmMode = serde_json::from_str(&format!("\"{}\"", mode_str))
11640        .map_err(|e| format!("invalid mode '{}': {}", mode_str, e))?;
11641    let agent_specs: Vec<car_multi::AgentSpec> =
11642        serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11643    let synth: Option<car_multi::AgentSpec> = req
11644        .params
11645        .get("synthesizer")
11646        .map(|v| serde_json::from_value(v.clone()))
11647        .transpose()
11648        .map_err(|e| format!("invalid synthesizer: {}", e))?;
11649
11650    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11651        channel: session.channel.clone(),
11652        host: session.host.clone(),
11653        client_id: session.client_id.clone(),
11654    });
11655    let infra = multi_infra_with_budget(req, session)?;
11656
11657    let mut swarm = car_multi::Swarm::new(agent_specs, swarm_mode);
11658    if let Some(s) = synth {
11659        swarm = swarm.with_synthesizer(s);
11660    }
11661
11662    let result = swarm
11663        .run(task, &runner, &infra)
11664        .await
11665        .map_err(|e| format!("swarm error: {}", e))?;
11666    serde_json::to_value(result).map_err(|e| e.to_string())
11667}
11668
11669async fn handle_multi_pipeline(
11670    req: &JsonRpcMessage,
11671    session: &crate::session::ClientSession,
11672) -> Result<Value, String> {
11673    let stages_val = req.params.get("stages").ok_or("missing 'stages'")?;
11674    let task = req
11675        .params
11676        .get("task")
11677        .and_then(|v| v.as_str())
11678        .ok_or("missing 'task'")?;
11679
11680    let stage_specs: Vec<car_multi::AgentSpec> =
11681        serde_json::from_value(stages_val.clone()).map_err(|e| format!("invalid stages: {}", e))?;
11682
11683    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11684        channel: session.channel.clone(),
11685        host: session.host.clone(),
11686        client_id: session.client_id.clone(),
11687    });
11688    let infra = multi_infra_with_budget(req, session)?;
11689
11690    let result = car_multi::Pipeline::new(stage_specs)
11691        .run(task, &runner, &infra)
11692        .await
11693        .map_err(|e| format!("pipeline error: {}", e))?;
11694    serde_json::to_value(result).map_err(|e| e.to_string())
11695}
11696
11697async fn handle_multi_supervisor(
11698    req: &JsonRpcMessage,
11699    session: &crate::session::ClientSession,
11700) -> Result<Value, String> {
11701    let workers_val = req.params.get("workers").ok_or("missing 'workers'")?;
11702    let supervisor_val = req.params.get("supervisor").ok_or("missing 'supervisor'")?;
11703    let task = req
11704        .params
11705        .get("task")
11706        .and_then(|v| v.as_str())
11707        .ok_or("missing 'task'")?;
11708    let max_rounds = req
11709        .params
11710        .get("max_rounds")
11711        .and_then(|v| v.as_u64())
11712        .unwrap_or(3) as u32;
11713
11714    let worker_specs: Vec<car_multi::AgentSpec> = serde_json::from_value(workers_val.clone())
11715        .map_err(|e| format!("invalid workers: {}", e))?;
11716    let supervisor_spec: car_multi::AgentSpec = serde_json::from_value(supervisor_val.clone())
11717        .map_err(|e| format!("invalid supervisor: {}", e))?;
11718
11719    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11720        channel: session.channel.clone(),
11721        host: session.host.clone(),
11722        client_id: session.client_id.clone(),
11723    });
11724    let infra = multi_infra_with_budget(req, session)?;
11725
11726    let result = car_multi::Supervisor::new(worker_specs, supervisor_spec)
11727        .with_max_rounds(max_rounds)
11728        .run(task, &runner, &infra)
11729        .await
11730        .map_err(|e| format!("supervisor error: {}", e))?;
11731    serde_json::to_value(result).map_err(|e| e.to_string())
11732}
11733
11734async fn handle_multi_map_reduce(
11735    req: &JsonRpcMessage,
11736    session: &crate::session::ClientSession,
11737) -> Result<Value, String> {
11738    let mapper_val = req.params.get("mapper").ok_or("missing 'mapper'")?;
11739    let reducer_val = req.params.get("reducer").ok_or("missing 'reducer'")?;
11740    let task = req
11741        .params
11742        .get("task")
11743        .and_then(|v| v.as_str())
11744        .ok_or("missing 'task'")?;
11745    let items_val = req.params.get("items").ok_or("missing 'items'")?;
11746
11747    let mapper_spec: car_multi::AgentSpec =
11748        serde_json::from_value(mapper_val.clone()).map_err(|e| format!("invalid mapper: {}", e))?;
11749    let reducer_spec: car_multi::AgentSpec = serde_json::from_value(reducer_val.clone())
11750        .map_err(|e| format!("invalid reducer: {}", e))?;
11751    let items: Vec<String> =
11752        serde_json::from_value(items_val.clone()).map_err(|e| format!("invalid items: {}", e))?;
11753
11754    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11755        channel: session.channel.clone(),
11756        host: session.host.clone(),
11757        client_id: session.client_id.clone(),
11758    });
11759    let infra = multi_infra_with_budget(req, session)?;
11760
11761    let result = car_multi::MapReduce::new(mapper_spec, reducer_spec)
11762        .run(task, &items, &runner, &infra)
11763        .await
11764        .map_err(|e| format!("map_reduce error: {}", e))?;
11765    serde_json::to_value(result).map_err(|e| e.to_string())
11766}
11767
11768async fn handle_multi_vote(
11769    req: &JsonRpcMessage,
11770    session: &crate::session::ClientSession,
11771) -> Result<Value, String> {
11772    let agents_val = req.params.get("agents").ok_or("missing 'agents'")?;
11773    let task = req
11774        .params
11775        .get("task")
11776        .and_then(|v| v.as_str())
11777        .ok_or("missing 'task'")?;
11778
11779    let agent_specs: Vec<car_multi::AgentSpec> =
11780        serde_json::from_value(agents_val.clone()).map_err(|e| format!("invalid agents: {}", e))?;
11781    let synth: Option<car_multi::AgentSpec> = req
11782        .params
11783        .get("synthesizer")
11784        .map(|v| serde_json::from_value(v.clone()))
11785        .transpose()
11786        .map_err(|e| format!("invalid synthesizer: {}", e))?;
11787
11788    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11789        channel: session.channel.clone(),
11790        host: session.host.clone(),
11791        client_id: session.client_id.clone(),
11792    });
11793    let infra = multi_infra_with_budget(req, session)?;
11794
11795    let mut vote = car_multi::Vote::new(agent_specs);
11796    if let Some(s) = synth {
11797        vote = vote.with_synthesizer(s);
11798    }
11799
11800    let result = vote
11801        .run(task, &runner, &infra)
11802        .await
11803        .map_err(|e| format!("vote error: {}", e))?;
11804    serde_json::to_value(result).map_err(|e| e.to_string())
11805}
11806
11807async fn handle_multi_tournament(
11808    req: &JsonRpcMessage,
11809    session: &crate::session::ClientSession,
11810) -> Result<Value, String> {
11811    let competitors_val = req
11812        .params
11813        .get("competitors")
11814        .ok_or("missing 'competitors'")?;
11815    let judge_val = req.params.get("judge").ok_or("missing 'judge'")?;
11816    let task = req
11817        .params
11818        .get("task")
11819        .and_then(|v| v.as_str())
11820        .ok_or("missing 'task'")?;
11821
11822    let competitors: Vec<car_multi::AgentSpec> = serde_json::from_value(competitors_val.clone())
11823        .map_err(|e| format!("invalid competitors: {}", e))?;
11824    let judge: car_multi::AgentSpec =
11825        serde_json::from_value(judge_val.clone()).map_err(|e| format!("invalid judge: {}", e))?;
11826
11827    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11828        channel: session.channel.clone(),
11829        host: session.host.clone(),
11830        client_id: session.client_id.clone(),
11831    });
11832    let infra = multi_infra_with_budget(req, session)?;
11833
11834    let result = car_multi::Tournament::new(competitors, judge)
11835        .run(task, &runner, &infra)
11836        .await
11837        .map_err(|e| format!("tournament error: {}", e))?;
11838    serde_json::to_value(result).map_err(|e| e.to_string())
11839}
11840
11841async fn handle_multi_subtask(
11842    req: &JsonRpcMessage,
11843    session: &crate::session::ClientSession,
11844) -> Result<Value, String> {
11845    let main_val = req.params.get("main").ok_or("missing 'main'")?;
11846    let task = req
11847        .params
11848        .get("task")
11849        .and_then(|v| v.as_str())
11850        .ok_or("missing 'task'")?;
11851
11852    let main_spec: car_multi::AgentSpec =
11853        serde_json::from_value(main_val.clone()).map_err(|e| format!("invalid main: {}", e))?;
11854
11855    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
11856        channel: session.channel.clone(),
11857        host: session.host.clone(),
11858        client_id: session.client_id.clone(),
11859    });
11860    let infra = multi_infra_with_budget(req, session)?;
11861
11862    let result = car_multi::SpawnSubtask::new(main_spec)
11863        .run(task, &runner, &infra)
11864        .await
11865        .map_err(|e| format!("spawn_subtask error: {}", e))?;
11866    serde_json::to_value(result).map_err(|e| e.to_string())
11867}
11868
11869// ---------------------------------------------------------------------------
11870// Scheduler handlers
11871// ---------------------------------------------------------------------------
11872
11873/// `schedule.suggest` — deterministically parse the Agent Builder's supported
11874/// cadence phrases. Unsupported input is not an RPC error: it returns
11875/// `{ cadence: null, reason }` so the caller can ask for a corrected phrase.
11876fn handle_schedule_suggest(req: &JsonRpcMessage) -> Result<Value, String> {
11877    let phrase = req
11878        .params
11879        .get("phrase")
11880        .and_then(Value::as_str)
11881        .ok_or("schedule.suggest requires 'phrase'")?;
11882    let timezone = req.params.get("timezone").and_then(Value::as_str);
11883    serde_json::to_value(car_scheduler::suggest_schedule(phrase, timezone))
11884        .map_err(|e| e.to_string())
11885}
11886
11887fn handle_scheduler_create(req: &JsonRpcMessage) -> Result<Value, String> {
11888    let name = req
11889        .params
11890        .get("name")
11891        .and_then(|v| v.as_str())
11892        .ok_or("scheduler.create requires 'name'")?;
11893    let prompt = req
11894        .params
11895        .get("prompt")
11896        .and_then(|v| v.as_str())
11897        .ok_or("scheduler.create requires 'prompt'")?;
11898
11899    let mut task = car_scheduler::Task::new(name, prompt);
11900
11901    if let Some(t) = opt_str(&req.params, "trigger") {
11902        let trigger = match t {
11903            "once" => car_scheduler::TaskTrigger::Once,
11904            "cron" => car_scheduler::TaskTrigger::Cron,
11905            "interval" => car_scheduler::TaskTrigger::Interval,
11906            "file_watch" => car_scheduler::TaskTrigger::FileWatch,
11907            _ => car_scheduler::TaskTrigger::Manual,
11908        };
11909        let schedule = req
11910            .params
11911            .get("schedule")
11912            .and_then(|v| v.as_str())
11913            .unwrap_or("");
11914        task = task.with_trigger(trigger, schedule);
11915    }
11916
11917    if let Some(sp) = opt_str(&req.params, "system_prompt") {
11918        task = task.with_system_prompt(sp);
11919    }
11920
11921    serde_json::to_value(&task).map_err(|e| e.to_string())
11922}
11923
11924/// Common param extraction for the OS-schedule handlers: `{ task, program, args? }`.
11925fn os_schedule_params(req: &JsonRpcMessage) -> Result<(String, String, String), String> {
11926    let task = req
11927        .params
11928        .get("task")
11929        .ok_or("requires 'task'")
11930        .and_then(|v| serde_json::to_string(v).map_err(|_| "invalid 'task'"))?;
11931    let program = req
11932        .params
11933        .get("program")
11934        .and_then(|v| v.as_str())
11935        .ok_or("requires 'program' (the binary the OS runs to execute the task)")?
11936        .to_string();
11937    let args = req
11938        .params
11939        .get("args")
11940        .cloned()
11941        .unwrap_or_else(|| Value::Array(vec![]));
11942    let args_json = serde_json::to_string(&args).map_err(|e| e.to_string())?;
11943    Ok((task, program, args_json))
11944}
11945
11946/// Preview the durable OS-level schedule a task would install (no I/O).
11947fn handle_scheduler_os_render(req: &JsonRpcMessage) -> Result<Value, String> {
11948    let (task, program, args) = os_schedule_params(req)?;
11949    let json = car_ffi_common::scheduler::render_os_schedule(&task, &program, &args)?;
11950    serde_json::from_str(&json).map_err(|e| e.to_string())
11951}
11952
11953/// Install a durable OS-level schedule (launchd/cron) for a task so it fires
11954/// even when the daemon is down.
11955fn handle_scheduler_os_install(req: &JsonRpcMessage) -> Result<Value, String> {
11956    let (task, program, args) = os_schedule_params(req)?;
11957    let json = car_ffi_common::scheduler::install_os_schedule(&task, &program, &args)?;
11958    serde_json::from_str(&json).map_err(|e| e.to_string())
11959}
11960
11961/// `tasks.schedule` — schedule a deterministic command on a cadence, hiding the
11962/// OS backend (#72). Params: `{ name, program, args?, cadence: { interval_secs? |
11963/// cron? }, durable?, working_dir?, env?, timeout_secs? }`.
11964///
11965/// **Host-gated**: scheduling a command is persistent, unsandboxed code
11966/// execution, so it requires host-management authority (a no-op in tokenless
11967/// dev/embedder mode). A registered agent connection can't self-schedule a
11968/// command — same trust root as `permission.set_tier` / `messaging.config.*`.
11969fn handle_tasks_schedule(
11970    req: &JsonRpcMessage,
11971    session: &crate::session::ClientSession,
11972    state: &ServerState,
11973) -> Result<Value, String> {
11974    require_approval_authority(session, state)?;
11975    let spec = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
11976    let json = car_ffi_common::scheduler::schedule_task(&spec)?;
11977    serde_json::from_str(&json).map_err(|e| e.to_string())
11978}
11979
11980/// `tasks.list` — list deterministic (command) scheduled tasks + their backend.
11981fn handle_tasks_list(_req: &JsonRpcMessage) -> Result<Value, String> {
11982    let json = car_ffi_common::scheduler::list_scheduled_tasks()?;
11983    serde_json::from_str(&json).map_err(|e| e.to_string())
11984}
11985
11986/// `tasks.unschedule` — remove a task's OS schedule + delete it from the store.
11987/// Params: `{ id }` (bare id or full label). Host-gated (same authority as
11988/// `tasks.schedule`).
11989fn handle_tasks_unschedule(
11990    req: &JsonRpcMessage,
11991    session: &crate::session::ClientSession,
11992    state: &ServerState,
11993) -> Result<Value, String> {
11994    require_approval_authority(session, state)?;
11995    let id = req
11996        .params
11997        .get("id")
11998        .and_then(|v| v.as_str())
11999        .ok_or("tasks.unschedule requires 'id'")?;
12000    let json = car_ffi_common::scheduler::unschedule_task(id)?;
12001    serde_json::from_str(&json).map_err(|e| e.to_string())
12002}
12003
12004/// Remove a task's OS-level schedule. Params: `{ label }` (full label or bare id).
12005fn handle_scheduler_os_uninstall(req: &JsonRpcMessage) -> Result<Value, String> {
12006    let label = req
12007        .params
12008        .get("label")
12009        .and_then(|v| v.as_str())
12010        .ok_or("scheduler.os_uninstall requires 'label' (full label or task id)")?;
12011    let json = car_ffi_common::scheduler::uninstall_os_schedule(label)?;
12012    serde_json::from_str(&json).map_err(|e| e.to_string())
12013}
12014
12015/// List CAR-managed OS-level schedule labels installed on this host.
12016fn handle_scheduler_os_list(_req: &JsonRpcMessage) -> Result<Value, String> {
12017    let json = car_ffi_common::scheduler::list_os_schedules()?;
12018    serde_json::from_str(&json).map_err(|e| e.to_string())
12019}
12020
12021/// Reap orphaned OS-level schedules whose backing task was deleted or made
12022/// non-schedulable. Returns `{ removed, kept, errors }`.
12023fn handle_scheduler_os_reconcile(_req: &JsonRpcMessage) -> Result<Value, String> {
12024    let json = car_ffi_common::scheduler::reconcile_os_schedules()?;
12025    serde_json::from_str(&json).map_err(|e| e.to_string())
12026}
12027
12028/// Reap orphaned OS-level schedules at daemon boot — best-effort, mirrors
12029/// [`recover_workflow_checkpoints`]. A schedule whose task was deleted while the
12030/// daemon was down keeps firing a no-op command until this runs.
12031pub fn reconcile_os_schedules_at_boot() {
12032    match car_ffi_common::scheduler::reconcile_os_schedules() {
12033        Ok(json) => match serde_json::from_str::<Value>(&json) {
12034            Ok(report) => {
12035                let removed = report
12036                    .get("removed")
12037                    .and_then(Value::as_array)
12038                    .map_or(0, Vec::len);
12039                let errors = report
12040                    .get("errors")
12041                    .and_then(Value::as_array)
12042                    .map_or(0, Vec::len);
12043                if removed > 0 || errors > 0 {
12044                    tracing::info!(removed, errors, "reaped orphaned OS schedules at boot");
12045                }
12046            }
12047            Err(e) => {
12048                tracing::warn!(error = %e, "OS-schedule reconcile returned unparseable report")
12049            }
12050        },
12051        Err(e) => tracing::warn!(error = %e, "OS-schedule reconcile failed at boot"),
12052    }
12053}
12054
12055/// Reap CAR Docker sandbox containers orphaned by a prior daemon crash/SIGKILL
12056/// (Parslee-ai/car#479). `SandboxExecutor`'s `Drop` handles normal teardown, but
12057/// a hard kill leaves the `sleep infinity` container running forever. Sandboxes
12058/// are created per `session.bindSandbox` (never at boot), so any container
12059/// carrying the CAR ownership label is by definition orphaned. Best-effort:
12060/// a host without Docker is a silent no-op.
12061pub async fn reap_orphaned_sandboxes_at_boot() {
12062    let reaped = car_sandbox::reap_orphaned_sandboxes().await;
12063    if reaped > 0 {
12064        tracing::info!(reaped, "reaped orphaned CAR sandbox containers at boot");
12065    }
12066}
12067
12068/// Seed a client-supplied task with any prior execution history persisted under
12069/// the given TaskStore. The WS `scheduler.run` / `scheduler.run_loop` surface is
12070/// otherwise stateless — a client deserializes a `Task` and posts it each call,
12071/// so `task.executions` arrives empty and the deterministic occurrence guard in
12072/// [`car_scheduler::Executor::run_occurrence`] never sees the prior run. Loading
12073/// the stored task by id and copying its `executions` (plus the run bookkeeping
12074/// the guard reads alongside them) restores statefulness across the JSON-RPC
12075/// boundary, so a replayed Interval/Once/Cron occurrence dedups. Manual triggers
12076/// carry no occurrence id and are intentionally never deduped — seeding is a
12077/// no-op for them.
12078fn seed_task_from_store(task: &mut car_scheduler::Task, store: &car_scheduler::TaskStore) {
12079    if let Some(prior) = store.load(&task.id) {
12080        // Anchor the occurrence slot on the persisted creation time and copy the
12081        // prior execution history so the admission guard can recognize a replay.
12082        task.created_at = prior.created_at;
12083        task.executions = prior.executions;
12084        task.run_count = prior.run_count;
12085        task.last_run_at = prior.last_run_at;
12086    }
12087}
12088
12089/// Run a single task occurrence statefully against a TaskStore, persisting the
12090/// mutated task so the next call sees this run. Shared by the WS handler and the
12091/// dedup tests.
12092async fn run_scheduler_task_once(
12093    task: &mut car_scheduler::Task,
12094    runner: Arc<dyn car_multi::AgentRunner>,
12095    store: &car_scheduler::TaskStore,
12096) -> car_scheduler::TaskExecution {
12097    seed_task_from_store(task, store);
12098    let executor = car_scheduler::Executor::new(runner);
12099    let execution = executor.run_once(task).await;
12100    let _ = store.save(task);
12101    execution
12102}
12103
12104/// Run a task loop statefully against a TaskStore, persisting the mutated task
12105/// afterward. Shared by the WS handler and the dedup tests.
12106async fn run_scheduler_task_loop(
12107    task: &mut car_scheduler::Task,
12108    max_iterations: Option<u32>,
12109    runner: Arc<dyn car_multi::AgentRunner>,
12110    store: &car_scheduler::TaskStore,
12111) -> Vec<car_scheduler::TaskExecution> {
12112    seed_task_from_store(task, store);
12113    let executor = car_scheduler::Executor::new(runner);
12114    let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
12115    let executions = executor.run_loop(task, max_iterations, cancel_rx).await;
12116    let _ = store.save(task);
12117    executions
12118}
12119
12120async fn handle_scheduler_run(
12121    req: &JsonRpcMessage,
12122    session: &crate::session::ClientSession,
12123) -> Result<Value, String> {
12124    let task_val = req
12125        .params
12126        .get("task")
12127        .ok_or("scheduler.run requires 'task'")?;
12128    let mut task: car_scheduler::Task =
12129        serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
12130
12131    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
12132        channel: session.channel.clone(),
12133        host: session.host.clone(),
12134        client_id: session.client_id.clone(),
12135    });
12136    let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
12137    let execution = run_scheduler_task_once(&mut task, runner, &store).await;
12138
12139    serde_json::to_value(&execution).map_err(|e| e.to_string())
12140}
12141
12142async fn handle_scheduler_run_loop(
12143    req: &JsonRpcMessage,
12144    session: &crate::session::ClientSession,
12145) -> Result<Value, String> {
12146    let task_val = req
12147        .params
12148        .get("task")
12149        .ok_or("scheduler.run_loop requires 'task'")?;
12150    let mut task: car_scheduler::Task =
12151        serde_json::from_value(task_val.clone()).map_err(|e| format!("invalid task: {}", e))?;
12152    let max_iterations = req
12153        .params
12154        .get("max_iterations")
12155        .and_then(|v| v.as_u64())
12156        .map(|v| v as u32);
12157
12158    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
12159        channel: session.channel.clone(),
12160        host: session.host.clone(),
12161        client_id: session.client_id.clone(),
12162    });
12163    let store = car_scheduler::TaskStore::new(&car_scheduler::TaskStore::default_path());
12164    let executions = run_scheduler_task_loop(&mut task, max_iterations, runner, &store).await;
12165
12166    serde_json::to_value(&executions).map_err(|e| e.to_string())
12167}
12168
12169// ---------------------------------------------------------------------------
12170// Inference handlers
12171// ---------------------------------------------------------------------------
12172
12173pub(crate) fn get_inference_engine(state: &ServerState) -> &Arc<car_inference::InferenceEngine> {
12174    state.inference.get_or_init(|| {
12175        let engine = Arc::new(car_inference::InferenceEngine::new(
12176            car_inference::InferenceConfig::default(),
12177        ));
12178        // Isolate on-device (MLX/Candle) generation in a worker subprocess so a
12179        // native Metal/MLX abort on a heavy local inference can't take down the
12180        // shared daemon (car-releases#74). The engine's on-device branch routes
12181        // through this offloader when installed; a worker crash fails one RPC
12182        // and respawns rather than killing the daemon. Skipped inside a worker
12183        // process (it runs generation for real) and opt-out via
12184        // CAR_NO_INFERENCE_WORKER=1 for debugging the in-process path.
12185        let worker_disabled = std::env::var_os("CAR_NO_INFERENCE_WORKER").is_some();
12186        if !car_inference::is_offload_worker() && !worker_disabled {
12187            match crate::inference_worker::WorkerOffload::new() {
12188                Ok(offload) => {
12189                    car_inference::set_local_offload(Some(std::sync::Arc::new(offload)));
12190                    info!("on-device inference isolated in a worker subprocess (car-releases#74)");
12191                }
12192                Err(e) => tracing::warn!(
12193                    error = %e,
12194                    "could not install the inference worker offloader; on-device \
12195                     generation will run in-process (a native MLX abort could crash the daemon)"
12196                ),
12197            }
12198        }
12199        // Phase E2: keep the model catalog current automatically. OPT-IN via
12200        // `CAR_MODEL_DISCOVERY=1` (off by default — it makes an outbound
12201        // provider call to `/v1/models` on a timer, which not every operator
12202        // wants). When enabled, run provider model discovery on first engine
12203        // init then re-run daily, caching newly-released chat/reasoning models
12204        // (e.g. a new gpt-5.x) as Community entries that load at the next
12205        // daemon start — so the router picks them up without a release.
12206        // Best-effort: no key / no provider is a no-op.
12207        let discovery_enabled = std::env::var("CAR_MODEL_DISCOVERY")
12208            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
12209            .unwrap_or(false);
12210        if discovery_enabled {
12211            // `get_or_init` can run outside a tokio runtime (a sync or
12212            // non-async first caller); `tokio::spawn` panics there. Only spawn
12213            // when a runtime is actually present, otherwise log and skip.
12214            match tokio::runtime::Handle::try_current() {
12215                Ok(handle) => {
12216                    let disco = Arc::clone(&engine);
12217                    handle.spawn(async move {
12218                        loop {
12219                            match disco.discover_models().await {
12220                                Ok(n) => info!("model discovery: {n} discovered models cached"),
12221                                Err(e) => tracing::debug!("model discovery skipped: {e}"),
12222                            }
12223                            tokio::time::sleep(std::time::Duration::from_secs(24 * 3600)).await;
12224                        }
12225                    });
12226                }
12227                Err(_) => tracing::debug!(
12228                    "model discovery enabled (CAR_MODEL_DISCOVERY) but no tokio runtime at \
12229                     engine init — skipping the background task"
12230                ),
12231            }
12232        }
12233        engine
12234    })
12235}
12236
12237/// `foreman.plan` — decompose a coding `goal` into a footprint-annotated,
12238/// scheduled subtask plan. The planner's repair loop runs against the daemon's
12239/// inference engine. Returns a [`car_multi::PlanReport`] (the SCHEDULER surface;
12240/// the gate verdict is a separate surface produced by `foreman.run`).
12241async fn handle_foreman_plan(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
12242    let goal = msg
12243        .params
12244        .get("goal")
12245        .and_then(|v| v.as_str())
12246        .ok_or("missing 'goal'")?
12247        .to_string();
12248    let repo = msg
12249        .params
12250        .get("repo")
12251        .and_then(|v| v.as_str())
12252        .map(std::path::PathBuf::from)
12253        .or_else(|| std::env::current_dir().ok())
12254        .ok_or("no 'repo' and cwd is unavailable")?;
12255    let max_attempts = msg
12256        .params
12257        .get("max_attempts")
12258        .and_then(|v| v.as_u64())
12259        .unwrap_or(3) as u32;
12260
12261    let engine = std::sync::Arc::clone(get_inference_engine(state));
12262    let result = car_multi::decompose(&repo, &goal, max_attempts, move |prompt| {
12263        let engine = std::sync::Arc::clone(&engine);
12264        async move {
12265            engine
12266                .generate(car_inference::GenerateRequest {
12267                    prompt,
12268                    ..Default::default()
12269                })
12270                .await
12271                .map_err(|e| e.to_string())
12272        }
12273    })
12274    .await;
12275
12276    serde_json::to_value(car_multi::PlanReport::from(&result))
12277        .map_err(|e| format!("serialize plan: {e}"))
12278}
12279
12280/// `foreman.run` — plan a coding `goal`, farm the independent subtasks to an
12281/// external coding CLI in isolated git worktrees, gate each worktree and the
12282/// integrated union. Returns `{ plan, ran, run? }`. **Spends real agent quota.**
12283async fn handle_foreman_run(
12284    msg: &JsonRpcMessage,
12285    state: &ServerState,
12286    session: &crate::session::ClientSession,
12287) -> Result<Value, String> {
12288    let goal = msg
12289        .params
12290        .get("goal")
12291        .and_then(|v| v.as_str())
12292        .ok_or("missing 'goal'")?
12293        .to_string();
12294    let repo = msg
12295        .params
12296        .get("repo")
12297        .and_then(|v| v.as_str())
12298        .map(std::path::PathBuf::from)
12299        .or_else(|| std::env::current_dir().ok())
12300        .ok_or("no 'repo' and cwd is unavailable")?;
12301    let max_attempts = msg
12302        .params
12303        .get("max_attempts")
12304        .and_then(|v| v.as_u64())
12305        .unwrap_or(3) as u32;
12306    let adapter = msg
12307        .params
12308        .get("adapter")
12309        .and_then(|v| v.as_str())
12310        .unwrap_or("claude-code")
12311        .to_string();
12312    let str_array = |key: &str| -> Option<Vec<String>> {
12313        msg.params.get(key).and_then(|v| v.as_array()).map(|a| {
12314            a.iter()
12315                .filter_map(|x| x.as_str().map(String::from))
12316                .collect()
12317        })
12318    };
12319    // Per-worktree regression gate, and the integrated-union goal gate. A subtask
12320    // does only part of the goal, so a goal-level test belongs on the union, not
12321    // per-worktree; `union_verify_command` falls back to `verify_command`.
12322    let verify_command = str_array("verify_command");
12323    let union_verify_command = str_array("union_verify_command");
12324
12325    // Multiplayer (car#1117): spread the subtasks over every CAR instance that
12326    // can serve this repository, instead of running them all here. Opt-in,
12327    // because it spends other machines' quota and needs their operators to have
12328    // enrolled them. `workers` narrows placement to named instances.
12329    let distributed = msg
12330        .params
12331        .get("distributed")
12332        .and_then(|v| v.as_bool())
12333        .unwrap_or(false);
12334    let worker_filter = str_array("workers");
12335
12336    // Run the full pipeline: decompose, then farm out + verify the union, OR —
12337    // when the plan doesn't decompose (invalid, or no parallelism worth it) —
12338    // fall back to a single whole-goal session. Always produces a result.
12339    let engine = std::sync::Arc::clone(get_inference_engine(state));
12340    let run_id = uuid::Uuid::new_v4().to_string();
12341    let agent = car_external_agents::ForemanExternalAgent::new(adapter.clone());
12342    // The pool always contains this host too, so a distributed run whose peers
12343    // all decline still completes locally.
12344    let (pool, pool_plan) = if distributed {
12345        let (pool, plan) =
12346            crate::fleet::build_pool(state, &repo, &run_id, &adapter, worker_filter.as_deref())
12347                .await?;
12348        (Some(pool), Some(plan))
12349    } else {
12350        (None, None)
12351    };
12352    let worktree_agent: &dyn car_multi::WorktreeAgent = match &pool {
12353        Some(pool) => pool,
12354        None => &agent,
12355    };
12356    // Reuse the session's runtime policies + event log so the merge-verify gate
12357    // consults the operator's `policy.register`'d rules (it can deny a merge) and
12358    // its GateAccepted/GateRejected events are audited in the session log —
12359    // instead of a fresh, empty engine.
12360    let infra = car_multi::SharedInfra::with_shared(
12361        std::sync::Arc::clone(&session.runtime.state),
12362        std::sync::Arc::clone(&session.runtime.log),
12363        std::sync::Arc::clone(&session.runtime.policies),
12364    );
12365    let config = car_multi::FarmOutConfig {
12366        verify_command,
12367        union_verify_command,
12368        // The daemon entrypoint is delivery-first: recover via a single session
12369        // if the parallel union fails, rather than hand back a non-delivery.
12370        recover_via_single_session: true,
12371        ..Default::default()
12372    };
12373    let outcome = car_multi::run_foreman(
12374        &repo,
12375        &goal,
12376        max_attempts,
12377        worktree_agent,
12378        &config,
12379        &infra,
12380        move |prompt| {
12381            let engine = std::sync::Arc::clone(&engine);
12382            async move {
12383                engine
12384                    .generate(car_inference::GenerateRequest {
12385                        prompt,
12386                        ..Default::default()
12387                    })
12388                    .await
12389                    .map_err(|e| e.to_string())
12390            }
12391        },
12392    )
12393    .await;
12394
12395    let mode = match outcome.mode {
12396        car_multi::RunMode::Parallel => "parallel",
12397        car_multi::RunMode::SingleSession => "single_session",
12398        car_multi::RunMode::ParallelThenSingleSession => "parallel_then_single_session",
12399        car_multi::RunMode::RegionalReplan => "regional_replan",
12400    };
12401    let report =
12402        car_multi::ForemanReport::from_run(&outcome.outcomes, outcome.integration.as_ref());
12403    Ok(serde_json::json!({
12404        "plan": car_multi::PlanReport::from(&outcome.plan),
12405        "mode": mode,
12406        "ran": true,
12407        "delivered": outcome.delivered(),
12408        "run": report,
12409        "run_id": run_id,
12410        "distributed": distributed,
12411        // Which instances were in the pool, and where each subtask actually
12412        // ran (including workers that dropped one before another picked it up).
12413        // Absent on a local run, where the answer is always "here".
12414        "workers": pool.as_ref().map(|p| p.worker_ids()),
12415        "placements": pool.as_ref().map(crate::fleet::placements_json),
12416        // What the pool left out, and — when it left out everything — the one
12417        // line saying this "distributed" run was in fact local. Without it a
12418        // degraded run is indistinguishable from a slow one.
12419        "pool": pool_plan.as_ref().map(|plan| serde_json::json!({
12420            "remote_workers": plan.remote_workers,
12421            "excluded": plan.excluded.iter().map(|(instance, reason)| serde_json::json!({
12422                "instance": instance,
12423                "reason": reason,
12424            })).collect::<Vec<_>>(),
12425            // Workers the run LOST, as opposed to the ones it never had. A peer
12426            // that dies mid-run is excluded for the remainder (car#1323), and
12427            // without naming it here that shows up only as a slower run. Every
12428            // other key in this object describes the pool as BUILT, so a reader
12429            // asking whether the run was effectively local subtracts this from
12430            // `remote_workers` — `local_only` and `degraded_reason` are computed
12431            // before any subtask ran and cannot see it.
12432            "quarantined": pool.as_ref().map(|p| p.quarantined()).unwrap_or_default(),
12433            "local_only": plan.local_only(),
12434            "degraded_reason": plan.degraded_reason(),
12435        })),
12436    }))
12437}
12438
12439/// Decrements a session's in-flight chat-infer counter on drop, so the
12440/// conversation-outcome concurrency guard (neo #1) is released on every return
12441/// path — including an early error from generation.
12442struct ChatInflightGuard<'a>(&'a std::sync::atomic::AtomicUsize);
12443impl Drop for ChatInflightGuard<'_> {
12444    fn drop(&mut self) {
12445        self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
12446    }
12447}
12448
12449/// Capture the conversation-outcome inputs for a (possibly) chat infer, shared
12450/// by the streaming and non-streaming `infer` paths so the gate + idle-guard
12451/// rules can't drift apart. Must be called BEFORE `req` is consumed by
12452/// generation. Returns `(chat_user_text, was_idle, guard)`:
12453/// - `chat_user_text` is `Some` only for an EXPLICITLY-declared chat turn
12454///   (`intent.task == Chat`) — "no tools" means "not an agent loop", not "is a
12455///   conversation", so a one-off summarize/classify infer never feeds the
12456///   signal (neo #2).
12457/// - `was_idle` is true only when this turn BEGAN while the session was idle;
12458///   recording a turn against a concurrent infer's user text would fabricate
12459///   adjacency and mislabel routing stats (neo #1).
12460/// - `guard` decrements the in-flight counter on drop; the caller must hold it
12461///   for the whole generation.
12462fn begin_chat_turn<'a>(
12463    req: &car_inference::GenerateRequest,
12464    session: &'a crate::session::ClientSession,
12465) -> (Option<String>, bool, Option<ChatInflightGuard<'a>>) {
12466    let is_chat = req
12467        .intent
12468        .as_ref()
12469        .is_some_and(|i| i.task == Some(car_inference::TaskHint::Chat));
12470    if !is_chat {
12471        return (None, false, None);
12472    }
12473    let chat_user_text = req
12474        .messages
12475        .as_ref()
12476        .and_then(|ms| {
12477            ms.iter().rev().find_map(|m| match m {
12478                car_inference::Message::User { content } => Some(content.clone()),
12479                _ => None,
12480            })
12481        })
12482        .or_else(|| {
12483            let p = req.prompt.trim();
12484            (!p.is_empty()).then(|| p.to_string())
12485        });
12486    let was_idle = session
12487        .chat_inflight
12488        .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
12489        == 0;
12490    (
12491        chat_user_text,
12492        was_idle,
12493        Some(ChatInflightGuard(&session.chat_inflight)),
12494    )
12495}
12496
12497/// Score the session's prior chat turn (`slot`) against `new_user_text` and feed
12498/// the tracker, then overwrite `slot` with this turn. Pure over the borrowed
12499/// state (no locks/I/O of its own) so it's unit-testable against a real
12500/// `OutcomeTracker`. trace_id/model are carried from the `InferenceResult` that
12501/// produced each turn — credit lands on the exact model that generated the turn.
12502fn score_and_remember_chat_turn(
12503    slot: &mut Option<crate::session::LastChatTurn>,
12504    tracker: &mut car_inference::OutcomeTracker,
12505    new_user_text: String,
12506    result_text: &str,
12507    trace_id: &str,
12508    model_id: &str,
12509) -> car_memgine::outcome_bridge::CreditTally {
12510    use car_memgine::outcome_signal::ConversationTurn;
12511    let mut tally = car_memgine::outcome_bridge::CreditTally::default();
12512    if let Some(prev) = slot.as_ref() {
12513        let turns = vec![
12514            ConversationTurn::user(prev.user_text.clone(), "u_prev"),
12515            ConversationTurn::assistant(
12516                prev.assistant_text.clone(),
12517                "a_prev",
12518                Some(prev.model_id.clone()),
12519                Some(prev.trace_id.clone()),
12520            ),
12521            ConversationTurn::user(new_user_text.clone(), "u_new"),
12522        ];
12523        tally = car_memgine::outcome_bridge::record_conversation_outcomes(tracker, &turns);
12524    }
12525    *slot = Some(crate::session::LastChatTurn {
12526        user_text: new_user_text,
12527        assistant_text: result_text.to_string(),
12528        trace_id: trace_id.to_string(),
12529        model_id: model_id.to_string(),
12530    });
12531    tally
12532}
12533
12534#[cfg(test)]
12535mod chat_outcome_tests {
12536    use super::score_and_remember_chat_turn;
12537    use crate::session::LastChatTurn;
12538    use car_inference::{InferenceTask, OutcomeTracker};
12539
12540    #[test]
12541    fn first_turn_records_nothing_and_stashes() {
12542        let mut slot: Option<LastChatTurn> = None;
12543        let mut tracker = OutcomeTracker::new();
12544        let tally = score_and_remember_chat_turn(
12545            &mut slot,
12546            &mut tracker,
12547            "hello".into(),
12548            "hi there",
12549            "t1",
12550            "m1",
12551        );
12552        assert_eq!(tally.submitted, 0, "no prior turn → nothing to score");
12553        let stashed = slot.expect("this turn must be remembered");
12554        assert_eq!(stashed.trace_id, "t1");
12555        assert_eq!(stashed.user_text, "hello");
12556    }
12557
12558    #[test]
12559    fn second_turn_scores_prior_turn_and_reverses_mechanical_success() {
12560        let mut tracker = OutcomeTracker::new();
12561        // Prior assistant turn: trace t1 / model m1, completed with output →
12562        // mechanical success booked.
12563        let tid = tracker.record_start("m1", InferenceTask::Generate, "test");
12564        tracker.record_complete(&tid, 10, 1, 2);
12565        assert_eq!(tracker.profile("m1").unwrap().success_count, 1);
12566
12567        let mut slot = Some(LastChatTurn {
12568            user_text: "convert this to async".into(),
12569            assistant_text: "here's a threaded version".into(),
12570            trace_id: tid.clone(),
12571            model_id: "m1".into(),
12572        });
12573        // The next user turn circles (repair marker) → the prior turn is a failure.
12574        let tally = score_and_remember_chat_turn(
12575            &mut slot,
12576            &mut tracker,
12577            "no, that's not what i asked".into(),
12578            "async version",
12579            "t2",
12580            "m1",
12581        );
12582        assert_eq!(
12583            tally.resolved, 1,
12584            "prior turn's trace was pending → resolved"
12585        );
12586        let p = tracker.profile("m1").unwrap();
12587        assert_eq!(p.success_count, 0, "mechanical success reversed");
12588        assert_eq!(p.fail_count, 1, "circle booked as a failure for m1");
12589        assert_eq!(slot.unwrap().trace_id, "t2", "this turn is now remembered");
12590    }
12591}
12592
12593/// Raw infer RPCs treat a named model as a hard pin.  The inference layer can
12594/// otherwise append an on-device last-resort model after a remote failure,
12595/// which is appropriate only for adaptive (model-less) routing.
12596fn parse_inference_request(params: &Value) -> Result<car_inference::GenerateRequest, String> {
12597    let exact_model_id = match params.get("model_id") {
12598        None | Some(Value::Null) => None,
12599        Some(Value::String(model_id)) if !model_id.trim().is_empty() => Some(model_id.clone()),
12600        Some(_) => return Err("`model_id` must be a non-empty string".to_string()),
12601    };
12602    if exact_model_id.is_some() && params.get("model").is_some_and(|value| !value.is_null()) {
12603        return Err("`model` and `model_id` are mutually exclusive".to_string());
12604    }
12605
12606    let mut normalized = params.clone();
12607    if let Some(object) = normalized.as_object_mut() {
12608        object.remove("model_id");
12609    }
12610    let mut req: car_inference::GenerateRequest = typed_params(&normalized)?;
12611    if let Some(model_id) = exact_model_id {
12612        car_inference::pin_exact_model_id(&mut req, model_id)?;
12613    }
12614    if req.model.is_some() {
12615        req.params.strict_model = true;
12616    }
12617    Ok(req)
12618}
12619
12620#[cfg(test)]
12621mod inference_request_tests {
12622    use super::parse_inference_request;
12623    use serde_json::json;
12624
12625    #[test]
12626    fn explicit_model_is_strict() {
12627        let req = parse_inference_request(&json!({
12628            "prompt": "hello",
12629            "model": "openrouter/qwen/qwen3-coder-next",
12630        }))
12631        .expect("valid explicit-model request");
12632
12633        assert!(req.params.strict_model);
12634    }
12635
12636    #[test]
12637    fn explicit_model_overrides_requested_non_strict_mode() {
12638        let req = parse_inference_request(&json!({
12639            "prompt": "hello",
12640            "model": "openrouter/qwen/qwen3-coder-next",
12641            "params": { "strict_model": false },
12642        }))
12643        .expect("valid explicit-model request");
12644
12645        assert!(req.params.strict_model);
12646    }
12647
12648    #[test]
12649    fn exact_model_id_is_strict_and_does_not_alias() {
12650        let req = parse_inference_request(&json!({
12651            "prompt": "hello",
12652            "model_id": "openai/gpt-5.4:latest",
12653            "params": { "strict_model": false },
12654        }))
12655        .expect("valid exact-id request");
12656
12657        assert_eq!(
12658            car_inference::exact_pinned_model_id(&req),
12659            Some("openai/gpt-5.4:latest")
12660        );
12661        assert!(req.params.strict_model);
12662    }
12663
12664    #[test]
12665    fn legacy_model_and_exact_model_id_are_rejected_together() {
12666        let error = parse_inference_request(&json!({
12667            "prompt": "hello",
12668            "model": "GPT 5.4",
12669            "model_id": "openai/gpt-5.4:latest",
12670        }))
12671        .expect_err("ambiguous dual pin must fail");
12672
12673        assert!(error.contains("mutually exclusive"));
12674    }
12675
12676    #[test]
12677    fn model_less_requests_preserve_requested_strict_mode() {
12678        let loose = parse_inference_request(&json!({
12679            "prompt": "hello",
12680            "params": { "strict_model": false },
12681        }))
12682        .expect("valid adaptive request");
12683        let strict = parse_inference_request(&json!({
12684            "prompt": "hello",
12685            "params": { "strict_model": true },
12686        }))
12687        .expect("valid strict adaptive request");
12688
12689        assert!(!loose.params.strict_model);
12690        assert!(strict.params.strict_model);
12691    }
12692}
12693
12694/// Stringify an inference failure for the dispatcher, tagging a content refusal
12695/// with its stable wire prefix.
12696///
12697/// This is the WRITER half of the pair whose reader is
12698/// [`HandlerFailure::from_dispatch`]; both sides key off
12699/// [`car_proto::CONTENT_REFUSED_MESSAGE_PREFIX`] and must be changed together.
12700///
12701/// Classification happens HERE, by variant, because this is the last point that
12702/// still holds the typed `InferenceError` — `car-inference` already decided
12703/// whether the gateway refused on content grounds (it excludes the case from the
12704/// circuit breaker and books it as a capability rejection), and re-deriving that
12705/// verdict by substring-matching Display text downstream would be a guess.
12706/// `ContentRefused`'s Display carries the gateway's own `type=` / `code=` tags
12707/// and message, so tagging preserves them rather than replacing them.
12708fn inference_dispatch_error(e: &car_inference::InferenceError) -> String {
12709    match e {
12710        car_inference::InferenceError::ContentRefused { .. } => {
12711            format!("{} {e}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX)
12712        }
12713        car_inference::InferenceError::CatalogPreconditionMismatch { detail } => format!(
12714            "{} {detail}",
12715            car_proto::CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX
12716        ),
12717        _ => e.to_string(),
12718    }
12719}
12720
12721/// Same tagging, for a stream failure that reaches us as flattened TEXT.
12722///
12723/// A refusal raised BEFORE the stream opens is a typed `InferenceError` and goes
12724/// through [`inference_dispatch_error`]. One raised mid-stream does not: the
12725/// provider layer has already turned it into `StreamEvent::Error(String)` by the
12726/// time it gets here, so the variant is gone and only the message survives.
12727///
12728/// That is not a licence to invent a substring rule. The verdict is taken from
12729/// `car_inference::stream::content_refusal_tags`, which reads the `type=` /
12730/// `code=` tags the same module's SSE writer appended — the same function
12731/// `car-inference` itself uses on the non-streaming path, so both paths agree on
12732/// what a refusal IS by construction rather than by two rules kept in step by
12733/// hand. A stream whose gateway sent no tags stays a generic failure, which is
12734/// the safe direction (Parslee-ai/car#796).
12735fn stream_dispatch_error(detail: String) -> String {
12736    match car_inference::stream::content_refusal_tags(&detail) {
12737        Some(_) => format!("{} {detail}", car_proto::CONTENT_REFUSED_MESSAGE_PREFIX),
12738        None => detail,
12739    }
12740}
12741
12742async fn run_owned_inference_backend<F>(
12743    registry: Arc<crate::inference_control::InferenceRegistry>,
12744    inference_id: String,
12745    result_tx: tokio::sync::oneshot::Sender<Result<Value, String>>,
12746    work: F,
12747) where
12748    F: std::future::Future<Output = Result<Value, String>>,
12749{
12750    use futures::FutureExt;
12751    use std::panic::AssertUnwindSafe;
12752
12753    let result = match AssertUnwindSafe(work).catch_unwind().await {
12754        Ok(result) => result,
12755        Err(payload) => {
12756            let detail = payload
12757                .downcast_ref::<&str>()
12758                .map(|message| (*message).to_string())
12759                .or_else(|| payload.downcast_ref::<String>().cloned())
12760                .unwrap_or_else(|| "unknown panic payload".to_string());
12761            Err(format!("inference backend panicked: {detail}"))
12762        }
12763    };
12764    registry.backend_terminated(&inference_id);
12765    let _ = result_tx.send(result);
12766}
12767
12768async fn handle_infer(
12769    msg: &JsonRpcMessage,
12770    state: Arc<ServerState>,
12771    session: Arc<crate::session::ClientSession>,
12772) -> Result<Value, String> {
12773    reject_client_inference_id(&msg.params)?;
12774    let current_run = session.current_run_id.lock().await.clone();
12775    let request_id = msg.id.as_str().map(str::to_string);
12776    let (inference_id, mut terminal) = session
12777        .inference_control
12778        .begin_for_run(current_run, request_id)
12779        .map_err(|error| format!("cannot start inference: {error:?}"))?;
12780    let (result_tx, mut result_rx) = tokio::sync::oneshot::channel();
12781    let task_msg = msg.clone();
12782    let task_state = state.clone();
12783    let task_session = session.clone();
12784    let task_registry = session.inference_control.clone();
12785    let task_id = inference_id.clone();
12786    let backend = tokio::spawn(run_owned_inference_backend(
12787        task_registry,
12788        task_id.clone(),
12789        result_tx,
12790        async move {
12791            car_inference::scope_inference_control_id(
12792                task_id.clone(),
12793                handle_infer_active(&task_msg, &task_state, &task_session, &task_id),
12794            )
12795            .await
12796        },
12797    ));
12798    let mut owner =
12799        attach_owned_inference_backend(session.inference_control.clone(), &inference_id, backend)
12800            .map_err(|error| format!("cannot own inference backend: {error:?}"))?;
12801    let exposes_control = session_has_capability(&session, car_proto::INFER_CANCEL_CAPABILITY)
12802        || session_has_capability(&session, car_proto::INFER_DEADLINE_CAPABILITY);
12803    if exposes_control {
12804        if let Err(error) = send_infer_started(&session, &msg.id, &inference_id).await {
12805            return Err(clean_up_failed_infer_started(
12806                &session.inference_control,
12807                &inference_id,
12808                error,
12809            ));
12810        }
12811    }
12812
12813    let result = tokio::select! {
12814        biased;
12815        result = &mut result_rx => match result {
12816          Ok(Ok(mut value)) => {
12817            if session.inference_control.complete(&inference_id) {
12818                if exposes_control {
12819                    insert_inference_id(&mut value, &inference_id)?;
12820                }
12821                Ok(value)
12822            } else {
12823                Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12824            }
12825          }
12826          Ok(Err(error)) => {
12827            if session.inference_control.complete(&inference_id) {
12828                Err(error)
12829            } else {
12830                Ok(wait_terminal_control_value(terminal.clone(), &inference_id).await)
12831            }
12832          }
12833          Err(_) => Err("owned inference backend ended without a result".to_string()),
12834        },
12835        _ = terminal.changed() => {
12836            Ok(wait_terminal_control_value(terminal, &inference_id).await)
12837        }
12838    };
12839    // The engine-side deadline entry must not outlive the inference: a reused
12840    // control id inheriting a stale deadline would cut an innocent request.
12841    car_inference::clear_remote_deadline(&inference_id);
12842    owner.complete_normally();
12843    result
12844}
12845
12846#[derive(Deserialize)]
12847#[serde(deny_unknown_fields)]
12848struct InferCancelParams {
12849    inference_id: String,
12850}
12851
12852#[derive(Deserialize)]
12853#[serde(deny_unknown_fields)]
12854struct InferDeadlineParams {
12855    inference_id: String,
12856    timeout_ms: u64,
12857}
12858
12859async fn exact_backend_termination_ack(inference_id: &str) -> bool {
12860    let Some(offload) = car_inference::current_local_offload() else {
12861        return false;
12862    };
12863    matches!(
12864        offload.terminate_inference(inference_id).await,
12865        car_inference::InferenceTerminationAck::Confirmed
12866    )
12867}
12868
12869async fn handle_infer_cancel(
12870    msg: &JsonRpcMessage,
12871    session: &crate::session::ClientSession,
12872) -> Result<Value, String> {
12873    if !session_has_capability(session, car_proto::INFER_CANCEL_CAPABILITY) {
12874        return Err(format!(
12875            "{} negotiate `{}` as a required or optional capability before calling `infer.cancel`",
12876            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12877            car_proto::INFER_CANCEL_CAPABILITY
12878        ));
12879    }
12880    let params: InferCancelParams = typed_params(&msg.params)?;
12881    if params.inference_id.is_empty() {
12882        return Err("`inference_id` must be a non-empty opaque string".into());
12883    }
12884    let _pending = session
12885        .inference_control
12886        .try_acquire_control()
12887        .map_err(|error| format!("infer.cancel control admission rejected: {error:?}"))?;
12888    let status = session
12889        .inference_control
12890        .control(
12891            &params.inference_id,
12892            crate::inference_control::ControlCause::Cancel,
12893            exact_backend_termination_ack(&params.inference_id),
12894        )
12895        .await;
12896    serde_json::to_value(car_proto::InferenceControlResponse {
12897        inference_id: params.inference_id,
12898        status,
12899    })
12900    .map_err(|error| format!("serialize infer.cancel response: {error}"))
12901}
12902
12903async fn handle_infer_deadline(
12904    msg: &JsonRpcMessage,
12905    session: &crate::session::ClientSession,
12906) -> Result<Value, String> {
12907    if !session_has_capability(session, car_proto::INFER_DEADLINE_CAPABILITY) {
12908        return Err(format!(
12909            "{} negotiate `{}` as a required or optional capability before calling `infer.deadline`",
12910            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
12911            car_proto::INFER_DEADLINE_CAPABILITY
12912        ));
12913    }
12914    let params: InferDeadlineParams = typed_params(&msg.params)?;
12915    if params.inference_id.is_empty() {
12916        return Err("`inference_id` must be a non-empty opaque string".into());
12917    }
12918    if params.timeout_ms == 0
12919        || params.timeout_ms > crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12920    {
12921        return Err(format!(
12922            "`timeout_ms` must be between 1 and {}",
12923            crate::inference_control::MAX_DEADLINE_TIMEOUT_MS
12924        ));
12925    }
12926
12927    let ack_id = params.inference_id.clone();
12928    let status = match session
12929        .inference_control
12930        .schedule_deadline(
12931            &params.inference_id,
12932            std::time::Duration::from_millis(params.timeout_ms),
12933            || exact_backend_termination_ack(&ack_id),
12934        )
12935        .await
12936    {
12937        Ok(status) => {
12938            // Beyond the registry's kill-switch, arm the ENGINE-side deadline:
12939            // the remote retry loop derives its per-phase and retry budgets
12940            // from it, so the provider request survives past the unarmed
12941            // transport defaults up to the caller's bound, and a termination
12942            // names the deadline that was applied (car-eyj).
12943            car_inference::set_remote_deadline(
12944                &params.inference_id,
12945                std::time::Duration::from_millis(params.timeout_ms),
12946            );
12947            status
12948        }
12949        Err(crate::inference_control::DeadlineReservationError::Terminal(status)) => {
12950            return serde_json::to_value(car_proto::InferenceControlResponse {
12951                inference_id: params.inference_id,
12952                status,
12953            })
12954            .map_err(|error| format!("serialize infer.deadline response: {error}"));
12955        }
12956        Err(crate::inference_control::DeadlineReservationError::Registry(
12957            crate::inference_control::RegistryError::PendingControlLimitReached,
12958        )) => {
12959            return Err(
12960                "infer.deadline control admission rejected: PendingControlLimitReached".into(),
12961            );
12962        }
12963        Err(crate::inference_control::DeadlineReservationError::Registry(error)) => {
12964            return Err(format!("infer.deadline rejected: {error:?}"));
12965        }
12966    };
12967    serde_json::to_value(car_proto::InferenceControlResponse {
12968        inference_id: params.inference_id,
12969        status,
12970    })
12971    .map_err(|error| format!("serialize infer.deadline response: {error}"))
12972}
12973
12974fn reject_client_inference_id(params: &Value) -> Result<(), String> {
12975    if params.get("inference_id").is_some() {
12976        return Err("`inference_id` is server-assigned and cannot be supplied or reused".into());
12977    }
12978    Ok(())
12979}
12980
12981#[derive(Debug)]
12982enum InferStartedSendError {
12983    BeforeAdmission(String),
12984    AfterAdmission(String),
12985}
12986
12987impl InferStartedSendError {
12988    fn may_have_been_admitted(&self) -> bool {
12989        matches!(self, Self::AfterAdmission(_))
12990    }
12991}
12992
12993impl std::fmt::Display for InferStartedSendError {
12994    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12995        match self {
12996            Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
12997                formatter.write_str(message)
12998            }
12999        }
13000    }
13001}
13002
13003fn clean_up_failed_infer_started(
13004    registry: &crate::inference_control::InferenceRegistry,
13005    inference_id: &str,
13006    error: InferStartedSendError,
13007) -> String {
13008    if error.may_have_been_admitted() {
13009        registry.abort_after_started_admission(inference_id);
13010    } else {
13011        registry.abandon(inference_id);
13012    }
13013    error.to_string()
13014}
13015
13016struct InferenceOwnerGuard {
13017    registry: Arc<crate::inference_control::InferenceRegistry>,
13018    inference_id: String,
13019    completed_normally: bool,
13020}
13021
13022impl InferenceOwnerGuard {
13023    fn new(
13024        registry: Arc<crate::inference_control::InferenceRegistry>,
13025        inference_id: String,
13026    ) -> Self {
13027        Self {
13028            registry,
13029            inference_id,
13030            completed_normally: false,
13031        }
13032    }
13033
13034    fn complete_normally(&mut self) {
13035        self.completed_normally = true;
13036    }
13037}
13038
13039impl Drop for InferenceOwnerGuard {
13040    fn drop(&mut self) {
13041        if !self.completed_normally {
13042            self.registry.abort_owned_backend(&self.inference_id);
13043        }
13044    }
13045}
13046
13047fn attach_owned_inference_backend(
13048    registry: Arc<crate::inference_control::InferenceRegistry>,
13049    inference_id: &str,
13050    backend: tokio::task::JoinHandle<()>,
13051) -> Result<InferenceOwnerGuard, crate::inference_control::RegistryError> {
13052    registry.attach_backend(inference_id, backend)?;
13053    Ok(InferenceOwnerGuard::new(registry, inference_id.to_string()))
13054}
13055
13056async fn send_infer_started(
13057    session: &crate::session::ClientSession,
13058    request_id: &Value,
13059    inference_id: &str,
13060) -> Result<(), InferStartedSendError> {
13061    use futures::SinkExt;
13062    use tokio_tungstenite::tungstenite::Message;
13063
13064    let frame = serde_json::json!({
13065        "jsonrpc": "2.0",
13066        "method": "infer.started",
13067        "params": {
13068            "request_id": request_id,
13069            "inference_id": inference_id,
13070        },
13071    });
13072    let text = serde_json::to_string(&frame)
13073        .map_err(|error| InferStartedSendError::BeforeAdmission(error.to_string()))?;
13074    let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
13075    let mut write = tokio::time::timeout_at(deadline, session.channel.write.lock())
13076        .await
13077        .map_err(|_| {
13078            InferStartedSendError::BeforeAdmission(format!(
13079                "send infer.started timed out before admission after {}s",
13080                KEEPALIVE_WRITE_TIMEOUT.as_secs()
13081            ))
13082        })?;
13083
13084    match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
13085        Ok(Ok(())) => {}
13086        Ok(Err(error)) => {
13087            // A custom sink may enqueue in `start_send` before returning an
13088            // error. Retain the ID as terminal rather than assuming it stayed
13089            // invisible.
13090            return Err(InferStartedSendError::AfterAdmission(format!(
13091                "send infer.started admission failed: {error}"
13092            )));
13093        }
13094        Err(_) => {
13095            // `feed` only yields Pending while waiting for poll_ready; it has
13096            // not called synchronous start_send yet.
13097            return Err(InferStartedSendError::BeforeAdmission(format!(
13098                "send infer.started timed out before admission after {}s",
13099                KEEPALIVE_WRITE_TIMEOUT.as_secs()
13100            )));
13101        }
13102    }
13103
13104    match tokio::time::timeout_at(deadline, write.flush()).await {
13105        Ok(Ok(())) => Ok(()),
13106        Ok(Err(error)) => Err(InferStartedSendError::AfterAdmission(format!(
13107            "flush infer.started: {error}"
13108        ))),
13109        Err(_) => Err(InferStartedSendError::AfterAdmission(format!(
13110            "flush infer.started timed out after {}s; inference is terminal",
13111            KEEPALIVE_WRITE_TIMEOUT.as_secs()
13112        ))),
13113    }
13114}
13115
13116#[cfg(test)]
13117mod inference_backend_reliability_tests {
13118    use super::{
13119        attach_owned_inference_backend, clean_up_failed_infer_started, run_owned_inference_backend,
13120        send_infer_started, send_inference_notification_if_active, KEEPALIVE_WRITE_TIMEOUT,
13121    };
13122    use crate::inference_control::{InferenceRegistry, RegistryConfig};
13123    use crate::session::{ServerState, WsChannel, WsSink};
13124    use futures::{Sink, SinkExt};
13125    use serde_json::json;
13126    use std::collections::HashMap;
13127    use std::pin::Pin;
13128    use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
13129    use std::sync::Arc;
13130    use std::task::{Context, Poll};
13131    use std::time::Duration;
13132    use tokio::sync::Mutex;
13133    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
13134
13135    struct FlushStallingSink {
13136        pending: Arc<std::sync::Mutex<Vec<Message>>>,
13137        visible: Arc<std::sync::Mutex<Vec<Message>>>,
13138        flush_ready: Arc<AtomicBool>,
13139    }
13140
13141    impl Sink<Message> for FlushStallingSink {
13142        type Error = WsError;
13143
13144        fn poll_ready(
13145            self: Pin<&mut Self>,
13146            _context: &mut Context<'_>,
13147        ) -> Poll<Result<(), Self::Error>> {
13148            Poll::Ready(Ok(()))
13149        }
13150
13151        fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
13152            self.pending
13153                .lock()
13154                .expect("pending frames lock")
13155                .push(message);
13156            Ok(())
13157        }
13158
13159        fn poll_flush(
13160            self: Pin<&mut Self>,
13161            _context: &mut Context<'_>,
13162        ) -> Poll<Result<(), Self::Error>> {
13163            if !self.flush_ready.load(Ordering::SeqCst) {
13164                return Poll::Pending;
13165            }
13166            let mut pending = self.pending.lock().expect("pending frames lock");
13167            self.visible
13168                .lock()
13169                .expect("visible frames lock")
13170                .extend(pending.drain(..));
13171            Poll::Ready(Ok(()))
13172        }
13173
13174        fn poll_close(
13175            self: Pin<&mut Self>,
13176            context: &mut Context<'_>,
13177        ) -> Poll<Result<(), Self::Error>> {
13178            self.poll_flush(context)
13179        }
13180    }
13181
13182    fn one_slot_registry() -> Arc<InferenceRegistry> {
13183        Arc::new(InferenceRegistry::with_config(RegistryConfig {
13184            max_active: 1,
13185            max_tombstones: 1,
13186            max_pending_controls: 1,
13187            max_orphans: 1,
13188            tombstone_ttl: Duration::from_secs(300),
13189            termination_ack_timeout: Duration::from_millis(20),
13190        }))
13191    }
13192
13193    #[tokio::test]
13194    async fn panicking_backend_publishes_terminal_error_and_releases_registry_capacity() {
13195        let registry = one_slot_registry();
13196        registry
13197            .begin_with_id("panic-stream")
13198            .expect("register panicking stream");
13199        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
13200        let task = tokio::spawn(run_owned_inference_backend(
13201            registry.clone(),
13202            "panic-stream".to_string(),
13203            result_tx,
13204            async {
13205                panic!("fixture stream backend panic");
13206                #[allow(unreachable_code)]
13207                Ok(json!({}))
13208            },
13209        ));
13210        registry
13211            .attach_backend("panic-stream", task)
13212            .expect("attach panicking stream");
13213
13214        let error = result_rx
13215            .await
13216            .expect("panic must be converted into a published result")
13217            .expect_err("panic must be a terminal error");
13218        assert!(error.contains("backend panicked"), "{error}");
13219        assert!(
13220            registry.complete("panic-stream"),
13221            "client-visible failure must terminalize the active entry"
13222        );
13223        assert_eq!(registry.counts().0, 0);
13224        registry
13225            .begin_with_id("replacement-stream")
13226            .expect("panicking backend must not consume the one session slot forever");
13227    }
13228
13229    #[tokio::test]
13230    async fn panic_before_attach_and_receiver_drop_still_releases_backend_ownership() {
13231        let registry = one_slot_registry();
13232        registry
13233            .begin_with_id("panic-before-attach")
13234            .expect("register panicking backend");
13235        let (result_tx, result_rx) = tokio::sync::oneshot::channel();
13236        drop(result_rx);
13237        run_owned_inference_backend(
13238            registry.clone(),
13239            "panic-before-attach".to_string(),
13240            result_tx,
13241            async {
13242                panic!("panic before JoinHandle attachment");
13243                #[allow(unreachable_code)]
13244                Ok(json!({}))
13245            },
13246        )
13247        .await;
13248        registry
13249            .attach_backend("panic-before-attach", tokio::spawn(async {}))
13250            .expect("late attachment observes prior termination");
13251        assert!(registry.complete("panic-before-attach"));
13252        assert_eq!(registry.counts().0, 0);
13253        registry
13254            .begin_with_id("after-panic-before-attach")
13255            .expect("panic before attachment must not strand capacity");
13256    }
13257
13258    #[tokio::test(start_paused = true)]
13259    async fn infer_started_write_timeout_cleans_up_instead_of_waiting_on_the_socket_forever() {
13260        let tmp = tempfile::TempDir::new().expect("temporary server state");
13261        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
13262        let channel = Arc::new(WsChannel::test_stub());
13263        let session = state
13264            .create_session("stalled-infer-start", channel.clone())
13265            .await
13266            .expect("create test session");
13267        let registry = session.inference_control.clone();
13268        registry
13269            .begin_with_id("stalled-start")
13270            .expect("register pending inference");
13271        let backend = tokio::spawn(std::future::pending::<()>());
13272        registry
13273            .attach_backend("stalled-start", backend)
13274            .expect("attach pending backend");
13275
13276        let _held_write = channel.write.lock().await;
13277        let task_session = session.clone();
13278        let send = tokio::spawn(async move {
13279            let result = send_infer_started(&task_session, &json!(7), "stalled-start").await;
13280            if result.is_err() {
13281                task_session.inference_control.abandon("stalled-start");
13282            }
13283            result
13284        });
13285        tokio::task::yield_now().await;
13286        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13287
13288        let error = tokio::time::timeout(Duration::from_millis(1), send)
13289            .await
13290            .expect("infer.started must carry its own bounded-write policy")
13291            .expect("send task joins")
13292            .expect_err("held write lock must time out");
13293        assert!(error.to_string().contains("timed out"), "{error}");
13294        assert_eq!(registry.counts(), (0, 0), "failed start must be removed");
13295    }
13296
13297    #[tokio::test(start_paused = true)]
13298    async fn infer_started_flush_timeout_keeps_any_later_visible_id_terminally_queryable() {
13299        let tmp = tempfile::TempDir::new().expect("temporary server state");
13300        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
13301        let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13302        let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13303        let flush_ready = Arc::new(AtomicBool::new(false));
13304        let sink: WsSink = Box::pin(FlushStallingSink {
13305            pending: pending.clone(),
13306            visible: visible.clone(),
13307            flush_ready: flush_ready.clone(),
13308        });
13309        let channel = Arc::new(WsChannel {
13310            write: Mutex::new(sink),
13311            pending: Mutex::new(HashMap::new()),
13312            active_actions: Mutex::new(HashMap::new()),
13313            next_id: AtomicU64::new(0),
13314        });
13315        let session = state
13316            .create_session("partial-infer-start", channel.clone())
13317            .await
13318            .expect("create test session");
13319        let registry = session.inference_control.clone();
13320        registry
13321            .begin_with_id("partially-admitted-start")
13322            .expect("register pending inference");
13323        registry
13324            .attach_backend(
13325                "partially-admitted-start",
13326                tokio::spawn(std::future::pending::<()>()),
13327            )
13328            .expect("attach pending backend");
13329
13330        let task_session = session.clone();
13331        let send = tokio::spawn(async move {
13332            let error = send_infer_started(&task_session, &json!(8), "partially-admitted-start")
13333                .await
13334                .expect_err("flush must stall");
13335            clean_up_failed_infer_started(
13336                &task_session.inference_control,
13337                "partially-admitted-start",
13338                error,
13339            )
13340        });
13341        tokio::task::yield_now().await;
13342        assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13343        assert!(visible.lock().expect("visible frames lock").is_empty());
13344
13345        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13346        let error = send.await.expect("send task joins");
13347        assert!(error.contains("terminal"), "{error}");
13348        assert_eq!(registry.counts(), (0, 1));
13349        assert!(
13350            registry
13351                .terminal_outcome("partially-admitted-start")
13352                .is_some(),
13353            "a queued infer.started ID must remain queryable"
13354        );
13355
13356        flush_ready.store(true, Ordering::SeqCst);
13357        channel
13358            .write
13359            .lock()
13360            .await
13361            .send(Message::Text(
13362                json!({"jsonrpc":"2.0","id":8,"error":{"code":-32603}})
13363                    .to_string()
13364                    .into(),
13365            ))
13366            .await
13367            .expect("later dispatcher response flushes queued frames");
13368        let visible = visible.lock().expect("visible frames lock");
13369        assert_eq!(visible.len(), 2);
13370        let started: serde_json::Value = match &visible[0] {
13371            Message::Text(text) => serde_json::from_str(text).expect("started JSON"),
13372            other => panic!("expected text frame, got {other:?}"),
13373        };
13374        assert_eq!(
13375            started["params"]["inference_id"],
13376            "partially-admitted-start"
13377        );
13378        assert!(
13379            registry
13380                .terminal_outcome("partially-admitted-start")
13381                .is_some(),
13382            "later visibility must not turn the admitted ID into Unknown"
13383        );
13384    }
13385
13386    #[tokio::test(start_paused = true)]
13387    async fn notification_write_budget_reports_whether_a_frame_was_admitted() {
13388        let registry = one_slot_registry();
13389        registry
13390            .begin_with_id("notification-lock-timeout")
13391            .expect("register lock-stalled notification");
13392        let channel = Arc::new(WsChannel::test_stub());
13393        let held_write = channel.write.lock().await;
13394        let task_channel = channel.clone();
13395        let task_registry = registry.clone();
13396        let lock_timeout = tokio::spawn(async move {
13397            send_inference_notification_if_active(
13398                &task_channel,
13399                &task_registry,
13400                "notification-lock-timeout",
13401                json!({"method":"infer.progress"}).to_string(),
13402            )
13403            .await
13404            .expect_err("held writer lock must time out")
13405        });
13406        tokio::task::yield_now().await;
13407        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13408        let error = lock_timeout.await.expect("lock-timeout task joins");
13409        assert!(!error.may_have_been_admitted(), "{error}");
13410        assert!(
13411            error.to_string().contains("write lock timed out"),
13412            "{error}"
13413        );
13414        drop(held_write);
13415        registry.abandon("notification-lock-timeout");
13416
13417        let registry = one_slot_registry();
13418        registry
13419            .begin_with_id("progress-flush-timeout")
13420            .expect("register flush-stalled notification");
13421        let pending = Arc::new(std::sync::Mutex::new(Vec::new()));
13422        let visible = Arc::new(std::sync::Mutex::new(Vec::new()));
13423        let flush_ready = Arc::new(AtomicBool::new(false));
13424        let sink: WsSink = Box::pin(FlushStallingSink {
13425            pending: pending.clone(),
13426            visible: visible.clone(),
13427            flush_ready,
13428        });
13429        let channel = Arc::new(WsChannel {
13430            write: Mutex::new(sink),
13431            pending: Mutex::new(HashMap::new()),
13432            active_actions: Mutex::new(HashMap::new()),
13433            next_id: AtomicU64::new(0),
13434        });
13435        let task_registry = registry.clone();
13436        let flush_timeout = tokio::spawn(async move {
13437            send_inference_notification_if_active(
13438                &channel,
13439                &task_registry,
13440                "progress-flush-timeout",
13441                json!({"method":"infer.progress"}).to_string(),
13442            )
13443            .await
13444            .expect_err("flush-stalled notification must time out")
13445        });
13446        tokio::task::yield_now().await;
13447        assert_eq!(pending.lock().expect("pending frames lock").len(), 1);
13448        assert!(visible.lock().expect("visible frames lock").is_empty());
13449        tokio::time::advance(KEEPALIVE_WRITE_TIMEOUT + Duration::from_millis(1)).await;
13450        let error = flush_timeout.await.expect("flush-timeout task joins");
13451        assert!(error.may_have_been_admitted(), "{error}");
13452        assert!(error.to_string().contains("flush timed out"), "{error}");
13453        registry.abort_owned_backend("progress-flush-timeout");
13454        assert!(registry
13455            .terminal_outcome("progress-flush-timeout")
13456            .is_some());
13457    }
13458
13459    #[tokio::test]
13460    async fn dropping_an_attached_owner_aborts_infer_backend() {
13461        for method in ["infer"] {
13462            let registry = one_slot_registry();
13463            let inference_id = format!("dropped-owner-{method}");
13464            registry
13465                .begin_with_id(&inference_id)
13466                .expect("register owned backend");
13467            let admission = Arc::new(tokio::sync::Semaphore::new(1));
13468            let task_admission = admission.clone();
13469            let backend = tokio::spawn(async move {
13470                let _permit = task_admission
13471                    .acquire_owned()
13472                    .await
13473                    .expect("admission open");
13474                std::future::pending::<()>().await;
13475            });
13476            let owner_guard =
13477                attach_owned_inference_backend(registry.clone(), &inference_id, backend)
13478                    .expect("attach through the shared handler ownership path");
13479            let owner = tokio::spawn(async move {
13480                let _owner = owner_guard;
13481                std::future::pending::<()>().await;
13482            });
13483            tokio::task::yield_now().await;
13484            assert_eq!(admission.available_permits(), 0);
13485
13486            owner.abort();
13487            assert!(owner
13488                .await
13489                .expect_err("owner future is cancelled")
13490                .is_cancelled());
13491            tokio::task::yield_now().await;
13492
13493            assert_eq!(admission.available_permits(), 1, "{method}");
13494            assert_eq!(registry.counts(), (0, 1), "{method}");
13495            assert_eq!(registry.orphan_count(), 0, "{method}");
13496            assert!(
13497                registry.terminal_outcome(&inference_id).is_some(),
13498                "{method}"
13499            );
13500            registry
13501                .begin_with_id(&format!("replacement-{method}"))
13502                .expect("owner drop releases per-session capacity");
13503            registry.abort_all();
13504        }
13505    }
13506}
13507
13508fn insert_inference_id(value: &mut Value, inference_id: &str) -> Result<(), String> {
13509    value
13510        .as_object_mut()
13511        .ok_or_else(|| "inference result must be a JSON object".to_string())?
13512        .insert(
13513            "inference_id".to_string(),
13514            Value::String(inference_id.to_string()),
13515        );
13516    Ok(())
13517}
13518
13519/// Serialize inference notifications with terminal responses on the WebSocket
13520/// write lock, then re-check lifecycle state at the last safe point before the
13521/// frame reaches the wire. A terminal response can therefore never be
13522/// overtaken by a stream/progress frame that observed stale active state while
13523/// waiting for the shared sink.
13524async fn send_inference_notification_if_active(
13525    channel: &WsChannel,
13526    registry: &crate::inference_control::InferenceRegistry,
13527    inference_id: &str,
13528    text: String,
13529) -> Result<bool, InferenceNotificationSendError> {
13530    let deadline = tokio::time::Instant::now() + KEEPALIVE_WRITE_TIMEOUT;
13531    let mut write = tokio::time::timeout_at(deadline, channel.write.lock())
13532        .await
13533        .map_err(|_| {
13534            InferenceNotificationSendError::BeforeAdmission(format!(
13535                "notification write lock timed out after {}s",
13536                KEEPALIVE_WRITE_TIMEOUT.as_secs()
13537            ))
13538        })?;
13539    if !registry.is_active(inference_id) {
13540        return Ok(false);
13541    }
13542
13543    match tokio::time::timeout_at(deadline, write.feed(Message::Text(text.into()))).await {
13544        Ok(Ok(())) => {}
13545        Ok(Err(error)) => {
13546            return Err(InferenceNotificationSendError::AfterAdmission(format!(
13547                "notification admission failed: {error}"
13548            )));
13549        }
13550        Err(_) => {
13551            return Err(InferenceNotificationSendError::BeforeAdmission(format!(
13552                "notification admission timed out after {}s",
13553                KEEPALIVE_WRITE_TIMEOUT.as_secs()
13554            )));
13555        }
13556    }
13557
13558    match tokio::time::timeout_at(deadline, write.flush()).await {
13559        Ok(Ok(())) => Ok(true),
13560        Ok(Err(error)) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13561            "notification flush failed: {error}"
13562        ))),
13563        Err(_) => Err(InferenceNotificationSendError::AfterAdmission(format!(
13564            "notification flush timed out after {}s",
13565            KEEPALIVE_WRITE_TIMEOUT.as_secs()
13566        ))),
13567    }
13568}
13569
13570#[derive(Debug)]
13571enum InferenceNotificationSendError {
13572    BeforeAdmission(String),
13573    AfterAdmission(String),
13574}
13575
13576impl InferenceNotificationSendError {
13577    #[cfg(test)]
13578    fn may_have_been_admitted(&self) -> bool {
13579        matches!(self, Self::AfterAdmission(_))
13580    }
13581}
13582
13583impl std::fmt::Display for InferenceNotificationSendError {
13584    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13585        match self {
13586            Self::BeforeAdmission(message) | Self::AfterAdmission(message) => {
13587                formatter.write_str(message)
13588            }
13589        }
13590    }
13591}
13592
13593#[cfg(test)]
13594mod inference_notification_terminal_boundary_tests {
13595    use super::send_inference_notification_if_active;
13596    use crate::inference_control::{ControlCause, InferenceRegistry};
13597    use crate::session::WsChannel;
13598    use car_proto::InferenceControlStatus;
13599    use futures::SinkExt;
13600    use std::sync::Arc;
13601    use tokio::sync::oneshot;
13602    use tokio_tungstenite::tungstenite::Message;
13603
13604    #[tokio::test]
13605    async fn terminal_response_is_a_hard_boundary_for_progress_frames() {
13606        for (inference_id, cause, expected, wire_status) in [
13607            (
13608                "inf_terminal_boundary_cancel",
13609                ControlCause::Cancel,
13610                InferenceControlStatus::CancelledConfirmed,
13611                "cancelled_confirmed",
13612            ),
13613            (
13614                "inf_terminal_boundary_deadline",
13615                ControlCause::Deadline,
13616                InferenceControlStatus::DeadlineExceededConfirmed,
13617                "deadline_exceeded_confirmed",
13618            ),
13619        ] {
13620            let registry = Arc::new(InferenceRegistry::default());
13621            registry
13622                .begin_with_id(inference_id)
13623                .expect("register active inference");
13624            let (channel, captured) = WsChannel::test_capture();
13625            let channel = Arc::new(channel);
13626
13627            // The held write guard is the deterministic barrier: both notification
13628            // tasks have received and serialized their event, but neither can reach
13629            // the wire until after the terminal response is committed below.
13630            let mut boundary = channel.write.lock().await;
13631            assert!(registry.is_active(inference_id), "event precheck is active");
13632
13633            let frame = serde_json::json!({
13634                "jsonrpc": "2.0",
13635                "method": "infer.progress",
13636                "params": {"id": 1},
13637            });
13638            let channel = channel.clone();
13639            let notification_registry = registry.clone();
13640            let text = serde_json::to_string(&frame).expect("serialize notification");
13641            let (started_tx, started_rx) = oneshot::channel();
13642            let pending = tokio::spawn(async move {
13643                started_tx.send(()).expect("signal event receipt");
13644                send_inference_notification_if_active(
13645                    &channel,
13646                    &notification_registry,
13647                    inference_id,
13648                    text,
13649                )
13650                .await
13651            });
13652            started_rx.await.expect("notification task reached barrier");
13653
13654            let terminal = registry.control(inference_id, cause, async { true }).await;
13655            assert_eq!(terminal, expected);
13656            boundary
13657                .send(Message::Text(
13658                    serde_json::json!({
13659                        "jsonrpc": "2.0",
13660                        "id": "terminal-1",
13661                        "result": {
13662                            "inference_id": inference_id,
13663                            "status": wire_status,
13664                        },
13665                    })
13666                    .to_string()
13667                    .into(),
13668                ))
13669                .await
13670                .expect("write terminal response");
13671            drop(boundary);
13672
13673            assert!(
13674                !pending
13675                    .await
13676                    .expect("notification task")
13677                    .expect("notification send"),
13678                "terminal notification must be suppressed"
13679            );
13680
13681            let frames = captured.lock().expect("capture lock");
13682            assert_eq!(frames.len(), 1, "no frame may follow terminal response");
13683            assert!(frames[0].contains(wire_status));
13684        }
13685        eprintln!(
13686            "C3_TERMINAL_BOUNDARY_SHARED=terminals=cancelled_confirmed+deadline_exceeded_confirmed,late_progress=suppressed,frames=1_each"
13687        );
13688    }
13689}
13690
13691async fn wait_terminal_control_value(
13692    mut terminal: tokio::sync::watch::Receiver<Option<crate::inference_control::TerminalOutcome>>,
13693    inference_id: &str,
13694) -> Value {
13695    if terminal.borrow().is_none() {
13696        let _ = terminal.changed().await;
13697    }
13698    let status = match *terminal.borrow() {
13699        Some(crate::inference_control::TerminalOutcome::Controlled(status)) => status,
13700        _ => car_proto::InferenceControlStatus::AlreadyTerminal,
13701    };
13702    serde_json::to_value(car_proto::InferenceControlResponse {
13703        inference_id: inference_id.to_string(),
13704        status,
13705    })
13706    .unwrap_or_else(|_| {
13707        serde_json::json!({
13708            "inference_id": inference_id,
13709            "status": "already_terminal",
13710        })
13711    })
13712}
13713
13714async fn handle_infer_active(
13715    msg: &JsonRpcMessage,
13716    state: &ServerState,
13717    session: &crate::session::ClientSession,
13718    inference_id: &str,
13719) -> Result<Value, String> {
13720    use futures::FutureExt;
13721    use std::panic::AssertUnwindSafe;
13722    use std::time::Duration;
13723
13724    let engine = get_inference_engine(state);
13725    let mut req = parse_inference_request(&msg.params)?;
13726
13727    // If context_query is provided, build context from memgine and inject it
13728    if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13729        // `effective_memgine` (#82): injected context must come from the graph
13730        // the session's facts went into, not the ephemeral one.
13731        let memgine_arc = session.effective_memgine().await;
13732        let mut memgine = memgine_arc.lock().await;
13733        // Split at the stable Identity+Constraints boundary so an Anthropic
13734        // cached request breaks after the stable prefix (which hits) instead of
13735        // over the whole churning context. `full` is byte-identical to the old
13736        // `build_context`, so non-Anthropic providers and StateBench see the
13737        // same context; the prefix is just a hint the handler validates.
13738        let split = memgine.build_context_split_for_model(cq, None);
13739        if !split.full.is_empty() {
13740            req.context_stable_prefix = split.stable_prefix();
13741            req.context = Some(split.full);
13742        }
13743    }
13744    // Stamp the originating session so a delegated call reaches the runner THIS
13745    // host registered, not whichever registered last (car-releases#77). Never
13746    // serialized — `caller` is `#[serde(skip)]`, so it does not reach the host.
13747    req.caller = Some(session.client_id.clone());
13748    maybe_apply_proactive_memory(msg, session, &mut req).await?;
13749
13750    // Stakes-aware routing: a session authorized for FullAccess (irreversible /
13751    // externally-consequential) actions routes its inference quality-first — we
13752    // never economize on a session that can take actions it can't take back. The
13753    // policy→intent mapping lives here (car-server-core has both the session
13754    // tier and the request); car-inference stays policy-free.
13755    if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
13756    {
13757        req.intent.get_or_insert_with(Default::default).high_stakes = true;
13758    }
13759
13760    // Conversation-outcome (Part B): capture the chat user turn + idle-guard
13761    // verdict before `req` is consumed by generate_tracked. Shared with the
13762    // streaming path via `begin_chat_turn` (gate + concurrency rules live there).
13763    let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
13764
13765    // Heartbeat (car#476). A cold local model-load, a queued admission slot, or
13766    // a slow remote can each keep this handler busy well past the FFI client's
13767    // old flat 30s read window. Emit an `infer.progress` notification carrying
13768    // this request id every INFER_HEARTBEAT_INTERVAL for the whole in-flight
13769    // span, so the client holds an *idle* read deadline (reset per heartbeat)
13770    // and only reaps true daemon silence. Model-agnostic on purpose: a local
13771    // cold-start and a slow remote are both covered without the client having
13772    // to know which model the router picked. The guard aborts the ticker the
13773    // instant this handler returns (any path); an older client that doesn't
13774    // idle-reset simply drops the unknown notification.
13775    const INFER_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(10);
13776    struct HeartbeatGuard(tokio::task::JoinHandle<()>);
13777    impl Drop for HeartbeatGuard {
13778        fn drop(&mut self) {
13779            self.0.abort();
13780        }
13781    }
13782    let _heartbeat = {
13783        let channel = session.channel.clone();
13784        let request_id = msg.id.clone();
13785        let registry = session.inference_control.clone();
13786        let inference_id = inference_id.to_string();
13787        HeartbeatGuard(tokio::spawn(async move {
13788            let mut ticker = tokio::time::interval(INFER_HEARTBEAT_INTERVAL);
13789            ticker.tick().await; // consume the immediate first tick (fires at t=0)
13790            loop {
13791                ticker.tick().await;
13792                let notif = serde_json::json!({
13793                    "jsonrpc": "2.0",
13794                    "method": "infer.progress",
13795                    "params": { "id": request_id },
13796                });
13797                let Ok(text) = serde_json::to_string(&notif) else {
13798                    break;
13799                };
13800                match send_inference_notification_if_active(
13801                    &channel,
13802                    &registry,
13803                    &inference_id,
13804                    text,
13805                )
13806                .await
13807                {
13808                    Ok(true) => {}
13809                    Ok(false) | Err(_) => break,
13810                }
13811            }
13812        }))
13813    };
13814
13815    // Process-wide admission gate. Held for the duration of the
13816    // generation so a burst of concurrent infer RPCs can't multiply
13817    // KV-cache + activation memory and take the host out. The
13818    // `_permit` binding is intentional — its `Drop` releases the slot
13819    // when this future returns.
13820    //
13821    // Which POOL depends on whether this host will do the compute. A remote
13822    // call allocates no KV cache here, so charging it a RAM-sized permit capped
13823    // remote throughput by local memory (car#800). We can only tell when the
13824    // request PINS a model: routing picks the model later, inside
13825    // generate_tracked, and by then the permit is already held. Unpinned — and
13826    // delegated, which may be a cloud API or a host driving a local llama.cpp —
13827    // stays on the local pool. Guessing "remote" would strip the guard from
13828    // exactly the call that needs it.
13829    let does_local_compute =
13830        match car_inference::exact_pinned_model_id(&req).or(req.model.as_deref()) {
13831            Some(pinned) => !engine
13832                .registered_schema(pinned)
13833                .map(|s| s.is_remote())
13834                .unwrap_or(false),
13835            None => true,
13836        };
13837    let _permit = state.admission.acquire_for(does_local_compute).await;
13838
13839    // Use generate_tracked() so tool_calls, usage, model_used, trace_id, and
13840    // latency_ms are preserved in the response. Plain `generate()` discards
13841    // everything except `.text`, which silently breaks tool-use over the
13842    // WebSocket protocol (issue #43).
13843    //
13844    // NOTE: This directly serializes `InferenceResult`. Any field added to
13845    // that struct in `car-inference` becomes part of the public WebSocket
13846    // protocol. The shape is locked by `inference_result_serializes_*` tests
13847    // in car-inference; updating those tests is part of intentionally
13848    // changing the wire contract.
13849    // Bound the inference so a dead compute backend can never hang the client
13850    // forever. The 10s heartbeat above keeps the client's idle read deadline
13851    // alive for the whole in-flight span, so without this bound a compute that
13852    // dies silently reads as an infinite hang rather than an error. Two failure
13853    // modes are covered: (1) the compute panics and unwinds through this future
13854    // (e.g. candle re-panicking a spawn_blocking JoinError — the observed
13855    // cudarc/cuBLAS-load case) → caught by catch_unwind; (2) the compute dies on
13856    // a detached task, leaving this await pending forever → caught by the
13857    // timeout. Tunable via CAR_INFER_TIMEOUT_SECS (default 600s — generous
13858    // enough for a cold large-model load, which the heartbeat also covers).
13859    let infer_timeout = inference_total_timeout();
13860    let result = match tokio::time::timeout(
13861        infer_timeout,
13862        AssertUnwindSafe(engine.generate_tracked(req)).catch_unwind(),
13863    )
13864    .await
13865    {
13866        Ok(Ok(r)) => r.map_err(|e| inference_dispatch_error(&e))?,
13867        Ok(Err(panic)) => {
13868            let detail = panic
13869                .downcast_ref::<&str>()
13870                .map(|s| (*s).to_string())
13871                .or_else(|| panic.downcast_ref::<String>().cloned())
13872                .unwrap_or_else(|| "unknown panic".to_string());
13873            return Err(format!(
13874                "inference failed (internal panic): {detail}. If this is a CUDA build, \
13875                 ensure the CUDA toolkit `bin` directory is on PATH so cudarc can load \
13876                 cublas/cudart."
13877            ));
13878        }
13879        Err(_) => {
13880            return Err(format!(
13881                "inference timed out after {}s with no result — the compute backend may \
13882                 have failed to initialize (e.g. a CUDA/cuBLAS library load error). \
13883                 Set CAR_INFER_TIMEOUT_SECS to adjust the limit.",
13884                infer_timeout.as_secs()
13885            ));
13886        }
13887    };
13888
13889    // Conversation-outcome (Part B): score the session's PRIOR assistant turn by
13890    // this new user turn (the next-turn-is-the-label signal), then remember this
13891    // turn for the next round. trace_id/model are carried straight from the
13892    // result that produced each turn — never reconstructed from order — so credit
13893    // lands on the exact model that generated the judged turn. Gated on
13894    // `chat_was_idle` so overlapping infers don't fabricate adjacency (neo #1).
13895    // Best-effort: never fails the inference response.
13896    if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
13897        let mut slot = session.last_chat_turn.lock().await;
13898        let tracker_arc = engine.outcome_tracker();
13899        let mut tracker = tracker_arc.write().await;
13900        let tally = score_and_remember_chat_turn(
13901            &mut slot,
13902            &mut tracker,
13903            user_text,
13904            &result.text,
13905            &result.trace_id,
13906            &result.model_used,
13907        );
13908        if tally.submitted > 0 {
13909            tracing::debug!(
13910                submitted = tally.submitted,
13911                resolved = tally.resolved,
13912                "conversation-outcome recorded for prior chat turn"
13913            );
13914        }
13915    }
13916
13917    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13918}
13919
13920/// Streaming inference — mirrors NAPI `inferStream`. Closes
13921/// Parslee-ai/car-releases#30. Same `GenerateRequest` shape as
13922/// `infer`; emits `inference.stream.event` JSON-RPC notifications
13923/// during the run, and returns the final `InferenceResult` as the
13924/// JSON-RPC response when the stream completes.
13925///
13926/// Notification shape (server → client):
13927/// ```jsonc
13928/// {
13929///   "jsonrpc": "2.0",
13930///   "method": "inference.stream.event",
13931///   "params": {
13932///     "request_id": "<original RPC id>",
13933///     "event": { "type": "text" | "tool_start" | "tool_delta" | "usage", ... }
13934///   }
13935/// }
13936/// ```
13937///
13938/// The final `done` event is not pushed as a notification — it's
13939/// the JSON-RPC response with the accumulated `InferenceResult`.
13940/// `video.generate` — daemon-side wrapper for
13941/// `InferenceEngine::generate_video`. Mirrors `handle_infer`'s
13942/// admission gate + JSON request shape (Parslee-ai/car#185).
13943///
13944/// Previously the CLI's `cmd_video` constructed an in-process
13945/// engine and called `generate_video` directly — a v0.7 holdover
13946/// that bypassed the daemon. With this handler the CLI proxies
13947/// here, so the engine-level audio_passthrough gate fires
13948/// inside the daemon process where all FFI surfaces converge.
13949async fn handle_image_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13950    let engine = get_inference_engine(state);
13951    let req: car_inference::GenerateImageRequest = typed_params(&msg.params)?;
13952    // Share the same admission gate as text/video generation — a burst
13953    // of image requests shouldn't smuggle around the concurrency cap.
13954    let _permit = state.admission.acquire().await;
13955    let result = engine
13956        .generate_image(req)
13957        .await
13958        .map_err(|e| e.to_string())?;
13959    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13960}
13961
13962async fn handle_video_generate(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
13963    let engine = get_inference_engine(state);
13964    let req: car_inference::GenerateVideoRequest = typed_params(&msg.params)?;
13965    let _permit = state.admission.acquire().await;
13966    let result = engine
13967        .generate_video(req)
13968        .await
13969        .map_err(|e| e.to_string())?;
13970    serde_json::to_value(&result).map_err(|e| format!("serialize result: {}", e))
13971}
13972
13973async fn handle_infer_stream(
13974    msg: &JsonRpcMessage,
13975    session: &crate::session::ClientSession,
13976    state: &ServerState,
13977) -> Result<Value, String> {
13978    let engine = get_inference_engine(state);
13979    let mut req = parse_inference_request(&msg.params)?;
13980
13981    // Same context-injection convenience as non-streaming `infer` so
13982    // the two methods have parity on the call shape.
13983    if let Some(cq) = msg.params.get("context_query").and_then(|v| v.as_str()) {
13984        // `effective_memgine` (#82): injected context must come from the graph
13985        // the session's facts went into, not the ephemeral one.
13986        let memgine_arc = session.effective_memgine().await;
13987        let mut memgine = memgine_arc.lock().await;
13988        // Split at the stable Identity+Constraints boundary so an Anthropic
13989        // cached request breaks after the stable prefix (which hits) instead of
13990        // over the whole churning context. `full` is byte-identical to the old
13991        // `build_context`, so non-Anthropic providers and StateBench see the
13992        // same context; the prefix is just a hint the handler validates.
13993        let split = memgine.build_context_split_for_model(cq, None);
13994        if !split.full.is_empty() {
13995            req.context_stable_prefix = split.stable_prefix();
13996            req.context = Some(split.full);
13997        }
13998    }
13999    // Stamp the originating session so a delegated call reaches the runner THIS
14000    // host registered, not whichever registered last (car-releases#77). Never
14001    // serialized — `caller` is `#[serde(skip)]`, so it does not reach the host.
14002    req.caller = Some(session.client_id.clone());
14003    maybe_apply_proactive_memory(msg, session, &mut req).await?;
14004
14005    // Stakes-aware routing (parity with non-streaming `infer`): a FullAccess
14006    // session routes inference quality-first.
14007    if session.permission_gate.read().await.granted_tier() == car_policy::PermissionTier::FullAccess
14008    {
14009        req.intent.get_or_insert_with(Default::default).high_stakes = true;
14010    }
14011
14012    // Conversation-outcome (Part B, streaming parity): capture the chat user
14013    // turn + idle-guard verdict before `req` is consumed. Same gate + rules as
14014    // the non-streaming path (shared `begin_chat_turn`).
14015    let (chat_user_text, chat_was_idle, _chat_inflight) = begin_chat_turn(&req, session);
14016
14017    let _permit = state.admission.acquire().await;
14018    // Same classification as the non-streaming path: `generate_tracked_stream`
14019    // returns a typed `InferenceError` when the call is rejected BEFORE the
14020    // stream opens, which is where a gateway content refusal lands. (A refusal
14021    // that arrives mid-stream reaches us as `StreamEvent::Error(String)` —
14022    // already flattened to text by the provider layer — so it cannot be
14023    // classified by variant here.)
14024    let mut handle = engine
14025        .generate_tracked_stream(req)
14026        .await
14027        .map_err(|e| inference_dispatch_error(&e))?;
14028
14029    let mut accumulator = car_inference::StreamAccumulator::default();
14030    let mut stream_error: Option<String> = None;
14031    let mut saw_done = false;
14032    let request_id = msg.id.clone();
14033
14034    while let Some(event) = handle.events.recv().await {
14035        let event_payload = match &event {
14036            car_inference::StreamEvent::TextDelta(text) => {
14037                serde_json::json!({"type": "text", "data": text})
14038            }
14039            car_inference::StreamEvent::ToolCallStart { name, index, .. } => {
14040                serde_json::json!({"type": "tool_start", "name": name, "index": index})
14041            }
14042            car_inference::StreamEvent::ToolCallDelta {
14043                index,
14044                arguments_delta,
14045            } => serde_json::json!({
14046                "type": "tool_delta",
14047                "index": index,
14048                "data": arguments_delta,
14049            }),
14050            car_inference::StreamEvent::Usage {
14051                input_tokens,
14052                output_tokens,
14053                cache_read_input_tokens,
14054                cache_creation_input_tokens,
14055            } => serde_json::json!({
14056                "type": "usage",
14057                "input_tokens": input_tokens,
14058                "output_tokens": output_tokens,
14059                "cache_read_input_tokens": cache_read_input_tokens,
14060                "cache_creation_input_tokens": cache_creation_input_tokens,
14061            }),
14062            car_inference::StreamEvent::StopReason(reason) => {
14063                serde_json::json!({"type": "stop_reason", "data": reason})
14064            }
14065            car_inference::StreamEvent::ProviderOutputItem(item) => {
14066                serde_json::json!({"type": "provider_output_item", "item": item})
14067            }
14068            car_inference::StreamEvent::Error(message) => {
14069                stream_error = Some(message.clone());
14070                serde_json::json!({"type": "error", "message": message})
14071            }
14072            // Done is delivered as the JSON-RPC response, not a
14073            // notification — matches the NAPI contract where the
14074            // standalone function's return value is the accumulated
14075            // result and the callback only sees in-progress events.
14076            car_inference::StreamEvent::Done { .. } => {
14077                saw_done = true;
14078                accumulator.push(&event);
14079                continue;
14080            }
14081        };
14082
14083        let notif = serde_json::json!({
14084            "jsonrpc": "2.0",
14085            "method": "inference.stream.event",
14086            "params": {
14087                "request_id": request_id,
14088                "event": event_payload,
14089            },
14090        });
14091        if let Ok(text) = serde_json::to_string(&notif) {
14092            let _ = session
14093                .channel
14094                .write
14095                .lock()
14096                .await
14097                .send(Message::Text(text.into()))
14098                .await;
14099        }
14100        accumulator.push(&event);
14101    }
14102
14103    let (text, tool_calls, usage, stop_reason) = accumulator.finish_with_usage();
14104
14105    if let Some(error) = stream_error {
14106        // A refusal that lands mid-stream is the same ruling as one that lands
14107        // before the stream opens, and a benchmark scoring the tracked inference
14108        // path cannot see a difference it did not ask for. Classify it too, so
14109        // `infer_stream` reaches -32007 by both routes (Parslee-ai/car#796).
14110        return Err(stream_dispatch_error(error));
14111    }
14112    if !saw_done {
14113        return Err("stream ended without positive completion".to_string());
14114    }
14115
14116    // Record inference token telemetry into the session event log as deep
14117    // telemetry (§3.5.1) so trajectory-level token totals
14118    // (`EventLog::metrics_totals`) reflect model cost, not just tool
14119    // latency. Emitted **once** from the accumulator's reconciled usage —
14120    // providers (e.g. Anthropic) emit cumulative `Usage` multiple times
14121    // per stream, so metering inside the loop would double-count. The
14122    // dedicated `InferenceMetered` kind keeps this out of action stats.
14123    if let Some(u) = &usage {
14124        session.runtime.log.lock().await.append_metered(
14125            car_eventlog::EventKind::InferenceMetered,
14126            None,
14127            None,
14128            HashMap::new(),
14129            car_eventlog::Metrics::inference(u.prompt_tokens, u.completion_tokens, None),
14130        );
14131    }
14132
14133    // Conversation-outcome (Part B, streaming parity): score the session's PRIOR
14134    // chat turn by this new user turn, then stash this turn's trace_id/model for
14135    // the next round. They come from the `TrackedStream` handle (minted by the
14136    // same `record_start` the tap task resolves), so credit lands on the exact
14137    // model that produced the turn — the streaming analogue of `infer`'s
14138    // `InferenceResult`. What's load-bearing is that the PRIOR turn's trace still
14139    // survives in the tracker's `pending` set across the inter-turn gap; the only
14140    // thing that evicts it early is the 300s pending-sweep, identical to the
14141    // non-streaming path (a long-idle session loses the signal either way).
14142    // Best-effort: never fails the response.
14143    if let Some(user_text) = chat_user_text.filter(|_| chat_was_idle) {
14144        let mut slot = session.last_chat_turn.lock().await;
14145        let tracker_arc = engine.outcome_tracker();
14146        let mut tracker = tracker_arc.write().await;
14147        let tally = score_and_remember_chat_turn(
14148            &mut slot,
14149            &mut tracker,
14150            user_text,
14151            &text,
14152            &handle.trace_id,
14153            &handle.model_used,
14154        );
14155        if tally.submitted > 0 {
14156            tracing::debug!(
14157                submitted = tally.submitted,
14158                resolved = tally.resolved,
14159                "conversation-outcome recorded for prior chat turn (streaming)"
14160            );
14161        }
14162    }
14163
14164    Ok(serde_json::json!({
14165        "text": text,
14166        "tool_calls": tool_calls,
14167        "usage": usage,
14168        "stop_reason": stop_reason,
14169        // Parity with non-streaming `infer`: surface the trace + model so a
14170        // client can attribute the turn / resolve outcomes later.
14171        "trace_id": handle.trace_id,
14172        "model_used": handle.model_used,
14173    }))
14174}
14175
14176async fn handle_embed(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14177    let engine = get_inference_engine(state);
14178    let req: car_inference::EmbedRequest = typed_params(&msg.params)?;
14179    // Embeds load their own model weights; share the same admission
14180    // gate as generations so a burst of embed requests can't smuggle
14181    // around the concurrency cap.
14182    let _permit = state.admission.acquire().await;
14183    let result = engine.embed(req).await.map_err(|e| e.to_string())?;
14184    Ok(serde_json::json!({"embeddings": result}))
14185}
14186
14187async fn handle_classify(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14188    let engine = get_inference_engine(state);
14189    let req: car_inference::ClassifyRequest = typed_params(&msg.params)?;
14190    let _permit = state.admission.acquire().await;
14191    let result = engine.classify(req).await.map_err(|e| e.to_string())?;
14192    Ok(serde_json::json!({"classifications": result}))
14193}
14194
14195/// Surface the current admission state so the menubar tray and
14196/// `car daemon status` can show "queued: N" / "permits: P/T". Read-only
14197/// snapshot — racy by definition but correct enough for status panels.
14198fn handle_admission_status(state: &ServerState) -> Result<Value, String> {
14199    let total = state.admission.permits();
14200    let available = state.admission.permits_available();
14201    let in_use = total.saturating_sub(available);
14202    Ok(serde_json::json!({
14203        "permits_total": total,
14204        "permits_available": available,
14205        "permits_in_use": in_use,
14206        "env_override": crate::admission::ENV_MAX_CONCURRENT,
14207    }))
14208}
14209
14210async fn handle_tokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14211    let model = msg
14212        .params
14213        .get("model")
14214        .and_then(|v| v.as_str())
14215        .ok_or("missing 'model' parameter")?;
14216    let text = msg
14217        .params
14218        .get("text")
14219        .and_then(|v| v.as_str())
14220        .ok_or("missing 'text' parameter")?;
14221    let engine = get_inference_engine(state);
14222    let ids = engine
14223        .tokenize(model, text)
14224        .await
14225        .map_err(|e| e.to_string())?;
14226    Ok(serde_json::json!({"tokens": ids}))
14227}
14228
14229async fn handle_detokenize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14230    let model = msg
14231        .params
14232        .get("model")
14233        .and_then(|v| v.as_str())
14234        .ok_or("missing 'model' parameter")?;
14235    let tokens: Vec<u32> = msg
14236        .params
14237        .get("tokens")
14238        .and_then(|v| v.as_array())
14239        .ok_or("missing 'tokens' parameter")?
14240        .iter()
14241        .map(|t| {
14242            t.as_u64()
14243                .and_then(|n| u32::try_from(n).ok())
14244                .ok_or_else(|| "tokens[] must be u32 values".to_string())
14245        })
14246        .collect::<Result<Vec<_>, _>>()?;
14247    let engine = get_inference_engine(state);
14248    let text = engine
14249        .detokenize(model, &tokens)
14250        .await
14251        .map_err(|e| e.to_string())?;
14252    Ok(serde_json::json!({"text": text}))
14253}
14254
14255/// `models.register` — persist a user-supplied `ModelSchema` to `models.json`
14256/// under the CAR state root, i.e. `~/.car/models.json` unless `CAR_HOME` moves
14257/// it (Parslee-ai/car-releases#39). Replaces any existing entry with the same
14258/// `id`. Returns `{id, registered}`.
14259///
14260/// **Phase 1 limitation**: the daemon's live `UnifiedRegistry` is
14261/// not updated in-process — the new model becomes visible to
14262/// `models.list`, `infer`, `infer_stream` on the **next daemon
14263/// boot** when `load_user_config` re-reads the file. This is
14264/// enough to unblock opencode's setup flow (register ahead of
14265/// time, then start the daemon). Hot-update requires either an
14266/// `RwLock<InferenceEngine>` on `ServerState` or an
14267/// interior-mutable `UnifiedRegistry`; both touch 20+ call sites
14268/// and are tracked as a follow-up.
14269///
14270/// Until hot-update lands, callers SHOULD register their models
14271/// before issuing `infer` calls against them, and operators
14272/// SHOULD restart the daemon after batches of model
14273/// registrations.
14274fn parse_user_registered_model_schema(
14275    schema_value: Value,
14276) -> Result<car_inference::ModelSchema, String> {
14277    let mut schema: car_inference::ModelSchema =
14278        serde_json::from_value(schema_value).map_err(|e| format!("invalid ModelSchema: {e}"))?;
14279    if car_inference::openrouter::is_curated_managed_gateway_alias(&schema.id) {
14280        return Err(
14281            "Parslee-managed OpenRouter aliases are reserved and cannot be registered".into(),
14282        );
14283    }
14284    schema.mark_user_registered();
14285    Ok(schema)
14286}
14287
14288async fn handle_models_register(
14289    req: &JsonRpcMessage,
14290    _state: &Arc<ServerState>,
14291) -> Result<Value, String> {
14292    // The params shape mirrors v0.7's FFI `rt.registerModel(schemaJson)`:
14293    // either the bare `ModelSchema` value, OR `{ schema: ModelSchema }`.
14294    // Honor both so existing in-process callers don't have to reshape.
14295    let schema_value = match req.params.get("schema") {
14296        Some(v) => v.clone(),
14297        None => req.params.clone(),
14298    };
14299    let schema = parse_user_registered_model_schema(schema_value)?;
14300    let id = schema.id.clone();
14301
14302    // One resolver, shared with the read side: `models.json` under the CAR
14303    // state root (`~/.car/models.json` unless `CAR_HOME` moves it). The
14304    // registry's `user_config_path` is the same call, so what this writes is
14305    // what the next daemon boot loads. Read whatever's there, swap in the new
14306    // entry, write back atomically.
14307    let path = car_inference::registry::user_config_path()
14308        .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14309    if let Some(car_dir) = path.parent() {
14310        std::fs::create_dir_all(car_dir)
14311            .map_err(|e| format!("create {}: {e}", car_dir.display()))?;
14312    }
14313
14314    let mut models: Vec<car_inference::ModelSchema> = if path.exists() {
14315        let text =
14316            std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14317        if text.trim().is_empty() {
14318            Vec::new()
14319        } else {
14320            serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14321        }
14322    } else {
14323        Vec::new()
14324    };
14325    for model in &mut models {
14326        // Rewriting models.json is also a migration boundary for legacy rows
14327        // that omitted trust_tier or claimed Curated explicitly.
14328        model.mark_user_registered();
14329    }
14330    // Replace existing entry with the same id, else append.
14331    if let Some(slot) = models.iter_mut().find(|m| m.id == id) {
14332        *slot = schema;
14333    } else {
14334        models.push(schema);
14335    }
14336    let json =
14337        serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14338    let tmp = path.with_extension("json.tmp");
14339    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14340    std::fs::rename(&tmp, &path)
14341        .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14342    Ok(serde_json::json!({
14343        "id": id,
14344        "registered": true,
14345        "path": path.to_string_lossy(),
14346        "note": "Daemon restart required for live UnifiedRegistry visibility \
14347                 (Parslee-ai/car-releases#39 phase 1). The model is persisted; \
14348                 next car-server boot loads it via UnifiedRegistry::load_user_config.",
14349    }))
14350}
14351
14352#[cfg(test)]
14353mod model_registration_trust_tests {
14354    use super::parse_user_registered_model_schema;
14355
14356    #[test]
14357    fn daemon_models_register_rejects_reserved_managed_aliases() {
14358        let schema = car_inference::openrouter::curated_schemas()
14359            .into_iter()
14360            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14361            .unwrap();
14362        let error =
14363            parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap_err();
14364        assert_eq!(
14365            error,
14366            "Parslee-managed OpenRouter aliases are reserved and cannot be registered"
14367        );
14368    }
14369
14370    #[test]
14371    fn daemon_models_register_forces_non_reserved_schema_to_community() {
14372        let mut schema = car_inference::openrouter::curated_schemas()
14373            .into_iter()
14374            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14375            .unwrap();
14376        schema.id = "user/nearby-model".into();
14377
14378        let registered =
14379            parse_user_registered_model_schema(serde_json::to_value(schema).unwrap()).unwrap();
14380        assert_eq!(registered.trust_tier, car_inference::TrustTier::Community);
14381    }
14382}
14383
14384/// `models.unregister` — remove an entry by id from the same state-root
14385/// `models.json` `models.register` writes (`~/.car/models.json` unless
14386/// `CAR_HOME` moves it; Parslee-ai/car#186 — symmetric to `models.register`).
14387/// Returns `{ id, unregistered, path }` on success. Returns an error
14388/// when the model isn't present.
14389///
14390/// **Phase 1 limitation** (same as `models.register`): the daemon's
14391/// live `UnifiedRegistry` is not rebuilt — the removal takes effect
14392/// on the next daemon boot. Callers SHOULD restart the daemon after
14393/// a batch of unregistrations if they expect `models.list_unified`
14394/// to reflect the change immediately.
14395async fn handle_models_unregister(
14396    req: &JsonRpcMessage,
14397    _state: &Arc<ServerState>,
14398) -> Result<Value, String> {
14399    // Params shape mirrors the CLI flag: `{ id: string }`. Bare-string
14400    // params are honored for symmetry with the register handler's
14401    // tolerant shape (`{schema: ...}` OR bare schema).
14402    let id = match req.params.get("id") {
14403        Some(v) => v
14404            .as_str()
14405            .ok_or_else(|| "`id` must be a string".to_string())?
14406            .to_string(),
14407        None => match req.params.as_str() {
14408            Some(s) => s.to_string(),
14409            None => return Err("missing `id` parameter".to_string()),
14410        },
14411    };
14412
14413    // Same resolver `models.register` and the registry use, so an unregister
14414    // finds the file a register just wrote even when `CAR_HOME` moved the root.
14415    let path = car_inference::registry::user_config_path()
14416        .ok_or_else(|| "no CAR_HOME / HOME / USERPROFILE in env".to_string())?;
14417
14418    if !path.exists() {
14419        // Idempotent no-op: with no models.json the desired end-state (id not in
14420        // the user registry) already holds. The UI's Remove on a builtin-catalog
14421        // model that was never user-registered hit this as an alarming -32603
14422        // "no models.json — nothing to unregister" (PAR-7266). Report success.
14423        return Ok(serde_json::json!({
14424            "id": id,
14425            "unregistered": false,
14426            "note": "no models.json — nothing to unregister (already absent)",
14427        }));
14428    }
14429    let text =
14430        std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
14431    let mut models: Vec<car_inference::ModelSchema> = if text.trim().is_empty() {
14432        Vec::new()
14433    } else {
14434        serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))?
14435    };
14436    let before = models.len();
14437    models.retain(|m| m.id != id);
14438    if models.len() == before {
14439        // Not in the user registry (e.g. a builtin-catalog model the UI offered a
14440        // Remove button for) — idempotent no-op, not a UI-surfaced error (PAR-7266).
14441        return Ok(serde_json::json!({
14442            "id": id,
14443            "unregistered": false,
14444            "note": "model not in models.json — nothing to unregister",
14445        }));
14446    }
14447    let json =
14448        serde_json::to_string_pretty(&models).map_err(|e| format!("serialize models.json: {e}"))?;
14449    let tmp = path.with_extension("json.tmp");
14450    std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
14451    std::fs::rename(&tmp, &path)
14452        .map_err(|e| format!("rename {} -> {}: {e}", tmp.display(), path.display()))?;
14453    Ok(serde_json::json!({
14454        "id": id,
14455        "unregistered": true,
14456        "path": path.to_string_lossy(),
14457        "note": "Daemon restart required for live UnifiedRegistry visibility \
14458                 (phase 1, matching models.register).",
14459    }))
14460}
14461
14462fn handle_models_list(state: &ServerState) -> Result<Value, String> {
14463    let engine = get_inference_engine(state);
14464    let models = engine.list_models();
14465    serde_json::to_value(&models).map_err(|e| e.to_string())
14466}
14467
14468#[derive(Debug, Default, Deserialize)]
14469struct ModelsListUnifiedParams {
14470    #[serde(default)]
14471    refresh_openrouter_status: bool,
14472}
14473
14474async fn handle_models_list_unified(
14475    message: &JsonRpcMessage,
14476    state: &ServerState,
14477) -> Result<Value, String> {
14478    let params = if message.params.is_null() {
14479        ModelsListUnifiedParams::default()
14480    } else {
14481        typed_params(&message.params)?
14482    };
14483    if params.refresh_openrouter_status {
14484        let _ = car_inference::openrouter::refresh_credential_source_for_status().await;
14485    }
14486    let engine = get_inference_engine(state);
14487    let models = engine.list_models_unified();
14488    serde_json::to_value(&models).map_err(|e| e.to_string())
14489}
14490
14491#[cfg(test)]
14492mod models_list_unified_status_tests {
14493    use super::{handle_models_list_unified, JsonRpcMessage};
14494    use crate::session::{ServerState, ServerStateConfig};
14495    use serde_json::{json, Value};
14496    use std::sync::Arc;
14497
14498    #[tokio::test]
14499    async fn daemon_list_refreshes_openrouter_only_when_explicitly_requested() {
14500        const SENTINEL: &str = "CAR_SERVER_MODELS_LIST_STATUS_CHILD";
14501        if std::env::var_os(SENTINEL).is_none() {
14502            let status = std::process::Command::new(std::env::current_exe().unwrap())
14503                .arg("--exact")
14504                .arg(
14505                    "handler::models_list_unified_status_tests::daemon_list_refreshes_openrouter_only_when_explicitly_requested",
14506                )
14507                .arg("--nocapture")
14508                .arg("--test-threads=1")
14509                .env(SENTINEL, "1")
14510                .env_remove(car_home::ENV_VAR)
14511                .env_remove("CAR_SECRETS_FILE_DIR")
14512                .env_remove(car_inference::openrouter::API_KEY_ENV)
14513                .status()
14514                .expect("spawn isolated daemon models-list status test");
14515            assert!(status.success(), "isolated daemon models-list test failed");
14516            return;
14517        }
14518
14519        let fixture = tempfile::TempDir::new().unwrap();
14520        unsafe {
14521            std::env::set_var(car_home::ENV_VAR, fixture.path().join("car-home"));
14522            std::env::set_var(
14523                "CAR_SECRETS_FILE_DIR",
14524                fixture.path().join("isolated-secrets"),
14525            );
14526            std::env::remove_var(car_inference::openrouter::API_KEY_ENV);
14527        }
14528        car_secrets::SecretStore::new()
14529            .put(
14530                &car_secrets::SecretRef::with_default_service(
14531                    car_inference::openrouter::API_KEY_ENV,
14532                ),
14533                "fixture-value",
14534            )
14535            .unwrap();
14536        let engine = Arc::new(car_inference::InferenceEngine::new(
14537            car_inference::InferenceConfig {
14538                state_root: fixture.path().join("state"),
14539                models_dir: fixture.path().join("models"),
14540                ..car_inference::InferenceConfig::default()
14541            },
14542        ));
14543        let state = ServerState::with_config(
14544            ServerStateConfig::new(fixture.path().join("journal")).with_inference(engine),
14545        );
14546        let request = |params| JsonRpcMessage {
14547            jsonrpc: "2.0".into(),
14548            method: Some("models.list_unified".into()),
14549            params,
14550            id: json!(1),
14551            result: None,
14552            error: None,
14553        };
14554
14555        assert_eq!(car_inference::openrouter::credential_source(), None);
14556        let before = car_secrets::secret_store_activity();
14557        let passive = handle_models_list_unified(&request(Value::Null), &state)
14558            .await
14559            .unwrap();
14560        let after_passive = car_secrets::secret_store_activity();
14561        assert_eq!(after_passive.status_attempts, before.status_attempts);
14562        assert_eq!(after_passive.get_attempts, before.get_attempts);
14563        let passive_openrouter = passive
14564            .as_array()
14565            .unwrap()
14566            .iter()
14567            .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14568            .collect::<Vec<_>>();
14569        assert!(!passive_openrouter.is_empty());
14570        assert!(passive_openrouter
14571            .iter()
14572            .all(|model| model["available"] == false));
14573
14574        let refreshed = handle_models_list_unified(
14575            &request(json!({ "refresh_openrouter_status": true })),
14576            &state,
14577        )
14578        .await
14579        .unwrap();
14580        let after_refresh = car_secrets::secret_store_activity();
14581        assert!(after_refresh.status_attempts > after_passive.status_attempts);
14582        assert_eq!(after_refresh.get_attempts, after_passive.get_attempts);
14583        let refreshed_openrouter = refreshed
14584            .as_array()
14585            .unwrap()
14586            .iter()
14587            .filter(|model| model["id"].as_str().unwrap().starts_with("openrouter/"))
14588            .collect::<Vec<_>>();
14589        assert!(!refreshed_openrouter.is_empty());
14590        assert!(refreshed_openrouter
14591            .iter()
14592            .all(|model| model["available"] == true));
14593    }
14594}
14595
14596fn handle_models_catalog_snapshot(
14597    session: &crate::session::ClientSession,
14598    state: &ServerState,
14599) -> Result<Value, String> {
14600    if !session_has_capability(session, car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY) {
14601        return Err(format!(
14602            "{} negotiate `{}` as a required or optional capability before calling `models.catalog_snapshot`",
14603            car_proto::PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX,
14604            car_proto::MODELS_CATALOG_IDENTITY_CAPABILITY,
14605        ));
14606    }
14607    let engine = get_inference_engine(state);
14608    let snapshot = engine.catalog_snapshot()?;
14609    serde_json::to_value(snapshot).map_err(|error| error.to_string())
14610}
14611
14612#[derive(Debug, Default, Deserialize)]
14613#[serde(deny_unknown_fields)]
14614struct EmptyModelManagementParams {}
14615
14616#[derive(Debug, Deserialize)]
14617#[serde(deny_unknown_fields)]
14618struct ModelPreflightParams {
14619    model_id: String,
14620    #[serde(default)]
14621    context_tokens: usize,
14622}
14623
14624#[derive(Debug, Deserialize)]
14625#[serde(deny_unknown_fields)]
14626struct ModelIdParams {
14627    model_id: String,
14628}
14629
14630#[derive(Debug, Deserialize)]
14631#[serde(deny_unknown_fields)]
14632struct ModelPullParams {
14633    #[serde(alias = "id", alias = "model")]
14634    name: String,
14635}
14636
14637fn strict_model_params<T: serde::de::DeserializeOwned>(
14638    message: &JsonRpcMessage,
14639) -> Result<T, String> {
14640    let params = if message.params.is_null() {
14641        serde_json::json!({})
14642    } else {
14643        message.params.clone()
14644    };
14645    serde_json::from_value(params)
14646        .map_err(|error| format!("invalid model-management params: {error}"))
14647}
14648
14649fn handle_models_resource_policy_get(
14650    message: &JsonRpcMessage,
14651    state: &ServerState,
14652) -> Result<Value, String> {
14653    let _: EmptyModelManagementParams = strict_model_params(message)?;
14654    let engine = get_inference_engine(state);
14655    let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14656        engine.config.state_root.clone(),
14657    );
14658    let evidence = repository
14659        .load_with_evidence()
14660        .map_err(|error| error.to_string())?;
14661    let hardware = car_inference::HardwareInfo::detect();
14662    let evaluated_budget = evidence.policy.effective_budget(hardware.total_ram_mb);
14663    Ok(serde_json::json!({
14664        "policy": evidence.policy,
14665        "evaluated_budget": evaluated_budget,
14666        "hardware_total_mb": hardware.total_ram_mb,
14667        "source": evidence.source,
14668        "warning": evidence.warning,
14669    }))
14670}
14671
14672fn handle_models_resource_policy_set(
14673    message: &JsonRpcMessage,
14674    session: &crate::session::ClientSession,
14675    state: &ServerState,
14676) -> Result<Value, String> {
14677    require_approval_authority(session, state)?;
14678    let policy: car_inference::resource_policy::ResourcePolicy = strict_model_params(message)?;
14679    policy.validate().map_err(|error| error.to_string())?;
14680    let engine = get_inference_engine(state);
14681    let repository = car_inference::resource_policy::FileResourcePolicyRepository::new(
14682        engine.config.state_root.clone(),
14683    );
14684    car_inference::resource_policy::ResourcePolicyRepository::save(&repository, &policy)
14685        .map_err(|error| error.to_string())?;
14686    engine.apply_local_resource_policy(policy.clone());
14687    let hardware = car_inference::HardwareInfo::detect();
14688    let evaluated_budget = policy.effective_budget(hardware.total_ram_mb);
14689    Ok(serde_json::json!({
14690        "policy": policy,
14691        "evaluated_budget": evaluated_budget,
14692        "hardware_total_mb": hardware.total_ram_mb,
14693        "notice": evaluated_budget.normalization_notice,
14694    }))
14695}
14696
14697fn handle_models_preflight(message: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
14698    let params: ModelPreflightParams = strict_model_params(message)?;
14699    let preflight = get_inference_engine(state)
14700        .local_model_preflight(&params.model_id, params.context_tokens)
14701        .map_err(|error| error.to_string())?;
14702    serde_json::to_value(preflight).map_err(|error| error.to_string())
14703}
14704
14705fn handle_models_storage_roots(
14706    message: &JsonRpcMessage,
14707    session: &crate::session::ClientSession,
14708    state: &ServerState,
14709) -> Result<Value, String> {
14710    require_approval_authority(session, state)?;
14711    let _: EmptyModelManagementParams = strict_model_params(message)?;
14712    let engine = get_inference_engine(state);
14713    let store = engine.model_management_store();
14714    let hf_home = std::env::var_os("HF_HOME")
14715        .map(std::path::PathBuf::from)
14716        .or_else(|| {
14717            std::env::var_os("HOME")
14718                .or_else(|| std::env::var_os("USERPROFILE"))
14719                .map(|home| std::path::PathBuf::from(home).join(".cache/huggingface"))
14720        })
14721        .unwrap_or_else(|| std::path::PathBuf::from(".cache/huggingface"));
14722    let normalize = car_inference::resource_policy::normalized_state_root_key;
14723    Ok(serde_json::json!({
14724        "state_root": normalize(&engine.config.state_root),
14725        "models_dir": normalize(&engine.config.models_dir),
14726        "hf_home": normalize(&hf_home),
14727        "hf_hub": normalize(&hf_home.join("hub")),
14728        "install_receipts_dir": normalize(store.receipts_root()),
14729        "management_state_dir": normalize(store.management_state_dir()),
14730    }))
14731}
14732
14733async fn handle_models_remove(
14734    message: &JsonRpcMessage,
14735    session: &crate::session::ClientSession,
14736    state: &ServerState,
14737) -> Result<Value, String> {
14738    require_approval_authority(session, state)?;
14739    let params: ModelIdParams = strict_model_params(message)?;
14740    let engine = Arc::clone(get_inference_engine(state));
14741    let operation = state
14742        .spawn_durable_operation("models.remove", async move {
14743            let result = engine
14744                .remove_model_from_car(&params.model_id)
14745                .await
14746                .map_err(|error| error.to_string())?;
14747            Ok(serde_json::json!({
14748                "model_id": result.model_id,
14749                "removed_from_car": true,
14750                "artifact_kind": result.artifact_kind,
14751                "shared_cache_preserved": true,
14752            }))
14753        })
14754        .await;
14755    operation.await.map_err(|_| {
14756        "daemon-owned models.remove operation stopped before publishing a result".to_string()
14757    })?
14758}
14759
14760async fn handle_models_adopt(
14761    message: &JsonRpcMessage,
14762    session: &crate::session::ClientSession,
14763    state: &ServerState,
14764) -> Result<Value, String> {
14765    require_approval_authority(session, state)?;
14766    let params: ModelIdParams = strict_model_params(message)?;
14767    let engine = Arc::clone(get_inference_engine(state));
14768    let operation = state
14769        .spawn_durable_operation("models.adopt", async move {
14770            let receipt = engine
14771                .adopt_model_into_car(&params.model_id)
14772                .await
14773                .map_err(|error| error.to_string())?;
14774            Ok(serde_json::json!({
14775                "model_id": receipt.model_id,
14776                "adopted": true,
14777                "can_remove": true,
14778            }))
14779        })
14780        .await;
14781    operation.await.map_err(|_| {
14782        "daemon-owned models.adopt operation stopped before publishing a result".to_string()
14783    })?
14784}
14785
14786/// Return the runtime-resolved, non-secret gateway base for one canonical
14787/// Parslee-managed OpenRouter alias. This is intentionally a separate,
14788/// authenticated read: `models.list_unified` remains the public catalog view
14789/// and never publishes routing authorities for every model. The registry must
14790/// already be initialized explicitly; provenance never triggers engine setup.
14791async fn handle_models_route_provenance(
14792    req: &JsonRpcMessage,
14793    state: &ServerState,
14794) -> Result<Value, String> {
14795    let id = require_str(&req.params, "id")?;
14796    let engine = state.inference.get().ok_or_else(|| {
14797        "models.route_provenance requires an initialized model registry; \
14798         call `models.list_unified` to initialize it, then retry"
14799            .to_string()
14800    })?;
14801    let schema = engine
14802        .registered_schema(id)
14803        .ok_or_else(|| format!("unknown model id `{id}`"))?;
14804    let canonical_id = canonical_managed_route_provenance_id(&schema)?;
14805
14806    let (endpoint, authority) = resolved_managed_parslee_base().await?;
14807    serde_json::to_value(ManagedRouteProvenance {
14808        id: canonical_id,
14809        provider: "parslee",
14810        source: "proprietary_oauth_pkce",
14811        is_local: false,
14812        endpoint,
14813        authority,
14814    })
14815    .map_err(|error| error.to_string())
14816}
14817
14818/// Keep provenance tied to the actual registered row (including a valid signed
14819/// catalog replacement), using the same managed transport predicate inference
14820/// uses for outbound requests.
14821fn canonical_managed_route_provenance_id(
14822    schema: &car_inference::ModelSchema,
14823) -> Result<&str, String> {
14824    let Some(id) = car_inference::openrouter::canonical_managed_gateway_selector(schema) else {
14825        return Err(
14826            "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14827                .into(),
14828        );
14829    };
14830    Ok(id)
14831}
14832
14833#[derive(Debug, Serialize)]
14834struct ManagedRouteProvenance<'a> {
14835    id: &'a str,
14836    provider: &'static str,
14837    source: &'static str,
14838    is_local: bool,
14839    endpoint: String,
14840    authority: String,
14841}
14842
14843/// Resolve through the exact runtime source used by Parslee inference, then
14844/// emit only its non-secret HTTP(S) origin. With no process override,
14845/// `car_auth::api_base` performs one authoritative V2 secret-store read, which
14846/// may invoke a platform credential helper. Keep that synchronous local I/O off
14847/// Tokio's worker threads; the lookup neither refreshes, writes, nor contacts
14848/// the resolved base.
14849async fn resolved_managed_parslee_base() -> Result<(String, String), String> {
14850    let api_base = tokio::task::spawn_blocking(|| car_auth::api_base(None))
14851        .await
14852        .map_err(|error| format!("managed inference API base worker failed: {error}"))?;
14853    redact_resolved_managed_api_base(&api_base)
14854}
14855
14856fn redact_resolved_managed_api_base(api_base: &str) -> Result<(String, String), String> {
14857    let url = reqwest::Url::parse(api_base)
14858        .map_err(|_| "invalid managed inference API base".to_string())?;
14859    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
14860        return Err("invalid managed inference API base".into());
14861    }
14862
14863    let authority = url.origin().ascii_serialization();
14864    Ok((authority.clone(), authority))
14865}
14866
14867#[cfg(test)]
14868mod managed_route_provenance_tests {
14869    use super::{canonical_managed_route_provenance_id, redact_resolved_managed_api_base};
14870    use car_inference::openrouter::{
14871        canonical_managed_gateway_selector, curated_schemas, is_curated_managed_gateway_alias,
14872    };
14873
14874    #[test]
14875    fn managed_route_provenance_redacts_the_runtime_resolved_base_without_refresh() {
14876        assert_eq!(
14877            redact_resolved_managed_api_base("https://persisted.example/").unwrap(),
14878            (
14879                "https://persisted.example".into(),
14880                "https://persisted.example".into()
14881            )
14882        );
14883    }
14884
14885    #[test]
14886    fn managed_route_provenance_normalizes_and_redacts_url_components() {
14887        assert_eq!(
14888            redact_resolved_managed_api_base("HTTPS://Staging-Api.Example:443/").unwrap(),
14889            (
14890                "https://staging-api.example".into(),
14891                "https://staging-api.example".into()
14892            )
14893        );
14894        for (base, expected) in [
14895            ("https://gateway.example/prefix", "https://gateway.example"),
14896            (
14897                "https://user:sensitive@gateway.example/private?token=value#fragment",
14898                "https://gateway.example",
14899            ),
14900        ] {
14901            assert_eq!(
14902                redact_resolved_managed_api_base(base).unwrap(),
14903                (expected.into(), expected.into())
14904            );
14905        }
14906        for invalid in [
14907            "ftp://gateway.example",
14908            "mailto:ops@example.com",
14909            "https://",
14910        ] {
14911            let error = redact_resolved_managed_api_base(invalid).unwrap_err();
14912            assert_eq!(error, "invalid managed inference API base");
14913        }
14914    }
14915
14916    #[test]
14917    fn managed_route_provenance_accepts_only_canonical_managed_aliases() {
14918        assert!(is_curated_managed_gateway_alias(
14919            "parslee/openrouter/frontier-general"
14920        ));
14921        assert!(!is_curated_managed_gateway_alias(
14922            "openrouter/openai/gpt-5.4"
14923        ));
14924        assert!(!is_curated_managed_gateway_alias("parslee/not-managed"));
14925
14926        let schemas = curated_schemas();
14927        let managed = schemas
14928            .iter()
14929            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14930            .unwrap();
14931        assert_eq!(
14932            canonical_managed_gateway_selector(managed),
14933            Some("parslee/openrouter/frontier-general")
14934        );
14935
14936        let personal = schemas
14937            .iter()
14938            .find(|schema| schema.id == "openrouter/openai/gpt-5.4")
14939            .unwrap();
14940        assert!(canonical_managed_gateway_selector(personal).is_none());
14941
14942        let mut non_parslee = managed.clone();
14943        non_parslee.provider = "other".into();
14944        assert!(canonical_managed_gateway_selector(&non_parslee).is_none());
14945
14946        let mut noncanonical_id = managed.clone();
14947        noncanonical_id.id = "parslee/not-managed".into();
14948        assert!(canonical_managed_gateway_selector(&noncanonical_id).is_none());
14949    }
14950
14951    #[test]
14952    fn managed_route_provenance_uses_live_signed_transport_contract() {
14953        let mut signed_override = curated_schemas()
14954            .into_iter()
14955            .find(|schema| schema.id == "parslee/openrouter/frontier-general")
14956            .unwrap();
14957        signed_override.name = "signed-catalog-display-metadata-drift".into();
14958        assert_eq!(
14959            canonical_managed_route_provenance_id(&signed_override),
14960            Ok("parslee/openrouter/frontier-general")
14961        );
14962
14963        signed_override.provider = "not-parslee".into();
14964        assert_eq!(
14965            canonical_managed_route_provenance_id(&signed_override).unwrap_err(),
14966            "models.route_provenance supports only canonical Parslee-managed OpenRouter aliases"
14967        );
14968    }
14969}
14970
14971#[derive(Debug, Deserialize)]
14972#[serde(rename_all = "camelCase")]
14973struct ModelSearchParams {
14974    #[serde(default)]
14975    query: Option<String>,
14976    #[serde(default)]
14977    capability: Option<car_inference::ModelCapability>,
14978    #[serde(default)]
14979    provider: Option<String>,
14980    #[serde(default)]
14981    local_only: bool,
14982    #[serde(default)]
14983    available_only: bool,
14984    #[serde(default)]
14985    limit: Option<usize>,
14986}
14987
14988#[derive(Debug, Serialize)]
14989#[serde(rename_all = "camelCase")]
14990struct ModelSearchEntry {
14991    /// Carries `family` and `version` for EVERY row (the search view is the
14992    /// documented place that names an alias's upstream family), where the
14993    /// flattened `ModelInfo` publishes them for local rows only. The search
14994    /// handler overwrites both on the info before wrapping it.
14995    #[serde(flatten)]
14996    info: car_inference::ModelInfo,
14997    tags: Vec<String>,
14998    pullable: bool,
14999    upgrade: Option<car_inference::ModelUpgrade>,
15000}
15001
15002#[derive(Debug, Serialize)]
15003#[serde(rename_all = "camelCase")]
15004struct ModelSearchResponse {
15005    models: Vec<ModelSearchEntry>,
15006    upgrades: Vec<car_inference::ModelUpgrade>,
15007    total: usize,
15008    available: usize,
15009    local: usize,
15010    remote: usize,
15011}
15012
15013async fn handle_models_search(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15014    let params: ModelSearchParams =
15015        serde_json::from_value(req.params.clone()).unwrap_or(ModelSearchParams {
15016            query: None,
15017            capability: None,
15018            provider: None,
15019            local_only: false,
15020            available_only: false,
15021            limit: None,
15022        });
15023    let engine = get_inference_engine(state);
15024    let upgrades = engine.available_model_upgrades();
15025    let upgrades_by_from: HashMap<String, car_inference::ModelUpgrade> = upgrades
15026        .iter()
15027        .cloned()
15028        .map(|upgrade| (upgrade.from_id.clone(), upgrade))
15029        .collect();
15030    let query = params
15031        .query
15032        .as_deref()
15033        .map(str::trim)
15034        .filter(|q| !q.is_empty())
15035        .map(|q| q.to_ascii_lowercase());
15036    let provider = params
15037        .provider
15038        .as_deref()
15039        .map(str::trim)
15040        .filter(|p| !p.is_empty())
15041        .map(|p| p.to_ascii_lowercase());
15042
15043    let mut entries: Vec<ModelSearchEntry> = engine
15044        .list_schemas()
15045        .into_iter()
15046        .filter(|schema| {
15047            if let Some(capability) = params.capability {
15048                if !schema.has_capability(capability) {
15049                    return false;
15050                }
15051            }
15052            if let Some(provider) = provider.as_deref() {
15053                if schema.provider.to_ascii_lowercase() != provider {
15054                    return false;
15055                }
15056            }
15057            if params.local_only && !schema.is_local() {
15058                return false;
15059            }
15060            if params.available_only && !schema.available {
15061                return false;
15062            }
15063            if let Some(query) = query.as_deref() {
15064                let capability_text = schema
15065                    .capabilities
15066                    .iter()
15067                    .map(|cap| format!("{cap:?}").to_ascii_lowercase())
15068                    .collect::<Vec<_>>()
15069                    .join(" ");
15070                let haystack = format!(
15071                    "{} {} {} {} {} {}",
15072                    schema.id,
15073                    schema.name,
15074                    schema.provider,
15075                    schema.family,
15076                    schema.tags.join(" "),
15077                    capability_text
15078                )
15079                .to_ascii_lowercase();
15080                if !haystack.contains(query) {
15081                    return false;
15082                }
15083            }
15084            true
15085        })
15086        .map(|schema| {
15087            let pullable = model_is_pullable(&schema);
15088            let mut info =
15089                car_inference::ModelInfo::from(&schema).with_fit(engine.model_fit(&schema));
15090            info.family = Some(schema.family);
15091            info.version = Some(schema.version);
15092            let upgrade = upgrades_by_from.get(&schema.id).cloned();
15093            ModelSearchEntry {
15094                info,
15095                tags: schema.tags,
15096                pullable,
15097                upgrade,
15098            }
15099        })
15100        .collect();
15101    entries.sort_by(|a, b| {
15102        b.info
15103            .available
15104            .cmp(&a.info.available)
15105            .then(b.info.is_local.cmp(&a.info.is_local))
15106            .then(a.info.name.cmp(&b.info.name))
15107    });
15108    if let Some(limit) = params.limit {
15109        entries.truncate(limit);
15110    }
15111
15112    let total = entries.len();
15113    let available = entries.iter().filter(|entry| entry.info.available).count();
15114    let local = entries.iter().filter(|entry| entry.info.is_local).count();
15115    let response = ModelSearchResponse {
15116        models: entries,
15117        upgrades,
15118        total,
15119        available,
15120        local,
15121        remote: total.saturating_sub(local),
15122    };
15123    serde_json::to_value(response).map_err(|e| e.to_string())
15124}
15125
15126fn model_is_pullable(schema: &car_inference::ModelSchema) -> bool {
15127    schema.downloads_weights() && !schema.weights_ready
15128}
15129
15130/// Parse an optional serde enum param: absent/null → `None` (caller defaults);
15131/// present-but-unparseable → a clear invalid-params error rather than a silent
15132/// fallback, so a host app learns it sent a bad value.
15133fn optional_enum_param<T: serde::de::DeserializeOwned>(
15134    req: &JsonRpcMessage,
15135    key: &str,
15136) -> Result<Option<T>, String> {
15137    match req.params.get(key) {
15138        None | Some(Value::Null) => Ok(None),
15139        Some(v) => serde_json::from_value(v.clone())
15140            .map(Some)
15141            .map_err(|_| format!("invalid '{key}': {v}")),
15142    }
15143}
15144
15145/// Parse `use_case`/`tier`/`cloud_ok` from JSON-RPC params and run the
15146/// recommender. `HardwareInfo::detect()` runs per call — acceptable because
15147/// recommend/setup_plan are onboarding-frequency (not the inference hot path),
15148/// and detection is a couple of cheap, non-blocking system probes.
15149fn recommend_from_params(
15150    req: &JsonRpcMessage,
15151    engine: &car_inference::InferenceEngine,
15152) -> Result<car_inference::RecommendationSet, String> {
15153    let hw = car_inference::HardwareInfo::detect();
15154    recommend_from_params_with_hardware(req, engine, &hw)
15155}
15156
15157fn recommend_from_params_with_hardware(
15158    req: &JsonRpcMessage,
15159    engine: &car_inference::InferenceEngine,
15160    hw: &car_inference::HardwareInfo,
15161) -> Result<car_inference::RecommendationSet, String> {
15162    let schemas = engine.list_schemas();
15163    let policy = engine.active_local_resource_policy();
15164    recommend_from_params_with_policy(req, &schemas, hw, &policy.policy, policy.warning)
15165}
15166
15167/// Dependency-injected core for recommendation-policy fallback tests. Keeping
15168/// hardware, registry rows, and policy storage explicit makes corrupt/missing
15169/// file behavior testable without touching a developer's machine state.
15170#[cfg(test)]
15171fn recommend_from_params_with_inputs(
15172    req: &JsonRpcMessage,
15173    schemas: &[car_inference::ModelSchema],
15174    hw: &car_inference::HardwareInfo,
15175    policy_repository: &car_inference::FileResourcePolicyRepository,
15176) -> Result<car_inference::RecommendationSet, String> {
15177    let (policy, _, warning) = load_recommendation_policy(policy_repository);
15178    recommend_from_params_with_policy(req, schemas, hw, &policy, warning)
15179}
15180
15181#[cfg(test)]
15182fn load_recommendation_policy(
15183    policy_repository: &car_inference::FileResourcePolicyRepository,
15184) -> (
15185    car_inference::ResourcePolicy,
15186    car_inference::ResourcePolicyLoadSource,
15187    Option<String>,
15188) {
15189    match policy_repository.load_with_evidence() {
15190        Ok(evidence) => (evidence.policy, evidence.source, evidence.warning),
15191        Err(error) => (
15192            car_inference::ResourcePolicy::everyday(),
15193            car_inference::ResourcePolicyLoadSource::CorruptDefault,
15194            Some(format!(
15195                "The local-model resource policy could not be read ({error}); CAR used Everyday."
15196            )),
15197        ),
15198    }
15199}
15200
15201fn recommend_from_params_with_policy(
15202    req: &JsonRpcMessage,
15203    schemas: &[car_inference::ModelSchema],
15204    hw: &car_inference::HardwareInfo,
15205    policy: &car_inference::ResourcePolicy,
15206    warning: Option<String>,
15207) -> Result<car_inference::RecommendationSet, String> {
15208    let use_case =
15209        optional_enum_param::<car_inference::UseCase>(req, "use_case")?.unwrap_or_default();
15210    let tier = optional_enum_param::<car_inference::QualityTier>(req, "tier")?.unwrap_or_default();
15211    let privacy = if req
15212        .params
15213        .get("cloud_ok")
15214        .and_then(|v| v.as_bool())
15215        .unwrap_or(false)
15216    {
15217        car_inference::Privacy::CloudOk
15218    } else {
15219        car_inference::Privacy::OnDevice
15220    };
15221    let refs: Vec<&car_inference::ModelSchema> = schemas.iter().collect();
15222    let mut set = car_inference::recommend_with_policy(&refs, hw, policy, use_case, tier, privacy);
15223    if let Some(warning) = warning {
15224        set.note = Some(match set.note.take() {
15225            Some(note) => format!("Resource policy warning: {warning} {note}"),
15226            None => format!("Resource policy warning: {warning}"),
15227        });
15228    }
15229    Ok(set)
15230}
15231
15232/// `models.recommend` — ranked, explained model picks for this machine + intent.
15233fn handle_models_recommend(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15234    let engine = get_inference_engine(state);
15235    let set = recommend_from_params(req, engine)?;
15236    serde_json::to_value(set).map_err(|e| e.to_string())
15237}
15238
15239/// `models.setup_plan` — a concrete onboarding plan the host/SDK can present:
15240/// machine description, the top pick, alternatives, and what needs more memory.
15241fn handle_models_setup_plan(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15242    let engine = get_inference_engine(state);
15243    let hw = car_inference::HardwareInfo::detect();
15244    let schemas = engine.list_schemas();
15245    let policy = engine.active_local_resource_policy();
15246    setup_plan_from_params_with_policy_inputs(
15247        req,
15248        &schemas,
15249        &hw,
15250        &policy.policy,
15251        policy.source,
15252        policy.warning,
15253    )
15254}
15255
15256#[cfg(test)]
15257fn setup_plan_from_params_with_inputs(
15258    req: &JsonRpcMessage,
15259    schemas: &[car_inference::ModelSchema],
15260    hw: &car_inference::HardwareInfo,
15261    policy_repository: &car_inference::FileResourcePolicyRepository,
15262) -> Result<Value, String> {
15263    let (policy, source, warning) = load_recommendation_policy(policy_repository);
15264    setup_plan_from_params_with_policy_inputs(req, schemas, hw, &policy, source, warning)
15265}
15266
15267fn setup_plan_from_params_with_policy_inputs(
15268    req: &JsonRpcMessage,
15269    schemas: &[car_inference::ModelSchema],
15270    hw: &car_inference::HardwareInfo,
15271    active_policy: &car_inference::ResourcePolicy,
15272    active_policy_source: car_inference::ResourcePolicyLoadSource,
15273    active_policy_warning: Option<String>,
15274) -> Result<Value, String> {
15275    let preview_policy = match req.params.get("resource_policy") {
15276        None => None,
15277        Some(value) => {
15278            let policy: car_inference::ResourcePolicy = serde_json::from_value(value.clone())
15279                .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15280            policy
15281                .validate()
15282                .map_err(|error| format!("invalid 'resource_policy': {error}"))?;
15283            Some(policy)
15284        }
15285    };
15286    let (policy, policy_source, warning) = match preview_policy {
15287        Some(policy) => (policy, serde_json::json!("preview"), None),
15288        None => (
15289            active_policy.clone(),
15290            serde_json::json!(active_policy_source),
15291            active_policy_warning,
15292        ),
15293    };
15294    let evaluated_budget = policy.effective_budget(hw.total_ram_mb);
15295    let mut set = recommend_from_params_with_policy(req, schemas, hw, &policy, warning)?;
15296    let (recommended, alternatives) = split_setup_recommendations(set.picks);
15297    if recommended.is_none() {
15298        let notice = "No model is within CAR's automatic recommendation target; heavier or unknown-memory alternatives require an explicit choice.";
15299        set.note = Some(match set.note.take() {
15300            Some(note) => format!("{note} {notice}"),
15301            None => notice.into(),
15302        });
15303    }
15304    serde_json::to_value(serde_json::json!({
15305        "machine": describe_machine_for_plan(hw),
15306        "recommended": recommended,
15307        "alternatives": alternatives,
15308        "needs_more_memory": set.not_enough_memory,
15309        "note": set.note,
15310        "resource_policy": policy,
15311        "evaluated_budget": evaluated_budget,
15312        "policy_source": policy_source,
15313    }))
15314    .map_err(|e| e.to_string())
15315}
15316
15317fn split_setup_recommendations(
15318    mut picks: Vec<car_inference::Recommendation>,
15319) -> (
15320    Option<car_inference::Recommendation>,
15321    Vec<car_inference::Recommendation>,
15322) {
15323    let recommended_index = picks
15324        .iter()
15325        .position(|pick| pick.within_recommendation_target);
15326    let recommended = recommended_index.map(|index| picks.remove(index));
15327    (recommended, picks)
15328}
15329
15330/// Plain-language one-liner about the host machine for setup plans.
15331fn describe_machine_for_plan(hw: &car_inference::HardwareInfo) -> String {
15332    use car_inference::hardware::SupportedAcceleration::*;
15333    match hw.supported_acceleration() {
15334        Apple { unified_memory_mb } => format!(
15335            "Apple Silicon, {} GB unified memory (Metal)",
15336            unified_memory_mb / 1024
15337        ),
15338        Cuda { device_memory_mb } => match device_memory_mb {
15339            Some(mb) => format!("NVIDIA GPU, {} GB VRAM (CUDA)", mb / 1024),
15340            None => "NVIDIA GPU (CUDA)".to_string(),
15341        },
15342        UnsupportedDiscreteGpu { name, .. } => format!(
15343            "{} GB RAM, CPU inference ({name} not yet supported)",
15344            hw.total_ram_mb / 1024
15345        ),
15346        Cpu => format!("{} GB RAM, CPU inference", hw.total_ram_mb / 1024),
15347    }
15348}
15349
15350#[cfg(test)]
15351mod model_resource_policy_recommendation_tests {
15352    use super::{
15353        model_is_pullable, recommend_from_params_with_inputs, setup_plan_from_params_with_inputs,
15354        split_setup_recommendations, JsonRpcMessage,
15355    };
15356    use car_inference::hardware::GpuBackend;
15357    use car_inference::{
15358        FileResourcePolicyRepository, HardwareInfo, ModelSchema, ResourcePolicy,
15359        ResourcePolicyRepository,
15360    };
15361    use serde_json::json;
15362
15363    fn request() -> JsonRpcMessage {
15364        JsonRpcMessage {
15365            jsonrpc: "2.0".into(),
15366            method: Some("models.recommend".into()),
15367            params: json!({"use_case": "assistant", "tier": "balanced"}),
15368            id: json!(1),
15369            result: None,
15370            error: None,
15371        }
15372    }
15373
15374    fn mac_32gb() -> HardwareInfo {
15375        HardwareInfo {
15376            os: "macos".into(),
15377            arch: "aarch64".into(),
15378            cpu_cores: 10,
15379            total_ram_mb: 32 * 1024,
15380            gpu_backend: GpuBackend::Metal,
15381            gpu_memory_mb: None,
15382            gpu_devices: vec![],
15383            recommended_model: String::new(),
15384            recommended_context: 8_192,
15385            max_model_mb: 0,
15386        }
15387    }
15388
15389    fn setup_schemas(home: &tempfile::TempDir) -> Vec<ModelSchema> {
15390        let models_dir = home.path().join("weights");
15391        let huggingface_hub_root = home.path().join("huggingface-hub");
15392        std::fs::create_dir_all(&huggingface_hub_root).unwrap();
15393        car_inference::registry::builtin_catalog_with_huggingface_hub_for_testing(
15394            &models_dir,
15395            &huggingface_hub_root,
15396        )
15397        .into_iter()
15398        .filter(|model| matches!(model.id.as_str(), "mlx/qwen3-4b:4bit" | "mlx/qwen3-8b:4bit"))
15399        .collect()
15400    }
15401
15402    fn setup_request(params: serde_json::Value) -> JsonRpcMessage {
15403        JsonRpcMessage {
15404            jsonrpc: "2.0".into(),
15405            method: Some("models.setup_plan".into()),
15406            params,
15407            id: json!(1),
15408            result: None,
15409            error: None,
15410        }
15411    }
15412
15413    #[test]
15414    fn setup_plan_preview_custom_zero_changes_eligibility_without_persisting() {
15415        let home = tempfile::tempdir().unwrap();
15416        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15417        repository.save(&ResourcePolicy::everyday()).unwrap();
15418        let policy_path = repository.path();
15419        let saved_bytes = std::fs::read(&policy_path).unwrap();
15420        let schemas = setup_schemas(&home);
15421
15422        // The preview core intentionally has no engine/mutation dependency.
15423        let preview = setup_plan_from_params_with_inputs(
15424            &setup_request(json!({
15425                "use_case": "assistant",
15426                "tier": "balanced",
15427                "resource_policy": {
15428                    "profile": "custom",
15429                    "custom_max_model_mb": 0
15430                }
15431            })),
15432            &schemas,
15433            &mac_32gb(),
15434            &repository,
15435        )
15436        .unwrap();
15437
15438        assert_eq!(preview["resource_policy"]["profile"], "custom");
15439        assert_eq!(preview["resource_policy"]["custom_max_model_mb"], 0);
15440        assert_eq!(
15441            preview["evaluated_budget"]["configured_model_ceiling_mb"],
15442            0
15443        );
15444        assert_eq!(
15445            preview["evaluated_budget"]["effective_new_load_ceiling_mb"],
15446            0
15447        );
15448        assert_eq!(preview["policy_source"], "preview");
15449        assert!(preview["recommended"].is_null());
15450        assert_eq!(preview["alternatives"].as_array().unwrap().len(), 0);
15451        assert_eq!(preview["needs_more_memory"].as_array().unwrap().len(), 2);
15452
15453        assert_eq!(std::fs::read(&policy_path).unwrap(), saved_bytes);
15454        let fresh_repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15455        assert_eq!(fresh_repository.load().unwrap(), ResourcePolicy::everyday());
15456
15457        let persisted = setup_plan_from_params_with_inputs(
15458            &setup_request(json!({"use_case":"assistant","tier":"balanced"})),
15459            &schemas,
15460            &mac_32gb(),
15461            &fresh_repository,
15462        )
15463        .unwrap();
15464        assert_eq!(persisted["policy_source"], "loaded");
15465        assert_eq!(persisted["resource_policy"]["profile"], "everyday");
15466        assert_eq!(persisted["recommended"]["model_id"], "mlx/qwen3-4b:4bit");
15467        assert!(persisted["alternatives"]
15468            .as_array()
15469            .unwrap()
15470            .iter()
15471            .any(|pick| pick["model_id"] == "mlx/qwen3-8b:4bit"));
15472    }
15473
15474    #[test]
15475    fn setup_plan_preview_preserves_exact_custom_ten_and_a_half_gb() {
15476        let home = tempfile::tempdir().unwrap();
15477        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15478        repository.save(&ResourcePolicy::everyday()).unwrap();
15479
15480        let plan = setup_plan_from_params_with_inputs(
15481            &setup_request(json!({
15482                "resource_policy": {
15483                    "profile": "custom",
15484                    "custom_max_model_mb": 10_752
15485                }
15486            })),
15487            &setup_schemas(&home),
15488            &mac_32gb(),
15489            &repository,
15490        )
15491        .unwrap();
15492
15493        assert_eq!(plan["resource_policy"]["custom_max_model_mb"], 10_752);
15494        assert_eq!(
15495            plan["evaluated_budget"]["configured_model_ceiling_mb"],
15496            10_752
15497        );
15498        assert_eq!(
15499            plan["evaluated_budget"]["effective_new_load_ceiling_mb"],
15500            10_752
15501        );
15502        assert_eq!(repository.load().unwrap(), ResourcePolicy::everyday());
15503    }
15504
15505    #[test]
15506    fn setup_plan_preview_rejects_invalid_policy_shapes() {
15507        let home = tempfile::tempdir().unwrap();
15508        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15509        let schemas = setup_schemas(&home);
15510        let invalid = [
15511            json!({"profile":"custom","custom_max_model_mb":10_752,"unknown":true}),
15512            json!({"profile":"custom"}),
15513            json!({"profile":"everyday","custom_max_model_mb":512}),
15514            json!({"profile":"custom","custom_max_model_mb":10_547}),
15515            json!({"profile":"custom","custom_max_model_mb":-512}),
15516            json!({"profile":"custom","custom_max_model_mb":10_752.0}),
15517            serde_json::Value::Null,
15518            json!("custom"),
15519            json!([]),
15520            json!(true),
15521        ];
15522
15523        for resource_policy in invalid {
15524            let error = setup_plan_from_params_with_inputs(
15525                &setup_request(json!({"resource_policy": resource_policy.clone()})),
15526                &schemas,
15527                &mac_32gb(),
15528                &repository,
15529            )
15530            .expect_err(&format!(
15531                "invalid preview must fail closed: {resource_policy}"
15532            ));
15533            assert!(
15534                error.contains("invalid 'resource_policy'"),
15535                "unexpected error for {resource_policy}: {error}"
15536            );
15537        }
15538    }
15539
15540    #[test]
15541    fn setup_plan_without_preview_keeps_corrupt_policy_warning_and_everyday_fallback() {
15542        let home = tempfile::tempdir().unwrap();
15543        std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15544        let repository = FileResourcePolicyRepository::new(home.path().to_path_buf());
15545
15546        let plan = setup_plan_from_params_with_inputs(
15547            &setup_request(json!({})),
15548            &setup_schemas(&home),
15549            &mac_32gb(),
15550            &repository,
15551        )
15552        .unwrap();
15553
15554        assert_eq!(plan["policy_source"], "corrupt_default");
15555        assert_eq!(
15556            plan["resource_policy"],
15557            json!({
15558                "profile": "everyday",
15559                "custom_max_model_mb": null
15560            })
15561        );
15562        assert_eq!(
15563            plan["evaluated_budget"]["configured_model_ceiling_mb"],
15564            13_107
15565        );
15566        assert!(plan["note"]
15567            .as_str()
15568            .is_some_and(|note| note.contains("saved resource policy could not be loaded")));
15569    }
15570
15571    #[test]
15572    fn isolated_car_home_everyday_policy_recommends_real_4b_and_keeps_8b_heavier() {
15573        let home = tempfile::tempdir().unwrap();
15574        let schemas = setup_schemas(&home);
15575        assert_eq!(schemas.len(), 2, "real built-in 4B/8B rows must exist");
15576
15577        let set = recommend_from_params_with_inputs(
15578            &request(),
15579            &schemas,
15580            &mac_32gb(),
15581            &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15582        )
15583        .unwrap();
15584
15585        assert_eq!(set.picks[0].model_id, "mlx/qwen3-4b:4bit");
15586        assert!(set.picks[0].within_recommendation_target);
15587        let eight = set
15588            .picks
15589            .iter()
15590            .find(|pick| pick.model_id == "mlx/qwen3-8b:4bit")
15591            .expect("8B remains visible as a heavier alternative");
15592        assert_eq!(eight.display_name, "Qwen3-8B-MLX");
15593        let mut over_target = eight.clone();
15594        over_target.within_recommendation_target = false;
15595        let (automatic, visible) = split_setup_recommendations(vec![over_target]);
15596        assert!(automatic.is_none());
15597        assert_eq!(visible.len(), 1);
15598
15599        let (recommended, alternatives) = split_setup_recommendations(set.picks);
15600        assert_eq!(recommended.unwrap().model_id, "mlx/qwen3-4b:4bit");
15601        assert!(alternatives
15602            .iter()
15603            .any(|pick| pick.model_id == "mlx/qwen3-8b:4bit"));
15604    }
15605
15606    #[test]
15607    fn corrupt_policy_is_visible_in_recommendation_notice() {
15608        let home = tempfile::tempdir().unwrap();
15609        std::fs::write(home.path().join("model-resource-policy.json"), b"not-json").unwrap();
15610        let schemas: Vec<ModelSchema> = setup_schemas(&home)
15611            .into_iter()
15612            .filter(|model| model.id == "mlx/qwen3-4b:4bit")
15613            .collect();
15614
15615        let set = recommend_from_params_with_inputs(
15616            &request(),
15617            &schemas,
15618            &mac_32gb(),
15619            &FileResourcePolicyRepository::new(home.path().to_path_buf()),
15620        )
15621        .unwrap();
15622        assert!(
15623            set.note
15624                .as_deref()
15625                .is_some_and(|note| note.contains("saved resource policy could not be loaded")),
15626            "corrupt policy recovery must remain visible: {:?}",
15627            set.note
15628        );
15629    }
15630
15631    #[test]
15632    fn routable_mlx_model_without_weights_remains_pullable() {
15633        let home = tempfile::tempdir().unwrap();
15634        let mut schema = setup_schemas(&home)
15635            .into_iter()
15636            .find(|model| model.id == "mlx/qwen3-4b:4bit")
15637            .expect("built-in 4B MLX row from an explicit empty test hub");
15638        // Exercise the routable-but-not-downloaded lifecycle on every test
15639        // platform instead of assuming the runner itself supports MLX or has
15640        // no shared Hugging Face snapshot outside this temporary state root.
15641        schema.available = true;
15642        schema.weights_ready = false;
15643
15644        assert!(schema.available, "the MLX runtime is routable on this host");
15645        assert!(
15646            !schema.weights_ready,
15647            "fresh test root has no model weights"
15648        );
15649        assert!(model_is_pullable(&schema));
15650    }
15651}
15652
15653fn handle_models_upgrades(state: &ServerState) -> Result<Value, String> {
15654    let engine = get_inference_engine(state);
15655    serde_json::to_value(serde_json::json!({
15656        "upgrades": engine.available_model_upgrades()
15657    }))
15658    .map_err(|e| e.to_string())
15659}
15660
15661/// `models.detect_upgrades` — curated + upstream-aware findings (channel-gated,
15662/// cached, offline-safe).
15663async fn handle_models_detect_upgrades(state: &ServerState) -> Result<Value, String> {
15664    let engine = get_inference_engine(state);
15665    let findings = engine.detect_upgrades().await;
15666    serde_json::to_value(serde_json::json!({ "upgrades": findings })).map_err(|e| e.to_string())
15667}
15668
15669/// `models.check_upgrade_nudge` — the current nudge decision (poll form). The
15670/// daemon also pushes `models.upgrade_available` proactively; this lets a
15671/// client ask on demand. `inference_active` defaults to false.
15672async fn handle_models_check_upgrade_nudge(
15673    req: &JsonRpcMessage,
15674    state: &ServerState,
15675) -> Result<Value, String> {
15676    let engine = get_inference_engine(state);
15677    let inference_active = req
15678        .params
15679        .get("inference_active")
15680        .and_then(|v| v.as_bool())
15681        .unwrap_or(false);
15682    let (decision, _state) = engine.check_upgrade_nudge(inference_active).await;
15683    serde_json::to_value(decision).map_err(|e| e.to_string())
15684}
15685
15686/// `models.dismiss_upgrade` — remember that the user waved away a nudge.
15687fn handle_models_dismiss_upgrade(
15688    req: &JsonRpcMessage,
15689    state: &ServerState,
15690) -> Result<Value, String> {
15691    let key = req
15692        .params
15693        .get("dismiss_key")
15694        .and_then(|v| v.as_str())
15695        .map(str::trim)
15696        .filter(|s| !s.is_empty())
15697        .ok_or("missing or empty 'dismiss_key' parameter")?;
15698    let engine = get_inference_engine(state);
15699    engine
15700        .dismiss_upgrade_nudge(key)
15701        .map_err(|e| e.to_string())?;
15702    Ok(serde_json::json!({ "dismissed": key }))
15703}
15704
15705/// `models.check_concierge` — the current concierge suggestions (poll form).
15706/// The daemon also pushes `models.suggestion_available` proactively; this lets
15707/// a client ask on demand. `inference_active` defaults to false.
15708async fn handle_models_check_concierge(
15709    req: &JsonRpcMessage,
15710    state: &ServerState,
15711) -> Result<Value, String> {
15712    let engine = get_inference_engine(state);
15713    let inference_active = req
15714        .params
15715        .get("inference_active")
15716        .and_then(|v| v.as_bool())
15717        .unwrap_or(false);
15718    let (suggestions, _state) = engine.check_concierge(inference_active).await;
15719    serde_json::to_value(serde_json::json!({ "suggestions": suggestions }))
15720        .map_err(|e| e.to_string())
15721}
15722
15723/// `models.dismiss_suggestion` — remember that the user waved away a concierge
15724/// suggestion, so it is never surfaced again.
15725fn handle_models_dismiss_suggestion(
15726    req: &JsonRpcMessage,
15727    state: &ServerState,
15728) -> Result<Value, String> {
15729    let key = req
15730        .params
15731        .get("dismiss_key")
15732        .and_then(|v| v.as_str())
15733        .map(str::trim)
15734        .filter(|s| !s.is_empty())
15735        .ok_or("missing or empty 'dismiss_key' parameter")?;
15736    let engine = get_inference_engine(state);
15737    engine
15738        .dismiss_concierge_suggestion(key)
15739        .map_err(|e| e.to_string())?;
15740    Ok(serde_json::json!({ "dismissed": key }))
15741}
15742
15743/// `concierge.status` — the ambient concierge view (Phase C1): per-lane
15744/// usage + friction, the current grounded decision (Observe or
15745/// Act+suggestion), and per-model health. Pull, not push.
15746async fn handle_concierge_status(
15747    req: &JsonRpcMessage,
15748    state: &ServerState,
15749) -> Result<Value, String> {
15750    let engine = get_inference_engine(state);
15751    // Don't let a status pull surface a proactive Act while the user is
15752    // mid-inference — mirror the proactive path's defer.
15753    let inference_active = req
15754        .params
15755        .get("inference_active")
15756        .and_then(|v| v.as_bool())
15757        .unwrap_or_else(|| state.admission.in_flight() > 0);
15758    let status = engine.concierge_status(inference_active).await;
15759    serde_json::to_value(status).map_err(|e| e.to_string())
15760}
15761
15762/// `concierge.dismiss` — record a *labeled* dismissal (Phase B4/C1):
15763/// `{ dismiss_key, reason }` where reason is one of not_now / wrong /
15764/// too_expensive / privacy / never_for_project. Permanent reasons
15765/// suppress the suggestion forever; not_now only cools it down.
15766fn handle_concierge_dismiss(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15767    let key = req
15768        .params
15769        .get("dismiss_key")
15770        .and_then(|v| v.as_str())
15771        .map(str::trim)
15772        .filter(|s| !s.is_empty())
15773        .ok_or("missing or empty 'dismiss_key' parameter")?;
15774    let reason: car_inference::DismissReason = req
15775        .params
15776        .get("reason")
15777        .cloned()
15778        .map(serde_json::from_value)
15779        .transpose()
15780        .map_err(|e| format!("invalid 'reason': {e}"))?
15781        .unwrap_or(car_inference::DismissReason::NotNow);
15782    let engine = get_inference_engine(state);
15783    engine.dismiss_concierge_labeled(key, reason)?;
15784    Ok(serde_json::json!({ "dismissed": key, "reason": reason }))
15785}
15786
15787/// `concierge.defaults` — all configured lane→model defaults (Phase D1).
15788fn handle_concierge_defaults(state: &ServerState) -> Result<Value, String> {
15789    let engine = get_inference_engine(state);
15790    serde_json::to_value(engine.lane_defaults()).map_err(|e| e.to_string())
15791}
15792
15793/// `concierge.set_default { use_case, model_id, project? }` — set the
15794/// default model for a lane (Phase D1). `use_case` is the snake_case
15795/// lane name (assistant/coding/…).
15796async fn handle_concierge_set_default(
15797    req: &JsonRpcMessage,
15798    state: &ServerState,
15799) -> Result<Value, String> {
15800    let use_case: car_inference::UseCase = req
15801        .params
15802        .get("use_case")
15803        .cloned()
15804        .map(serde_json::from_value)
15805        .transpose()
15806        .map_err(|e| format!("invalid 'use_case': {e}"))?
15807        .ok_or("missing 'use_case'")?;
15808    let model_id = req
15809        .params
15810        .get("model_id")
15811        .and_then(|v| v.as_str())
15812        .map(str::trim)
15813        .filter(|s| !s.is_empty())
15814        .ok_or("missing or empty 'model_id'")?;
15815    let project = req
15816        .params
15817        .get("project")
15818        .and_then(|v| v.as_str())
15819        .map(str::to_string);
15820    let engine = get_inference_engine(state);
15821    engine
15822        .user_set_lane_default(use_case, model_id, project)
15823        .await?;
15824    Ok(serde_json::json!({ "set": model_id, "use_case": use_case }))
15825}
15826
15827/// `concierge.clear_default { use_case, project? }` — remove a lane
15828/// default (Phase D1; also the rollback path in D3).
15829async fn handle_concierge_clear_default(
15830    req: &JsonRpcMessage,
15831    state: &ServerState,
15832) -> Result<Value, String> {
15833    let use_case: car_inference::UseCase = req
15834        .params
15835        .get("use_case")
15836        .cloned()
15837        .map(serde_json::from_value)
15838        .transpose()
15839        .map_err(|e| format!("invalid 'use_case': {e}"))?
15840        .ok_or("missing 'use_case'")?;
15841    let project = req
15842        .params
15843        .get("project")
15844        .and_then(|v| v.as_str())
15845        .map(str::to_string);
15846    let engine = get_inference_engine(state);
15847    let removed = engine.user_clear_lane_default(use_case, project).await?;
15848    Ok(serde_json::json!({ "cleared": removed }))
15849}
15850
15851/// `concierge.apply { use_case, model_id, project? }` — closed-loop
15852/// "set it up" (Phase D3): acquire the model + set it as the lane
15853/// default, capturing the prior default so it's reversible. User-
15854/// consented (this is a direct user action).
15855async fn handle_concierge_apply(
15856    req: &JsonRpcMessage,
15857    state: &ServerState,
15858) -> Result<Value, String> {
15859    let use_case: car_inference::UseCase = req
15860        .params
15861        .get("use_case")
15862        .cloned()
15863        .map(serde_json::from_value)
15864        .transpose()
15865        .map_err(|e| format!("invalid 'use_case': {e}"))?
15866        .ok_or("missing 'use_case'")?;
15867    let model_id = req
15868        .params
15869        .get("model_id")
15870        .and_then(|v| v.as_str())
15871        .map(str::trim)
15872        .filter(|s| !s.is_empty())
15873        .ok_or("missing or empty 'model_id'")?;
15874    let project = req
15875        .params
15876        .get("project")
15877        .and_then(|v| v.as_str())
15878        .map(str::to_string);
15879    let engine = get_inference_engine(state);
15880    let result = engine.apply_concierge(use_case, model_id, project).await?;
15881    serde_json::to_value(result).map_err(|e| e.to_string())
15882}
15883
15884/// `concierge.rollback { use_case, project? }` — revert a lane default to
15885/// its value before the last `apply` (Phase D3).
15886async fn handle_concierge_rollback(
15887    req: &JsonRpcMessage,
15888    state: &ServerState,
15889) -> Result<Value, String> {
15890    let use_case: car_inference::UseCase = req
15891        .params
15892        .get("use_case")
15893        .cloned()
15894        .map(serde_json::from_value)
15895        .transpose()
15896        .map_err(|e| format!("invalid 'use_case': {e}"))?
15897        .ok_or("missing 'use_case'")?;
15898    let project = req
15899        .params
15900        .get("project")
15901        .and_then(|v| v.as_str())
15902        .map(str::to_string);
15903    let engine = get_inference_engine(state);
15904    let restored = engine.rollback_lane(use_case, project).await?;
15905    Ok(serde_json::json!({ "restored": restored }))
15906}
15907
15908/// `concierge.actions { limit? }` — the audit log of consented concierge
15909/// actions (Phase D2), most recent last.
15910fn handle_concierge_actions(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15911    let limit = req
15912        .params
15913        .get("limit")
15914        .and_then(|v| v.as_u64())
15915        .unwrap_or(50) as usize;
15916    let engine = get_inference_engine(state);
15917    serde_json::to_value(serde_json::json!({ "actions": engine.concierge_actions(limit) }))
15918        .map_err(|e| e.to_string())
15919}
15920
15921/// `concierge.refresh_catalog` — fetch + verify the signed model catalog
15922/// from the configured source (Phase E1). New models apply at next
15923/// daemon start (the registry is immutable at runtime).
15924async fn handle_concierge_refresh_catalog(state: &ServerState) -> Result<Value, String> {
15925    let engine = get_inference_engine(state);
15926    let count = engine.refresh_catalog().await?;
15927    Ok(serde_json::json!({
15928        "refreshed": count,
15929        "note": "verified catalog cached; new models apply on next daemon restart"
15930    }))
15931}
15932
15933/// `concierge.ask { question }` — conversational concierge (Phase
15934/// F1/F2): a grounded, ledger-backed answer about the user's models. The
15935/// LLM explains only from the assembled evidence; it can't invent models
15936/// or assert fit.
15937async fn handle_concierge_ask(req: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
15938    let question = req
15939        .params
15940        .get("question")
15941        .and_then(|v| v.as_str())
15942        .map(str::trim)
15943        .filter(|s| !s.is_empty())
15944        .ok_or("missing or empty 'question'")?;
15945    let engine = get_inference_engine(state);
15946    let answer = engine.concierge_ask(question).await?;
15947    Ok(serde_json::json!({ "answer": answer }))
15948}
15949
15950/// `models.update_prefs_get` — current update preferences.
15951fn handle_models_update_prefs_get(state: &ServerState) -> Result<Value, String> {
15952    let engine = get_inference_engine(state);
15953    serde_json::to_value(engine.update_prefs()).map_err(|e| e.to_string())
15954}
15955
15956/// `models.update_prefs_set` — replace update preferences. Params are the
15957/// `UpdatePreferences` shape (all fields optional; missing → defaults).
15958fn handle_models_update_prefs_set(
15959    req: &JsonRpcMessage,
15960    state: &ServerState,
15961) -> Result<Value, String> {
15962    let prefs: car_inference::UpdatePreferences = serde_json::from_value(req.params.clone())
15963        .map_err(|e| format!("invalid preferences: {e}"))?;
15964    let engine = get_inference_engine(state);
15965    engine.set_update_prefs(&prefs).map_err(|e| e.to_string())?;
15966    serde_json::to_value(prefs).map_err(|e| e.to_string())
15967}
15968
15969/// Run one proactive upgrade check and push a `models.upgrade_available`
15970/// nudge to subscribers if warranted. Stamps `last_nudge_secs` after sending
15971/// so the per-day throttle holds across ticks. The daemon calls this on a
15972/// periodic timer; the nudge logic itself decides whether to actually surface
15973/// anything (policy/throttle/dismissals).
15974pub async fn run_upgrade_nudge_check(state: &Arc<ServerState>) {
15975    let engine = get_inference_engine(state);
15976    // Defer the nudge if any inference is in flight (held admission permits) —
15977    // the real "machine is busy" signal, not a guess.
15978    let inference_active = state.admission.in_flight() > 0;
15979    let (decision, mut nstate) = engine.check_upgrade_nudge(inference_active).await;
15980    if let Some(nudge) = decision.nudge {
15981        let delivered = broadcast_upgrade_nudge(state, &nudge).await;
15982        // Only burn the once-per-day throttle when the nudge actually reached
15983        // someone — otherwise a nudge fired with no UI connected would silence
15984        // the user for a day without them ever seeing it.
15985        if delivered > 0 {
15986            let now = std::time::SystemTime::now()
15987                .duration_since(std::time::UNIX_EPOCH)
15988                .map(|d| d.as_secs())
15989                .unwrap_or(0);
15990            nstate.last_nudge_secs = now;
15991            let _ = nstate.save_to(&car_inference::NudgeState::default_path());
15992        }
15993    }
15994}
15995
15996/// Push a `models.upgrade_available` notification to all subscribed UI clients,
15997/// returning how many it reached. Targets the same subscriber set the macOS
15998/// host already uses for pushed events (`a2ui_subscribers`) — the UI-push
15999/// channel — rather than standing up a parallel subscription. Mirrors
16000/// [`broadcast_a2ui_event`].
16001pub async fn broadcast_upgrade_nudge(
16002    state: &Arc<ServerState>,
16003    nudge: &car_inference::UpgradeNudge,
16004) -> usize {
16005    use futures::SinkExt;
16006    use tokio_tungstenite::tungstenite::Message;
16007    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
16008        .a2ui_subscribers
16009        .lock()
16010        .await
16011        .values()
16012        .cloned()
16013        .collect();
16014    if subscribers.is_empty() {
16015        return 0;
16016    }
16017    let Ok(json) = serde_json::to_string(&serde_json::json!({
16018        "jsonrpc": "2.0",
16019        "method": "models.upgrade_available",
16020        "params": nudge,
16021    })) else {
16022        return 0;
16023    };
16024    let mut delivered = 0;
16025    for channel in subscribers {
16026        if channel
16027            .write
16028            .lock()
16029            .await
16030            .send(Message::Text(json.clone().into()))
16031            .await
16032            .is_ok()
16033        {
16034            delivered += 1;
16035        }
16036    }
16037    delivered
16038}
16039
16040/// Run one proactive concierge check and push a `models.suggestion_available`
16041/// notification per unserved lane to subscribers. Stamps `last_concierge_secs`
16042/// after sending so the throttle holds across ticks. Mirrors
16043/// [`run_upgrade_nudge_check`] but on the concierge's independent cadence and
16044/// throttle field, so the two never starve each other.
16045pub async fn run_concierge_check(state: &Arc<ServerState>) {
16046    let engine = get_inference_engine(state);
16047    let inference_active = state.admission.in_flight() > 0;
16048    let (suggestions, mut nstate) = engine.check_concierge(inference_active).await;
16049    if suggestions.is_empty() {
16050        return;
16051    }
16052    let mut any_delivered = false;
16053    for suggestion in &suggestions {
16054        if broadcast_concierge_suggestion(state, suggestion).await > 0 {
16055            any_delivered = true;
16056        }
16057    }
16058    // Only burn the throttle when a suggestion actually reached someone —
16059    // otherwise a check that fired with no UI connected would silence the user
16060    // for a week without them ever seeing it.
16061    if any_delivered {
16062        let now = std::time::SystemTime::now()
16063            .duration_since(std::time::UNIX_EPOCH)
16064            .map(|d| d.as_secs())
16065            .unwrap_or(0);
16066        nstate.last_concierge_secs = now;
16067        let _ = nstate.save_to(&car_inference::NudgeState::default_path());
16068    }
16069}
16070
16071/// One pass of idle model-backend eviction. A long-running daemon under
16072/// the default 24 GB cache budget pins loaded model weights resident
16073/// forever — capacity eviction never fires below the cap — so RSS climbs
16074/// to a model's working set and never drops at idle (car-releases#67).
16075/// This sweeps backends untouched for `CAR_INFERENCE_MODEL_IDLE_SECS`
16076/// (default 300) out of the caches and releases isolated-worker residents at
16077/// the same age; their RAM is reclaimed once outstanding handles drop or the
16078/// worker's confirmed process exit completes.
16079///
16080/// Only sweeps if the engine has already been created — it never forces
16081/// lazy engine init just to sweep an empty cache. Meant to be driven on a
16082/// timer by the daemon.
16083pub async fn run_idle_backend_eviction(state: &Arc<ServerState>) {
16084    let Some(engine) = state.inference.get() else {
16085        return;
16086    };
16087    let (entries, bytes) = engine.evict_idle_backends();
16088    if entries > 0 {
16089        tracing::info!(
16090            entries,
16091            mb = bytes / (1024 * 1024),
16092            "evicted idle model backends; resident model memory released"
16093        );
16094    }
16095    // Supervised vllm-mlx server processes are managed alongside the in-process
16096    // backends: stop the ones idle past their TTL so a server-backed model
16097    // doesn't pin the GPU after use.
16098    let stopped = engine.evict_idle_vllm_servers().await;
16099    if stopped > 0 {
16100        tracing::info!(stopped, "stopped idle vllm-mlx servers");
16101    }
16102    // The isolated worker owns a separate engine and cache, so in-process
16103    // eviction cannot reclaim it. Use the same TTL and leave zero/off exactly
16104    // as before.
16105    if let Some(idle_for) = car_inference::backend_cache::idle_ttl_from_env() {
16106        if let Some(offload) = car_inference::current_local_offload() {
16107            let released = offload.release_idle_models(idle_for).await;
16108            if !released.is_empty() {
16109                tracing::info!(
16110                    model_ids = ?released,
16111                    "released idle on-device inference worker residents"
16112                );
16113            }
16114        }
16115    }
16116}
16117
16118/// Push a `models.suggestion_available` notification to subscribed UI clients,
16119/// returning how many it reached. Targets the same `a2ui_subscribers` UI-push
16120/// channel as [`broadcast_upgrade_nudge`].
16121pub async fn broadcast_concierge_suggestion(
16122    state: &Arc<ServerState>,
16123    suggestion: &car_inference::ConciergeSuggestion,
16124) -> usize {
16125    use futures::SinkExt;
16126    use tokio_tungstenite::tungstenite::Message;
16127    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
16128        .a2ui_subscribers
16129        .lock()
16130        .await
16131        .values()
16132        .cloned()
16133        .collect();
16134    if subscribers.is_empty() {
16135        return 0;
16136    }
16137    let Ok(json) = serde_json::to_string(&serde_json::json!({
16138        "jsonrpc": "2.0",
16139        "method": "models.suggestion_available",
16140        "params": suggestion,
16141    })) else {
16142        return 0;
16143    };
16144    let mut delivered = 0;
16145    for channel in subscribers {
16146        if channel
16147            .write
16148            .lock()
16149            .await
16150            .send(Message::Text(json.clone().into()))
16151            .await
16152            .is_ok()
16153        {
16154            delivered += 1;
16155        }
16156    }
16157    delivered
16158}
16159
16160/// Bridges the (sync) download progress sink to async WS broadcasts: each
16161/// `DownloadEvent` is pushed onto an unbounded channel that a concurrent task
16162/// drains and broadcasts as `models.pull_progress`.
16163struct PullProgressSink {
16164    tx: tokio::sync::mpsc::UnboundedSender<car_inference::DownloadEvent>,
16165}
16166
16167impl car_inference::DownloadProgress for PullProgressSink {
16168    fn on_event(&self, event: &car_inference::DownloadEvent) {
16169        // Unbounded send from a sync context; ignore if the receiver is gone.
16170        let _ = self.tx.send(event.clone());
16171    }
16172}
16173
16174async fn handle_models_pull(
16175    msg: &JsonRpcMessage,
16176    session: &crate::session::ClientSession,
16177    state: &Arc<ServerState>,
16178) -> Result<Value, String> {
16179    require_approval_authority(session, state)?;
16180    let params: ModelPullParams = strict_model_params(msg)?;
16181    let name = params.name;
16182    if name.is_empty() || name.trim() != name {
16183        return Err(
16184            "invalid model-management params: model id must be non-empty exact UTF-8 without surrounding whitespace"
16185                .into(),
16186        );
16187    }
16188    let engine = Arc::clone(get_inference_engine(state));
16189    let operation_state = Arc::clone(state);
16190    let operation = state
16191        .spawn_durable_operation("models.pull", async move {
16192            run_models_pull(name, engine, operation_state).await
16193        })
16194        .await;
16195    operation.await.map_err(|_| {
16196        "daemon-owned models.pull operation stopped before publishing a result".to_string()
16197    })?
16198}
16199
16200async fn run_models_pull(
16201    name: String,
16202    engine: Arc<car_inference::InferenceEngine>,
16203    state: Arc<ServerState>,
16204) -> Result<Value, String> {
16205    // Stream progress live: events flow sink → channel → broadcaster task,
16206    // which runs concurrently with the download. Unbounded is safe here because
16207    // progress is file-level (O(files): Started + per-file Started/Completed +
16208    // Completed — a few dozen events per pull), not byte-level, so the channel
16209    // can't grow without bound.
16210    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<car_inference::DownloadEvent>();
16211    let sink = car_inference::ProgressSink::new(Arc::new(PullProgressSink { tx }));
16212    let broadcaster_state = state.clone();
16213    let model_label = name.clone();
16214    let broadcaster = tokio::spawn(async move {
16215        while let Some(event) = rx.recv().await {
16216            broadcast_pull_progress(&broadcaster_state, &model_label, &event).await;
16217        }
16218    });
16219
16220    let result = engine.pull_model_with_progress(&name, &sink).await;
16221    // Drop the sink so its sender closes, ending the broadcaster cleanly.
16222    drop(sink);
16223    // A broadcaster panic must not poison the (successful) pull — log and move on.
16224    if let Err(e) = broadcaster.await {
16225        tracing::warn!(error = %e, "pull-progress broadcaster task failed");
16226    }
16227
16228    let path = result.map_err(|e| e.to_string())?;
16229    Ok(serde_json::json!({"path": path.display().to_string()}))
16230}
16231
16232/// Push a `models.pull_progress` notification to subscribed UI clients.
16233/// Mirrors [`broadcast_upgrade_nudge`]; best-effort, no-op with no subscribers.
16234async fn broadcast_pull_progress(
16235    state: &Arc<ServerState>,
16236    model: &str,
16237    event: &car_inference::DownloadEvent,
16238) {
16239    use futures::SinkExt;
16240    use tokio_tungstenite::tungstenite::Message;
16241    let subscribers: Vec<Arc<crate::session::WsChannel>> = state
16242        .a2ui_subscribers
16243        .lock()
16244        .await
16245        .values()
16246        .cloned()
16247        .collect();
16248    if subscribers.is_empty() {
16249        return;
16250    }
16251    let Ok(json) = serde_json::to_string(&serde_json::json!({
16252        "jsonrpc": "2.0",
16253        "method": "models.pull_progress",
16254        "params": { "model": model, "event": event },
16255    })) else {
16256        return;
16257    };
16258    for channel in subscribers {
16259        let _ = channel
16260            .write
16261            .lock()
16262            .await
16263            .send(Message::Text(json.clone().into()))
16264            .await;
16265    }
16266}
16267
16268async fn handle_skills_distill(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
16269    let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
16270        msg.params
16271            .get("events")
16272            .cloned()
16273            .unwrap_or(msg.params.clone()),
16274    )
16275    .map_err(|e| format!("invalid events: {}", e))?;
16276
16277    let inference = get_inference_engine(state).clone();
16278    let engine = car_memgine::MemgineEngine::new(None).with_inference(inference);
16279
16280    let skills = engine.distill_skills(&events).await;
16281    serde_json::to_value(&skills).map_err(|e| e.to_string())
16282}
16283
16284/// Run memory consolidation against this client's session memgine
16285/// (or the daemon-owned per-agent memgine when bound — #170).
16286/// Returns the JSON `ConsolidationReport`.
16287async fn handle_memory_consolidate(
16288    session: &crate::session::ClientSession,
16289) -> Result<Value, String> {
16290    let engine_arc = session.effective_memgine().await;
16291    let report = {
16292        let mut engine = engine_arc.lock().await;
16293        engine.consolidate().await
16294    };
16295    if let Some(id) = session.agent_id.lock().await.clone() {
16296        if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
16297            tracing::warn!(agent_id = %id, error = %e,
16298                "agent memgine persist after consolidate failed");
16299        }
16300    }
16301    serde_json::to_value(&report).map_err(|e| e.to_string())
16302}
16303
16304/// `memory.utility_get` — read the live engine's utility-aware
16305/// retrieval blend (U-Mem). Returns `{ utility_weight,
16306/// utility_exploration }`.
16307///
16308/// Engine source: `session.memgine` — deliberately the same engine
16309/// `handle_memory_build_context`/`_fast` retrieve from, so this getter
16310/// and the setter always report/mutate the engine that live context
16311/// assembly reads. NOTE: that's `session.memgine`, not
16312/// `effective_memgine()` — `build_context` is itself the outlier that
16313/// skipped the #170 bound-memgine routing; when that gap is closed,
16314/// these two utility handlers must move with it. For a bound agent the
16315/// persistent default still arrives via the `.car/`-seeded per-agent
16316/// engine (`get_or_load_agent_memgine`); this runtime override is a
16317/// daemon-global knob over the shared engine.
16318async fn handle_memory_utility_get(
16319    session: &crate::session::ClientSession,
16320) -> Result<Value, String> {
16321    let engine = session.memgine.lock().await;
16322    let (weight, exploration) = engine.utility_retrieval();
16323    Ok(serde_json::json!({
16324        "utility_weight": weight,
16325        "utility_exploration": exploration,
16326    }))
16327}
16328
16329/// `memory.utility_set` — runtime override of the utility-aware
16330/// retrieval blend on `session.memgine` (see `memory.utility_get` for
16331/// the engine-source rationale). The **persistent** baseline comes from
16332/// `.car/config.toml` at engine construction; this mutates the live
16333/// engine on top of that and does NOT persist across daemon restarts.
16334///
16335/// Read-modify-write: an omitted field keeps the engine's current value,
16336/// so `{ utility_weight: 0.5 }` tunes weight without resetting
16337/// exploration (matching the optional param typing + paired getter).
16338/// Takes effect on the next context build. Returns the applied
16339/// `{ utility_weight, utility_exploration }` (post-clamp).
16340///
16341/// NOTE: the shared engine is daemon-global (one `shared_memgine` across
16342/// all WS sessions + MCP), so this shifts retrieval ranking for every
16343/// client — last-writer-wins. For per-team persistent tuning, prefer the
16344/// `.car/config.toml` `utility_weight`/`utility_exploration` keys.
16345async fn handle_memory_utility_set(
16346    msg: &JsonRpcMessage,
16347    session: &crate::session::ClientSession,
16348) -> Result<Value, String> {
16349    let (weight, exploration) = {
16350        let mut engine = session.memgine.lock().await;
16351        // Read-modify-write: default each missing field to the engine's
16352        // current value rather than 0.0, so a partial set doesn't silently
16353        // zero the field the caller didn't pass.
16354        let (cur_weight, cur_exploration) = engine.utility_retrieval();
16355        let weight = msg
16356            .params
16357            .get("utility_weight")
16358            .and_then(|v| v.as_f64())
16359            .unwrap_or(cur_weight);
16360        let exploration = msg
16361            .params
16362            .get("utility_exploration")
16363            .and_then(|v| v.as_f64())
16364            .unwrap_or(cur_exploration);
16365        engine.set_utility_retrieval(weight, exploration);
16366        engine.utility_retrieval()
16367    };
16368    Ok(serde_json::json!({
16369        "utility_weight": weight,
16370        "utility_exploration": exploration,
16371    }))
16372}
16373
16374/// `cascade.run` — U-Mem Slice 5 live evolve loop. Runs the cost-aware
16375/// knowledge cascade (`car_memgine::cascade::run_cascade_async`) for real,
16376/// escalating cheapest-first on **observed** confidence: it walks the policy's
16377/// tiers within budget, runs each tier's real CAR mechanic, and stops once an
16378/// observed confidence meets the target.
16379///
16380/// Per the caller-injected-confidence design (CAR doesn't invent a confidence
16381/// its primitives don't produce): the daemon runs the tier **mechanics** and the
16382/// budget/target walk, while the **observed confidence** for `self_reflect` /
16383/// `tool_verify` is supplied by the caller in `observed` (they have no
16384/// authoritative confidence source). The mechanics per tier:
16385///   - `self_reflect` → `engine.reflect()` on the session memgine (a real side
16386///     effect: it ingests reflection insights into memory; skipped when
16387///     `dry_run` is set). Confidence: caller-supplied.
16388///   - `tool_verify`  → pass-through (CAR has no built-in verify tool — the
16389///     confidence/knowledge are **caller-attested** from whatever tool the caller
16390///     ran; the daemon does not verify).
16391///   - `human_expert` → the session's `ApprovalLedger` (the same HITL substrate
16392///     as `permission.*`) keyed by `cascade:<claim>` is the **authority**: a
16393///     prior **Approved** lets the tier contribute the caller's confidence, a
16394///     **Rejected** aborts the cascade, and an **undecided** claim forces the
16395///     tier's confidence to 0.0 (it can NEVER be accepted without a real
16396///     approval) and surfaces `pending_approval` so a host routes it through
16397///     `permission.approve` / `permission.reject` by that fingerprint. A
16398///     `human_expert` tier requires a non-empty `claim`.
16399///
16400/// Params: `{ current_confidence: f64, policy: CascadePolicy, observed:
16401/// { <tier>: { confidence: f64, knowledge?: string } }, claim?: string,
16402/// dry_run?: bool }`.
16403/// Returns `{ run: CascadeRun, pending_approval?: { fingerprint, claim } }`.
16404async fn handle_cascade_run(
16405    req: &JsonRpcMessage,
16406    session: &crate::session::ClientSession,
16407    state: &ServerState,
16408) -> Result<Value, String> {
16409    use car_memgine::cascade::{run_cascade_async, CascadePolicy, CascadeTier, TierResult};
16410
16411    #[derive(serde::Deserialize)]
16412    struct ObservedTier {
16413        confidence: f64,
16414        #[serde(default)]
16415        knowledge: Option<String>,
16416    }
16417
16418    let current_confidence = req
16419        .params
16420        .get("current_confidence")
16421        .and_then(|v| v.as_f64())
16422        .unwrap_or(0.0);
16423    let policy_val = req.params.get("policy").ok_or("missing 'policy'")?;
16424    let policy: CascadePolicy =
16425        serde_json::from_value(policy_val.clone()).map_err(|e| format!("invalid 'policy': {e}"))?;
16426    let observed: std::collections::HashMap<CascadeTier, ObservedTier> = req
16427        .params
16428        .get("observed")
16429        .map(|v| serde_json::from_value(v.clone()))
16430        .transpose()
16431        .map_err(|e| format!("invalid 'observed': {e}"))?
16432        .unwrap_or_default();
16433    let claim = req
16434        .params
16435        .get("claim")
16436        .and_then(|v| v.as_str())
16437        .unwrap_or("")
16438        .to_string();
16439    // When true, skip tier side effects (the `self_reflect` `reflect()` memory
16440    // mutation) — a side-effect-free preview that still walks the cascade on the
16441    // caller-supplied observed confidence. The `human_expert` ledger read is
16442    // read-only and always runs (the gate must hold even in a preview).
16443    let dry_run = req
16444        .params
16445        .get("dry_run")
16446        .and_then(|v| v.as_bool())
16447        .unwrap_or(false);
16448    let fingerprint = format!("cascade:{}", claim.trim());
16449
16450    // A `human_expert` tier needs a non-blank claim: the durable approval is
16451    // keyed on `cascade:<claim>`, so an empty claim would collapse every
16452    // claimless cascade onto the shared `cascade:` fingerprint — one stale
16453    // decision would then bleed across logically independent requests.
16454    let has_human_tier = policy
16455        .tiers
16456        .iter()
16457        .any(|t| t.tier == CascadeTier::HumanExpert);
16458    if has_human_tier && claim.trim().is_empty() {
16459        return Err(
16460            "a 'human_expert' tier requires a non-empty 'claim' (it keys the durable approval)"
16461                .into(),
16462        );
16463    }
16464
16465    // The engine that holds (and learns) this session's facts — `reflect()` must
16466    // ingest into the bound per-agent memgine when one is attached (#170), like
16467    // `consolidate`.
16468    let engine_arc = session.effective_memgine().await;
16469
16470    let observed_ref = &observed;
16471    let fp_ref = fingerprint.as_str();
16472    let run = move |tier: CascadeTier| {
16473        let engine_arc = engine_arc.clone();
16474        async move {
16475            let obs = observed_ref.get(&tier);
16476            let caller_confidence = obs.map(|o| o.confidence).unwrap_or(0.0);
16477            let caller_knowledge = obs.and_then(|o| o.knowledge.clone());
16478            // self_reflect / tool_verify have no authoritative confidence source,
16479            // so the caller's observed value drives escalation (the approved
16480            // caller-injected design). human_expert is different — the ledger is
16481            // the authority, so its confidence is GATED on a recorded decision,
16482            // never on the caller's number.
16483            let (knowledge, confidence) = match tier {
16484                CascadeTier::SelfReflect => {
16485                    let knowledge = if dry_run {
16486                        caller_knowledge
16487                            .unwrap_or_else(|| "self_reflect: dry_run (no reflect)".to_string())
16488                    } else {
16489                        // Real side effect: reflect over conversation, ingesting insights.
16490                        let report = {
16491                            let mut engine = engine_arc.lock().await;
16492                            engine.reflect().await
16493                        };
16494                        caller_knowledge.unwrap_or_else(|| {
16495                            format!(
16496                                "self_reflect: {} insight(s) ingested ({} corrections, {} preferences, {} friction)",
16497                                report.insights_ingested,
16498                                report.corrections_found,
16499                                report.preferences_found,
16500                                report.friction_points_found,
16501                            )
16502                        })
16503                    };
16504                    (knowledge, caller_confidence)
16505                }
16506                CascadeTier::ToolVerify => {
16507                    let knowledge = caller_knowledge
16508                        .unwrap_or_else(|| "tool_verify: caller-supplied".to_string());
16509                    (knowledge, caller_confidence)
16510                }
16511                CascadeTier::HumanExpert => {
16512                    // SHARED daemon ledger (C1): the approving host connection
16513                    // is not this connection.
16514                    let decision = {
16515                        let ledger = state.approval_ledger.read().await;
16516                        ledger.lookup(fp_ref).map(|r| r.decision)
16517                    };
16518                    match decision {
16519                        // A human rejected this exact claim — abort the cascade.
16520                        Some(car_policy::ApprovalDecision::Rejected) => {
16521                            return Err(format!("human_expert rejected: {fp_ref}"));
16522                        }
16523                        // Approved → the tier may contribute the caller's confidence
16524                        // (the human endorsed the claim; the caller scores how
16525                        // confident given that endorsement).
16526                        Some(car_policy::ApprovalDecision::Approved) => {
16527                            let knowledge = caller_knowledge
16528                                .unwrap_or_else(|| format!("human_expert: approved ({fp_ref})"));
16529                            (knowledge, caller_confidence)
16530                        }
16531                        // Undecided → 0.0, NOT the caller's number. The tier cannot
16532                        // satisfy the target without a real approval, so the run
16533                        // can never report `accepted_tier=human_expert` while the
16534                        // claim is still pending. Acceptance is gated on the ledger.
16535                        None => {
16536                            let knowledge = caller_knowledge.unwrap_or_else(|| {
16537                                format!("human_expert: awaiting approval ({fp_ref})")
16538                            });
16539                            (knowledge, 0.0)
16540                        }
16541                    }
16542                }
16543            };
16544            Ok::<TierResult, String>(TierResult {
16545                knowledge,
16546                confidence,
16547            })
16548        }
16549    };
16550
16551    let run_result = run_cascade_async(current_confidence, &policy, run).await?;
16552
16553    // Surface a pending human approval when the human_expert tier ran but no
16554    // durable decision exists yet — the host routes it through the existing
16555    // `permission.approve`/`permission.reject` flow by this fingerprint.
16556    let human_ran = run_result
16557        .steps
16558        .iter()
16559        .any(|s| s.tier == CascadeTier::HumanExpert);
16560    let pending = if human_ran {
16561        state
16562            .approval_ledger
16563            .read()
16564            .await
16565            .lookup(&fingerprint)
16566            .is_none()
16567    } else {
16568        false
16569    };
16570
16571    let mut resp = serde_json::json!({ "run": run_result });
16572    if pending {
16573        resp.as_object_mut().unwrap().insert(
16574            "pending_approval".into(),
16575            serde_json::json!({ "fingerprint": fingerprint, "claim": claim }),
16576        );
16577    }
16578    Ok(resp)
16579}
16580
16581/// Assemble the governor's full live component-state input for this session —
16582/// ALL FIVE components, each from its real signal source:
16583/// - **Memory / Skills / Context** from the session memgine
16584///   (`evolution_component_states` — Memory backlog/churn, Skills degradation
16585///   rate, Context conversation-token saturation);
16586/// - **Harness** from the session event log
16587///   (`crate::evolution::harness_component_from_events` — the share of logged
16588///   events implicated in a recurring interaction-failure pattern);
16589/// - **Tools** from live connector health
16590///   (`crate::evolution::tools_component_from_connectors` — disconnected /
16591///   total connectors).
16592///
16593/// A component with no observable signal source (empty store, empty log, no
16594/// connectors configured) is omitted rather than fabricated at zero.
16595async fn assemble_evolution_components(
16596    session: &crate::session::ClientSession,
16597    state: &Arc<ServerState>,
16598) -> Vec<car_memgine::self_evolution::ComponentState> {
16599    // Route through `effective_memgine` (S1) so a session bound to a
16600    // lifecycle agent (#169/#170) plans over the daemon-owned per-agent
16601    // engine, like every memory.* handler.
16602    let mut components = {
16603        let engine_arc = session.effective_memgine().await;
16604        let engine = engine_arc.lock().await;
16605        engine.evolution_component_states()
16606    };
16607    {
16608        let log = session.runtime.log.lock().await;
16609        if let Some(h) = crate::evolution::harness_component_from_events(log.events()) {
16610            components.push(h);
16611        }
16612    }
16613    state.ensure_connectors_loaded().await;
16614    let connectors = state.connectors().list().await;
16615    if let Some(t) = crate::evolution::tools_component_from_connectors(&connectors) {
16616        components.push(t);
16617    }
16618    components
16619}
16620
16621/// Return watch-only detector cadence, last-tick, detector, and active-count
16622/// state. The response reads the append-only ledger and performs no I/O beyond
16623/// that already-loaded daemon state.
16624async fn handle_selfheal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16625    serde_json::to_value(state.selfheal.status().await).map_err(|error| error.to_string())
16626}
16627
16628/// List active (not dismissed) detections with bounded pagination. Optional
16629/// filters: `kind`, `severity`, `since`; pagination: `offset`, `limit` (max
16630/// 500). This is a watch-only ledger query.
16631async fn handle_selfheal_detections(
16632    req: &JsonRpcMessage,
16633    state: &Arc<ServerState>,
16634) -> Result<Value, String> {
16635    let query = serde_json::from_value::<crate::selfheal::DetectionQuery>(req.params.clone())
16636        .map_err(|error| format!("invalid selfheal.detections params: {error}"))?;
16637    serde_json::to_value(state.selfheal.detections(query).await).map_err(|error| error.to_string())
16638}
16639
16640/// Append an operator dismissal marker for one stable SHA-256 dedup key. A
16641/// dismissal suppresses later detector ticks for that key; it never deletes or
16642/// rewrites prior ledger records.
16643async fn handle_selfheal_dismiss(
16644    req: &JsonRpcMessage,
16645    state: &Arc<ServerState>,
16646) -> Result<Value, String> {
16647    let key = req
16648        .params
16649        .get("dedup_key")
16650        .and_then(Value::as_str)
16651        .ok_or_else(|| "selfheal.dismiss requires string param 'dedup_key'".to_string())?;
16652    serde_json::to_value(state.selfheal.dismiss(key).await?).map_err(|error| error.to_string())
16653}
16654
16655/// Start one bounded, template-owned coder round for an eligible stable key.
16656async fn handle_selfheal_fix(
16657    req: &JsonRpcMessage,
16658    state: &Arc<ServerState>,
16659) -> Result<Value, String> {
16660    let key = req
16661        .params
16662        .get("dedup_key")
16663        .and_then(Value::as_str)
16664        .ok_or_else(|| "selfheal.fix requires string param 'dedup_key'".to_string())?;
16665    serde_json::to_value(state.selfheal.fix(key).await?).map_err(|error| error.to_string())
16666}
16667
16668/// What the repair loop is configured to do, and why it is not doing it.
16669///
16670/// Answerable whether or not the loop is enabled — a disabled loop and an idle
16671/// one are indistinguishable from outside, and on first setup the operator is
16672/// almost always looking at the first while believing it is the second.
16673async fn handle_heal_status(state: &Arc<ServerState>) -> Result<Value, String> {
16674    serde_json::to_value(state.heal.status(get_inference_engine(state)))
16675        .map_err(|error| error.to_string())
16676}
16677
16678/// Run one sweep now, instead of waiting for the cadence.
16679///
16680/// Goes through the same `HealService` the cadence uses, so it takes the same
16681/// try-lock and reads the same claim ledger. A manual run that built its own
16682/// loop could act on an item a running sweep already holds.
16683async fn handle_heal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16684    // SPAWNED, then awaited. Awaiting the sweep directly would tie it to this
16685    // future's lifetime, so any drop — a deadline, a client disconnect — would
16686    // take a live coder session with it, and a detached one at that. A spawned
16687    // task survives the drop: the sweep runs to completion, its claim is
16688    // already on disk, and its session reaches a terminal state and releases
16689    // its worktree. The caller may miss the answer; the daemon never loses
16690    // track of the work.
16691    let owned = state.clone();
16692    let handle = tokio::spawn(async move { owned.heal.run_tick(&owned).await });
16693    match handle.await {
16694        Ok(result) => serde_json::to_value(result?).map_err(|error| error.to_string()),
16695        Err(e) => Err(format!("heal sweep task failed: {e}")),
16696    }
16697}
16698
16699/// Run one detection tick now. The method uses the same non-overlap guard as
16700/// the cadence and may start one eligible default-on auto-fix round after the
16701/// deterministic detector pass.
16702
16703async fn handle_selfheal_run(state: &Arc<ServerState>) -> Result<Value, String> {
16704    serde_json::to_value(state.selfheal.run_tick(state).await?).map_err(|error| error.to_string())
16705}
16706
16707/// Plan an evolution cycle over the session's **live** signals — the
16708/// self-evolution governor's host surface (arXiv 2507.21046, Slice 3 + the
16709/// daemon populaters). Folds all five components live via
16710/// [`assemble_evolution_components`] and runs the governor. `params.policy` is
16711/// an optional `EvolutionPolicy` `{ pressure_threshold?, budget? }`. Returns
16712/// the `EvolutionPlan` JSON `{ decisions, spent, evolve_now }`. Read-only — it
16713/// plans; dispatching is `evolution.run` (or the caller's own loop).
16714async fn handle_evolution_plan(
16715    req: &JsonRpcMessage,
16716    session: &crate::session::ClientSession,
16717    state: &Arc<ServerState>,
16718) -> Result<Value, String> {
16719    let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16720        req.params
16721            .get("policy")
16722            .cloned()
16723            .unwrap_or_else(|| serde_json::json!({})),
16724    )
16725    .map_err(|e| format!("invalid policy: {e}"))?;
16726    let components = assemble_evolution_components(session, state).await;
16727    let plan = car_memgine::self_evolution::plan_evolution(&components, &policy);
16728    serde_json::to_value(plan).map_err(|e| e.to_string())
16729}
16730
16731/// Run one evolution cycle over the session's live signals — the real executor
16732/// behind the self-evolution governor (arXiv 2507.21046; the "remaining daemon
16733/// step" of `docs/proposals/self-evolution-governor.md`). Plans exactly like
16734/// `evolution.plan` (all five components live), then dispatches each
16735/// `EvolveNow` component in priority order:
16736///
16737/// - **Memory** → `engine.consolidate()`, sized by
16738///   `maintenance::decide_maintenance` off the live `memory_stats` (the
16739///   localized-vs-global choice is recorded; `consolidate()` is the single
16740///   live mechanism for both today).
16741/// - **Skills** → `engine.evolve_skills(failed_events, domain)` for every
16742///   domain `domains_needing_evolution` flags, with `failed_events` folded
16743///   from the session event log's failure records
16744///   (`crate::evolution::failed_trace_events` — per-action failures; the log
16745///   carries no state-before/after trajectories, so those fields are honest
16746///   `None`s). Errors `"no inference engine"` when the session engine has no
16747///   model — never a stub.
16748/// - **Harness** → the `harness_evolution` diagnose→gate→apply loop, HITL-
16749///   gated on the daemon's SHARED durable `ApprovalLedger`
16750///   (`~/.car/approvals.jsonl` — the same store `permission.*` writes, from
16751///   ANY connection; C1), fingerprint `harness:<component>:<patch-digest>`
16752///   (bound to the patch *content*, not diagnostic prose, so an approval
16753///   matches every re-diagnosis of the same change; C2):
16754///   - a mutation whose fingerprint a human already **approved** applies its
16755///     patch to the session runtime's live `HarnessConfig` under one atomic
16756///     read-modify-write (`Governance::HumanApproved`); **rejected** →
16757///     blocked;
16758///   - undecided mutations surface in `pending_approvals` (resolve via
16759///     `permission.approve`/`permission.reject` by fingerprint — on any
16760///     connection — then re-run);
16761///   - the regression gate (`EvolutionAgent::evaluate`) needs *candidate*
16762///     metrics measured on held-out telemetry after applying the mutation, so
16763///     auto-promotion only runs when such metrics exist. Two ways to get them:
16764///     the caller supplies `harness_candidate_metrics` (and optionally
16765///     `harness_baseline_metrics`; baseline defaults to the live session
16766///     metrics), or the caller opts into `harness_measure` and the daemon
16767///     measures them ITSELF via the installed
16768///     [`HarnessMeasurer`](crate::evolution::HarnessMeasurer) — one baseline
16769///     replay under the session's live `HarnessConfig`, then one replay per
16770///     measurable mutation under that config plus the mutation's patch. A
16771///     gate-passed non-safety mutation then auto-applies
16772///     (`Governance::Promoted`); a safety-affecting one still goes to HITL.
16773///   - `harness_measure` is mutually exclusive with the two supplied-metrics
16774///     params (an ERROR naming both, never a silent winner); under `dry_run`
16775///     it measures NOTHING (a paid replay is a side effect) and reports
16776///     `measurement.status = "skipped_dry_run"`; with no measurer installed on
16777///     this build it ERRS rather than degrading to HITL, because an opt-in
16778///     that silently does nothing would report an unattended cycle that never
16779///     measured anything. Mutations with no patch and safety-affecting
16780///     mutations are never measured — the first has nothing to apply, the
16781///     second can never auto-promote, so a replay would spend real model calls
16782///     to reach an outcome already decided.
16783///   - a supplied (or daemon-measured) `harness_baseline_metrics` also
16784///     REPLACES the Harness planning signal (`harness_component_from_metrics`
16785///     — failed/total attempts) so the declared telemetry both elects and
16786///     diagnoses the component coherently.
16787///   - the harness step summary carries a `measurement` object (status, split,
16788///     model, seed and the full baseline document) and every measured
16789///     mutation's detail carries its full `candidate_metrics`: a promotion
16790///     nobody can re-derive is not an audited promotion.
16791/// - **Context** → [`run_context_evolution`](crate::evolution::run_context_evolution):
16792///   diagnose from the engine's live conversation-layer saturation, then
16793///   resolve each mutation most-binding-first over two authorization paths.
16794///   - A durable ledger decision wins outright (`rejected_by_operator`, or the
16795///     human-approved apply→compact→re-read→REVERT-unless-tokens-fell path,
16796///     whose reverted mutations report `rolled_back` and count as nothing
16797///     applied).
16798///   - Otherwise, if the caller opted into **`context_measure`**, the
16799///     **pre-activation grader** runs: two bench replays over the same
16800///     deterministic split, one under the engine's live `MemgineConfig` and one
16801///     under that config plus the mutation's patch, graded on TASK outcomes by
16802///     `EvolutionAgent::evaluate_context` — the same gate, with the same
16803///     guards, that grades a harness mutation. A promoted mutation applies with
16804///     `governance: "promoted"` and no human in the loop; a rejected one
16805///     reports `rejected_by_gate` and applies nothing (and does NOT then ask an
16806///     operator to approve what the daemon just measured as a regression); a
16807///     failed replay reports `measurement_failed`; `NeedsApproval` /
16808///     `Incomparable` fall through to `pending_approval` carrying the gate's
16809///     own reason.
16810///   - Otherwise the human gate, with a reason naming the missing precondition
16811///     (`context_measure` absent, `dry_run`, or a patchless mutation). As with
16812///     `harness_measure`, requesting `context_measure` on a build with no
16813///     evaluator installed is an ERROR, and `dry_run` performs no paid replay.
16814/// - **Tools** → recorded as `out_of_scope` with its reason: connector
16815///   remediation is a credential operation this loop holds no authority to
16816///   perform (reconnect/re-auth stay operator actions via `connectors.*`).
16817///   That is a decision, not a failure, and `out_of_scope: true` with
16818///   `ran: true` is how it is now reported — an `Err` here used to make it
16819///   indistinguishable from a crashed mechanism.
16820///
16821/// `params`: `{ policy?, dry_run?, harness_baseline_metrics?,
16822/// harness_candidate_metrics?, harness_measure?, context_measure? }`, where
16823/// `harness_measure` and `context_measure` are each a
16824/// [`HarnessMeasureRequest`](crate::evolution::HarnessMeasureRequest)
16825/// `{ model, split?, held_in_fraction?, split_seed?, max_turns?, tasks_dir? }`
16826/// — the same type, because the two arms replay the same suite over the same
16827/// split and differ only in which config is varied. They are NOT mutually
16828/// exclusive with each other (they grade different pillars); `harness_measure`
16829/// remains mutually exclusive with the two supplied-metrics params. `dry_run` skips every side effect (no
16830/// consolidate, no evolve, no config apply, no event append) and reports what
16831/// would run — including the `pending_approvals` that a real run would
16832/// surface (listing them is response data, not a side effect). `evolved`
16833/// lists only components that **applied a change** (S2) — an Ok-but-no-op
16834/// step (nothing to evolve, everything still pending approval, dry run) is
16835/// reported in `steps` with `applied: false` but not in `evolved`. At most
16836/// one `evolution.run` executes per session at a time; a concurrent second
16837/// call errs instead of overlapping (S3). Returns
16838/// `{ plan, steps, evolved, out_of_scope, pending_approvals? }`, where
16839/// `out_of_scope` lists the components that were planned but have no mechanism
16840/// by decision (their steps carry `out_of_scope: true` and the reason); a real
16841/// run appends one
16842/// `EvolutionTriggered` event (`data.source = "evolution.run"`) to the session
16843/// event log and, when bound to a lifecycle agent, persists the per-agent
16844/// memgine after a cycle that applied something (S1).
16845/// Where one mutation's candidate telemetry came from — or why there is none.
16846///
16847/// The gate's input is never fabricated, so "no candidate metrics" and "the
16848/// measurement failed" are separate outcomes with separate statuses, and
16849/// neither is ever rounded to a zero document.
16850enum CandidateSource {
16851    /// Measured out of process by the caller (`harness_candidate_metrics`).
16852    Supplied(car_eventlog::harness_metrics::HarnessMetrics),
16853    /// Measured in process by this cycle's [`HarnessMeasurer`](crate::evolution::HarnessMeasurer).
16854    Measured(car_eventlog::harness_metrics::HarnessMetrics),
16855    /// A measurement was attempted and errored. Nothing is applied, nothing is
16856    /// synthesized, and the cycle continues to the next mutation.
16857    Failed(String),
16858    /// No measurement exists or could be taken, with the reason — routes to
16859    /// HITL exactly as it did before in-daemon measurement existed.
16860    Unavailable(String),
16861}
16862
16863async fn handle_evolution_run(
16864    req: &JsonRpcMessage,
16865    session: &crate::session::ClientSession,
16866    state: &Arc<ServerState>,
16867) -> Result<Value, String> {
16868    use car_memgine::harness_evolution::{
16869        mutation_fingerprint, EvolutionAgent, Governance, PromotionDecision,
16870    };
16871    use car_memgine::self_evolution::{EvolutionOutcome, EvolvableComponent};
16872
16873    // Non-overlap guard (S3): the dispatcher spawns a task per request, so
16874    // even one connection can land two evolution.run frames concurrently —
16875    // which would double-dispatch consolidate/evolve and race the harness
16876    // apply. Held (RAII) until this handler returns.
16877    let _cycle_token = session
16878        .evolution_guard
16879        .try_begin()
16880        .ok_or("evolution.run already in flight on this session")?;
16881
16882    let policy: car_memgine::self_evolution::EvolutionPolicy = serde_json::from_value(
16883        req.params
16884            .get("policy")
16885            .cloned()
16886            .unwrap_or_else(|| serde_json::json!({})),
16887    )
16888    .map_err(|e| format!("invalid policy: {e}"))?;
16889    let dry_run = req
16890        .params
16891        .get("dry_run")
16892        .and_then(|v| v.as_bool())
16893        .unwrap_or(false);
16894    let baseline_override: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16895        match req.params.get("harness_baseline_metrics") {
16896            Some(v) if !v.is_null() => Some(
16897                serde_json::from_value(v.clone())
16898                    .map_err(|e| format!("invalid harness_baseline_metrics: {e}"))?,
16899            ),
16900            _ => None,
16901        };
16902    let candidate_metrics: Option<car_eventlog::harness_metrics::HarnessMetrics> =
16903        match req.params.get("harness_candidate_metrics") {
16904            Some(v) if !v.is_null() => Some(
16905                serde_json::from_value(v.clone())
16906                    .map_err(|e| format!("invalid harness_candidate_metrics: {e}"))?,
16907            ),
16908            _ => None,
16909        };
16910
16911    // In-daemon measurement (opt-in). Parsed before anything runs so a
16912    // malformed request costs nothing.
16913    let measure_request = crate::evolution::parse_harness_measure_request(&req.params)?;
16914
16915    // 1. Mutual exclusion. Measuring and handing metrics in are two answers to
16916    //    the same question; picking a winner silently would let a caller
16917    //    believe the daemon graded a replay it never ran (or the reverse).
16918    if measure_request.is_some() && (baseline_override.is_some() || candidate_metrics.is_some()) {
16919        let supplied = match (baseline_override.is_some(), candidate_metrics.is_some()) {
16920            (true, true) => "harness_baseline_metrics and harness_candidate_metrics",
16921            (true, false) => "harness_baseline_metrics",
16922            _ => "harness_candidate_metrics",
16923        };
16924        return Err(format!(
16925            "harness_measure cannot be combined with {supplied}: harness_measure makes the \
16926             daemon measure the baseline and every candidate itself, so supplied metrics would \
16927             have to be either ignored or silently preferred. Send one or the other."
16928        ));
16929    }
16930
16931    // 2. dry_run measures NOTHING. A benchmark replay is a paid side effect and
16932    //    dry_run skips every side effect.
16933    let measuring = measure_request.is_some() && !dry_run;
16934
16935    // 3. No measurer installed on this build → error, not a quiet fall back to
16936    //    HITL. An opt-in that silently does nothing would report an unattended
16937    //    cycle that never measured anything.
16938    let measurer = if measuring {
16939        Some(state.harness_measurer().ok_or(
16940            "harness_measure requested but this daemon build has no in-process harness \
16941             evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16942             startup; embedders must call ServerState::set_harness_measurer)",
16943        )?)
16944    } else {
16945        None
16946    };
16947
16948    // 3b. The CONTEXT arm's pre-activation grader, opt-in via `context_measure`
16949    //     and resolved off the same installed measurer — the replay is the same
16950    //     replay over the same split, and only which config is varied differs.
16951    //
16952    //     Two differences from `harness_measure` above, both deliberate:
16953    //
16954    //     - It is NOT mutually exclusive with `harness_measure`. They grade
16955    //       different pillars against different configs, and a caller who wants
16956    //       one cycle to do both is asking for two independent measurements,
16957    //       not two answers to the same question. (It costs two more replays;
16958    //       that is the caller's explicit choice.)
16959    //     - The measurer is resolved even under `dry_run`, where the harness
16960    //       arm skips resolution entirely. A dry run is supposed to report what
16961    //       a REAL run would do, and what a real run would do on a build with
16962    //       no evaluator installed is fail — reporting "nothing was measured
16963    //       because this was a dry run" would hide that. The dry-run rule
16964    //       itself (no paid replay) is enforced inside
16965    //       `run_context_evolution`, which is also what lets its pending reason
16966    //       name `dry_run` as the precondition that was missing.
16967    let context_measure_request = crate::evolution::parse_context_measure_request(&req.params)?;
16968    let context_measurer = match context_measure_request.as_ref() {
16969        Some(_) => Some(state.harness_measurer().ok_or(
16970            "context_measure requested but this daemon build has no in-process harness \
16971             evaluator installed (car-server installs car-bench's BenchHarnessMeasurer at \
16972             startup; embedders must call ServerState::set_harness_measurer)",
16973        )?),
16974        None => None,
16975    };
16976
16977    // The config the whole comparison is anchored on: the session runtime's
16978    // live `HarnessConfig` at cycle start (`None` = the runtime default).
16979    // Captured ONCE — a mutation that applies mid-cycle must not move the base
16980    // for the next mutation's candidate, or that candidate would be graded
16981    // against a baseline measured under a different config.
16982    let live_harness_config = session.runtime.harness_config().await;
16983
16984    // 4. The baseline, measured once under that live config. An error here
16985    //    fails the Harness STEP (recorded, nothing applied) rather than the
16986    //    whole cycle — Memory and Skills are unaffected by a bench failure.
16987    let mut baseline_measurement_error: Option<String> = None;
16988    let mut measured_baseline: Option<car_eventlog::harness_metrics::HarnessMetrics> = None;
16989    if let (Some(m), Some(rq)) = (measurer.as_ref(), measure_request.as_ref()) {
16990        match crate::evolution::measure_baseline(m.as_ref(), rq, live_harness_config.as_ref()).await
16991        {
16992            Ok(metrics) => measured_baseline = Some(metrics),
16993            Err(e) => baseline_measurement_error = Some(e),
16994        }
16995    }
16996
16997    let mut components = assemble_evolution_components(session, state).await;
16998    // S1: the same engine the assembler planned over — the daemon-owned
16999    // per-agent engine for bound sessions, else the session engine.
17000    let engine_arc = session.effective_memgine().await;
17001
17002    // Pre-fold the session-log-derived executor inputs once (single lock).
17003    let (failed_events, live_metrics) = {
17004        let log = session.runtime.log.lock().await;
17005        (
17006            crate::evolution::failed_trace_events(log.events()),
17007            car_eventlog::harness_metrics::compute_harness_metrics(log.events()),
17008        )
17009    };
17010    // A daemon-measured baseline is the declared telemetry exactly like a
17011    // supplied one — same document, same role — so the telemetry that elects
17012    // the Harness component is the telemetry that diagnoses it.
17013    let has_baseline_override = baseline_override.is_some() || measured_baseline.is_some();
17014    let baseline_metrics = measured_baseline
17015        .clone()
17016        .or(baseline_override)
17017        .unwrap_or(live_metrics);
17018    // A caller-supplied baseline is the declared harness telemetry for this
17019    // cycle: it drives the diagnosis below AND the Harness planning signal —
17020    // otherwise a held-out baseline could diagnose mutations the plan never
17021    // dispatches (the live session log may be empty).
17022    if has_baseline_override {
17023        components
17024            .retain(|c| c.component != car_memgine::self_evolution::EvolvableComponent::Harness);
17025        if let Some(h) = crate::evolution::harness_component_from_metrics(&baseline_metrics) {
17026            components.push(h);
17027        }
17028    }
17029    let harness_mutations = EvolutionAgent::new().diagnose(&baseline_metrics);
17030
17031    // 6. Auditability: what was measured, on which split, under which model,
17032    //    and the full baseline document the verdicts were computed against.
17033    //    Metrics are numbers; publishing them is what makes a promotion
17034    //    re-derivable by whoever reviews the cycle.
17035    //
17036    //    A FAILED baseline replay gets a shape of its own here rather than
17037    //    being left implicit: it is the one outcome where the response would
17038    //    otherwise read as a clean cycle. Reported at step 7 below whether or
17039    //    not the Harness component was ever elected.
17040    let measurement_summary: Option<Value> = measure_request.as_ref().map(|rq| {
17041        let mut o = serde_json::json!({
17042            "split": rq.split,
17043            "model": rq.model,
17044            "split_seed": rq.split_seed,
17045        });
17046        let obj = o.as_object_mut().expect("object literal");
17047        if dry_run {
17048            obj.insert("status".into(), Value::from("skipped_dry_run"));
17049        } else if let Some(b) = &measured_baseline {
17050            obj.insert("status".into(), Value::from("measured"));
17051            obj.insert(
17052                "baseline_metrics".into(),
17053                serde_json::to_value(b).unwrap_or(Value::Null),
17054            );
17055        } else {
17056            obj.insert("status".into(), Value::from("measurement_failed"));
17057            obj.insert(
17058                "error".into(),
17059                baseline_measurement_error
17060                    .clone()
17061                    .map(Value::from)
17062                    .unwrap_or(Value::Null),
17063            );
17064        }
17065        o
17066    });
17067
17068    let pending_approvals: std::sync::Mutex<Vec<Value>> = std::sync::Mutex::new(Vec::new());
17069    let failed_events = &failed_events;
17070    let harness_mutations = &harness_mutations;
17071    let baseline_metrics = &baseline_metrics;
17072    let candidate_metrics = &candidate_metrics;
17073    let pending_ref = &pending_approvals;
17074    let engine_ref = &engine_arc;
17075    let measurer_ref = &measurer;
17076    let measure_request_ref = &measure_request;
17077    let context_measurer_ref = &context_measurer;
17078    let context_measure_request_ref = &context_measure_request;
17079    // The base every candidate config is projected from: the live config, or
17080    // the runtime default when none is installed (which is what the baseline
17081    // replay ran under).
17082    let base_harness_config = live_harness_config.clone().unwrap_or_default();
17083    let base_harness_config = &base_harness_config;
17084    let baseline_measurement_error = &baseline_measurement_error;
17085    let measurement_summary = &measurement_summary;
17086
17087    let run = |component: EvolvableComponent| {
17088        let engine = engine_ref.clone();
17089        async move {
17090            match component {
17091                EvolvableComponent::Memory => {
17092                    crate::evolution::run_memory_evolution(&engine, dry_run).await
17093                }
17094                EvolvableComponent::Skills => {
17095                    crate::evolution::run_skills_evolution(&engine, failed_events, dry_run).await
17096                }
17097                EvolvableComponent::Harness => {
17098                    // A failed baseline replay fails THIS step and nothing
17099                    // else: no diagnosis it produced can be trusted, nothing is
17100                    // applied, and no metrics are synthesized to stand in.
17101                    if let Some(e) = baseline_measurement_error {
17102                        return Err(e.clone());
17103                    }
17104                    if harness_mutations.is_empty() {
17105                        return Ok(EvolutionOutcome::no_op(
17106                            "no harness mutations diagnosed from session telemetry",
17107                        ));
17108                    }
17109                    let agent = EvolutionAgent::new();
17110                    let mut details: Vec<Value> = Vec::new();
17111                    let mut applied = 0usize;
17112                    let mut pending = 0usize;
17113                    for m in harness_mutations {
17114                        // C2: the fingerprint binds the authorized CHANGE
17115                        // (component + patch content), not diagnostic prose —
17116                        // stable across re-diagnoses, so a standing approval
17117                        // matches.
17118                        let fingerprint = mutation_fingerprint(m);
17119                        // C1: prior decisions come from the daemon's SHARED
17120                        // durable ledger — the approver is typically another
17121                        // connection (a host UI).
17122                        let prior = {
17123                            let ledger = state.approval_ledger.read().await;
17124                            ledger.lookup(&fingerprint).map(|r| r.decision)
17125                        };
17126                        // Authorization resolution, most binding first: a
17127                        // durable human decision, then the regression gate
17128                        // (only when the caller measured candidate telemetry),
17129                        // else HITL. A pending entry is always LISTED —
17130                        // reporting what needs approval is response data;
17131                        // dry_run only skips real side effects.
17132                        let status: Value = match prior {
17133                            Some(car_policy::ApprovalDecision::Rejected) => {
17134                                serde_json::json!({ "status": "rejected_by_operator" })
17135                            }
17136                            Some(car_policy::ApprovalDecision::Approved) => {
17137                                if m.patch.is_none() {
17138                                    serde_json::json!({
17139                                        "status": "approved_no_patch",
17140                                        "note": "approved but carries no concrete config patch — a human designs this change",
17141                                    })
17142                                } else if dry_run {
17143                                    serde_json::json!({ "status": "would_apply", "governance": "human_approved" })
17144                                } else {
17145                                    // S3: one atomic read-modify-write under
17146                                    // the runtime's config write lock.
17147                                    match session
17148                                        .runtime
17149                                        .update_harness_config(|cfg| {
17150                                            cfg.apply(m, Governance::HumanApproved)
17151                                        })
17152                                        .await
17153                                    {
17154                                        Ok(inverse) => {
17155                                            applied += 1;
17156                                            serde_json::json!({
17157                                                "status": "applied",
17158                                                "governance": "human_approved",
17159                                                "rollback_patch": inverse,
17160                                            })
17161                                        }
17162                                        Err(e) => serde_json::json!({
17163                                            "status": "apply_failed", "error": e,
17164                                        }),
17165                                    }
17166                                }
17167                            }
17168                            None => {
17169                                // Resolve this mutation's candidate telemetry:
17170                                // the caller's measured document, or — when
17171                                // in-daemon measuring is on — a replay we run
17172                                // ourselves under the candidate config.
17173                                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";
17174                                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";
17175                                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";
17176                                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";
17177
17178                                let source = if let Some(cand) = candidate_metrics {
17179                                    CandidateSource::Supplied(cand.clone())
17180                                } else if let (Some(measurer), Some(rq)) =
17181                                    (measurer_ref.as_ref(), measure_request_ref.as_ref())
17182                                {
17183                                    match m.patch.as_ref().filter(|p| !p.is_empty()) {
17184                                        None => CandidateSource::Unavailable(
17185                                            NO_PATCH_REASON.to_string(),
17186                                        ),
17187                                        Some(_) if m.requires_human_approval() => {
17188                                            CandidateSource::Unavailable(
17189                                                SAFETY_SKIP_REASON.to_string(),
17190                                            )
17191                                        }
17192                                        Some(patch) => {
17193                                            match crate::evolution::measure_candidate(
17194                                                measurer.as_ref(),
17195                                                rq,
17196                                                base_harness_config,
17197                                                patch,
17198                                            )
17199                                            .await
17200                                            {
17201                                                Ok(metrics) => CandidateSource::Measured(metrics),
17202                                                Err(e) => CandidateSource::Failed(e),
17203                                            }
17204                                        }
17205                                    }
17206                                } else if measure_request_ref.is_some() {
17207                                    CandidateSource::Unavailable(DRY_RUN_REASON.to_string())
17208                                } else {
17209                                    CandidateSource::Unavailable(NO_CANDIDATE_REASON.to_string())
17210                                };
17211
17212                                // Audit the document the verdict was computed
17213                                // from — a decision nobody can re-derive is not
17214                                // an audited decision.
17215                                let measured_here = matches!(source, CandidateSource::Measured(_));
17216
17217                                match source {
17218                                    CandidateSource::Supplied(ref cand)
17219                                    | CandidateSource::Measured(ref cand) => {
17220                                        let decision = agent.evaluate(m, baseline_metrics, cand);
17221                                        let mut status = match &decision {
17222                                            PromotionDecision::Promote { reason } => {
17223                                                let reason = reason.clone();
17224                                                if dry_run {
17225                                                    serde_json::json!({ "status": "would_apply", "governance": "promoted", "reason": reason })
17226                                                } else {
17227                                                    match session
17228                                                        .runtime
17229                                                        .update_harness_config(|cfg| {
17230                                                            cfg.apply(
17231                                                                m,
17232                                                                Governance::Promoted(
17233                                                                    decision.clone(),
17234                                                                ),
17235                                                            )
17236                                                        })
17237                                                        .await
17238                                                    {
17239                                                        Ok(inverse) => {
17240                                                            applied += 1;
17241                                                            serde_json::json!({
17242                                                                "status": "applied",
17243                                                                "governance": "promoted",
17244                                                                "reason": reason,
17245                                                                "rollback_patch": inverse,
17246                                                            })
17247                                                        }
17248                                                        Err(e) => serde_json::json!({
17249                                                            "status": "apply_failed", "error": e,
17250                                                        }),
17251                                                    }
17252                                                }
17253                                            }
17254                                            PromotionDecision::NeedsApproval { reason } => {
17255                                                pending += 1;
17256                                                pending_ref.lock().unwrap().push(
17257                                                    serde_json::json!({
17258                                                        "fingerprint": fingerprint,
17259                                                        "mutation": m.id,
17260                                                        "component": m.contract.component,
17261                                                        "safety_affecting": true,
17262                                                        "rationale": m.rationale,
17263                                                        "reason": reason,
17264                                                    }),
17265                                                );
17266                                                serde_json::json!({ "status": "pending_approval", "reason": reason })
17267                                            }
17268                                            PromotionDecision::Reject { reason } => {
17269                                                serde_json::json!({ "status": "rejected_by_gate", "reason": reason })
17270                                            }
17271                                            // No verdict: the two telemetry
17272                                            // documents are not measurable against
17273                                            // each other (today, task pass rates
17274                                            // over different task sets). Reported
17275                                            // as its own status rather than folded
17276                                            // into `rejected_by_gate` — the caller
17277                                            // needs to fix the comparison and
17278                                            // re-run, which is different advice
17279                                            // from "this mutation is bad".
17280                                            // Nothing is applied either way.
17281                                            PromotionDecision::Incomparable { reason } => {
17282                                                serde_json::json!({ "status": "incomparable", "reason": reason })
17283                                            }
17284                                        };
17285                                        if measured_here {
17286                                            if let Some(obj) = status.as_object_mut() {
17287                                                obj.insert(
17288                                                    "candidate_metrics".into(),
17289                                                    serde_json::to_value(cand)
17290                                                        .unwrap_or(Value::Null),
17291                                                );
17292                                            }
17293                                        }
17294                                        status
17295                                    }
17296                                    // A measurement that errored is reported as
17297                                    // exactly that. Nothing is applied, nothing is
17298                                    // fabricated, and the cycle moves on to the
17299                                    // next mutation.
17300                                    CandidateSource::Failed(error) => serde_json::json!({
17301                                        "status": "measurement_failed",
17302                                        "error": error,
17303                                    }),
17304                                    // No candidate telemetry → the regression gate
17305                                    // cannot be run honestly in-cycle; every
17306                                    // activation routes to HITL. Patchless
17307                                    // mutations are proposals a human addresses
17308                                    // either way.
17309                                    CandidateSource::Unavailable(reason) => {
17310                                        pending += 1;
17311                                        pending_ref.lock().unwrap().push(serde_json::json!({
17312                                            "fingerprint": fingerprint,
17313                                            "mutation": m.id,
17314                                            "component": m.contract.component,
17315                                            "safety_affecting": m.requires_human_approval(),
17316                                            "rationale": m.rationale,
17317                                            "reason": reason,
17318                                        }));
17319                                        serde_json::json!({
17320                                            "status": "pending_approval",
17321                                            "reason": reason,
17322                                        })
17323                                    }
17324                                }
17325                            }
17326                        };
17327                        let mut d = serde_json::json!({
17328                            "mutation": m.id,
17329                            "component": m.contract.component,
17330                            "fingerprint": fingerprint,
17331                            "rationale": m.rationale,
17332                        });
17333                        if let (Some(obj), Some(s)) = (d.as_object_mut(), status.as_object()) {
17334                            for (k, v) in s {
17335                                obj.insert(k.clone(), v.clone());
17336                            }
17337                        }
17338                        details.push(d);
17339                    }
17340                    let mut summary_obj = serde_json::json!({
17341                        "mechanism": "harness_evolution",
17342                        "mutations": harness_mutations.len(),
17343                        "applied": applied,
17344                        "pending": pending,
17345                        "details": details,
17346                    });
17347                    if let (Some(obj), Some(measurement)) =
17348                        (summary_obj.as_object_mut(), measurement_summary.as_ref())
17349                    {
17350                        obj.insert("measurement".into(), measurement.clone());
17351                    }
17352                    let summary = serde_json::to_string(&summary_obj).map_err(|e| e.to_string())?;
17353                    // S2: "evolved" means a patch actually landed — an
17354                    // all-pending/all-rejected pass is a no-op.
17355                    Ok(if applied > 0 {
17356                        EvolutionOutcome::applied(summary)
17357                    } else {
17358                        EvolutionOutcome::no_op(summary)
17359                    })
17360                }
17361                EvolvableComponent::Context => {
17362                    // `None` backoff: a session-driven run is a person asking
17363                    // for the check now. The unattended cadence is the loop
17364                    // that needs a brake, and it passes `Some`.
17365                    //
17366                    // The measure pair is `Some` exactly when the caller
17367                    // supplied `context_measure` (the measurer resolution
17368                    // above already erred if this build cannot measure), which
17369                    // is what turns the Context pillar unattended: a graded
17370                    // mutation promotes or is rejected without ever reaching
17371                    // the ledger.
17372                    let context_measure = match (
17373                        context_measurer_ref.as_ref(),
17374                        context_measure_request_ref.as_ref(),
17375                    ) {
17376                        (Some(m), Some(rq)) => {
17377                            Some((m.as_ref() as &dyn crate::evolution::HarnessMeasurer, rq))
17378                        }
17379                        _ => None,
17380                    };
17381                    crate::evolution::run_context_evolution(
17382                        &engine,
17383                        state,
17384                        dry_run,
17385                        pending_ref,
17386                        None,
17387                        context_measure,
17388                    )
17389                    .await
17390                }
17391                // A deliberate scope decision, reported as one. Returning an
17392                // `Err` here recorded `ran: false` — the same shape a crashed
17393                // mechanism produces — so a boundary the project chose on
17394                // purpose read as a failing subsystem in every cycle report.
17395                EvolvableComponent::Tools => Ok(EvolutionOutcome::out_of_scope(
17396                    crate::evolution::TOOLS_OUT_OF_SCOPE_REASON,
17397                )),
17398            }
17399        }
17400    };
17401
17402    let report = car_memgine::self_evolution::run_evolution_cycle(&components, &policy, run).await;
17403
17404    if !dry_run {
17405        let mut data: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
17406        data.insert("source".into(), Value::from("evolution.run"));
17407        data.insert(
17408            "evolve_now".into(),
17409            serde_json::to_value(&report.plan.evolve_now).unwrap_or(Value::Null),
17410        );
17411        data.insert(
17412            "evolved".into(),
17413            serde_json::to_value(&report.evolved).unwrap_or(Value::Null),
17414        );
17415        session.runtime.log.lock().await.append(
17416            car_eventlog::EventKind::EvolutionTriggered,
17417            None,
17418            None,
17419            data,
17420        );
17421        // S1: a bound lifecycle agent's engine is daemon-persisted after a
17422        // mutating run, like memory.add/memory.consolidate.
17423        if !report.evolved.is_empty() {
17424            if let Some(id) = session.agent_id.lock().await.clone() {
17425                if let Err(e) = persist_agent_memgine(&id, &engine_arc).await {
17426                    tracing::warn!(agent_id = %id, error = %e,
17427                        "agent memgine persist after evolution.run failed; in-memory state is canonical");
17428                }
17429            }
17430        }
17431    }
17432
17433    let pending = pending_approvals.into_inner().unwrap();
17434    let mut resp = serde_json::json!({
17435        "plan": report.plan,
17436        "steps": report.steps,
17437        "evolved": report.evolved,
17438        // Components this cycle deliberately did not evolve. Always present
17439        // (empty when there were none) so a caller can distinguish "no boundary
17440        // was hit" from "this daemon predates the field".
17441        "out_of_scope": report.out_of_scope,
17442    });
17443    if !pending.is_empty() {
17444        resp.as_object_mut()
17445            .unwrap()
17446            .insert("pending_approvals".into(), Value::Array(pending));
17447    }
17448    // 7. The measurement is reported at the TOP LEVEL, not only inside the
17449    //    Harness step.
17450    //
17451    //    A benchmark replay is a paid side effect — real model calls, real
17452    //    money — and a side effect nobody can see in the response is one nobody
17453    //    can audit. The baseline replay runs BEFORE the plan is assembled (it
17454    //    is what supplies the Harness planning signal), but the plan may
17455    //    legitimately never reach the Harness arm: `harness_component_from_
17456    //    metrics` returns `None` at zero attempts, and an elected component can
17457    //    still `skip` (pressure below threshold) or `defer` (evidence below
17458    //    `min_evidence`). Reported only from the step, a caller who asked for
17459    //    `harness_measure` on a healthy harness would be billed for a replay
17460    //    the response never mentions — and a replay that FAILED would vanish
17461    //    the same way, leaving a normal-looking cycle with no hint that the
17462    //    measurement they explicitly requested never happened.
17463    //
17464    //    So this key is present whenever `harness_measure` was requested, in
17465    //    every shape the measurement can end in: `measured`,
17466    //    `skipped_dry_run`, or `measurement_failed` carrying the error. The
17467    //    Harness step keeps its own copy — the elected case loses nothing.
17468    if let Some(measurement) = measurement_summary.as_ref() {
17469        resp.as_object_mut()
17470            .unwrap()
17471            .insert("measurement".into(), measurement.clone());
17472    }
17473    Ok(resp)
17474}
17475
17476/// Repair a degraded skill on this client's session memgine.
17477/// Returns `{ code: "..." }` on success, `null` if the skill
17478/// isn't broken or repair failed.
17479async fn handle_skill_repair(
17480    msg: &JsonRpcMessage,
17481    session: &crate::session::ClientSession,
17482) -> Result<Value, String> {
17483    let name = msg
17484        .params
17485        .get("skill_name")
17486        .and_then(|v| v.as_str())
17487        .ok_or("missing 'skill_name' parameter")?;
17488    let mut engine = session.memgine.lock().await;
17489    let code = engine.repair_skill(name).await;
17490    Ok(match code {
17491        Some(c) => serde_json::json!({ "code": c }),
17492        None => Value::Null,
17493    })
17494}
17495
17496/// Ingest distilled skills into this client's session memgine.
17497/// Returns the number of nodes inserted.
17498async fn handle_skills_ingest_distilled(
17499    msg: &JsonRpcMessage,
17500    session: &crate::session::ClientSession,
17501) -> Result<Value, String> {
17502    let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17503        msg.params
17504            .get("skills")
17505            .cloned()
17506            .unwrap_or(msg.params.clone()),
17507    )
17508    .map_err(|e| format!("invalid skills: {}", e))?;
17509    let mut engine = session.memgine.lock().await;
17510    let nodes = engine.ingest_distilled_skills(&skills);
17511    Ok(serde_json::json!({ "ingested": nodes.len() }))
17512}
17513
17514/// Run skill evolution against this session's memgine for a
17515/// specified domain.  Returns the resulting `DistilledSkill` array.
17516async fn handle_skills_evolve(
17517    msg: &JsonRpcMessage,
17518    session: &crate::session::ClientSession,
17519) -> Result<Value, String> {
17520    let domain = msg
17521        .params
17522        .get("domain")
17523        .and_then(|v| v.as_str())
17524        .ok_or("missing 'domain' parameter")?
17525        .to_string();
17526    let events: Vec<car_memgine::TraceEvent> = serde_json::from_value(
17527        msg.params
17528            .get("events")
17529            .cloned()
17530            .unwrap_or(Value::Array(vec![])),
17531    )
17532    .map_err(|e| format!("invalid events: {}", e))?;
17533    let mut engine = session.memgine.lock().await;
17534    let skills = engine.evolve_skills(&events, &domain).await;
17535    serde_json::to_value(&skills).map_err(|e| e.to_string())
17536}
17537
17538/// List domains whose skills are underperforming on this session.
17539async fn handle_skills_domains_needing_evolution(
17540    msg: &JsonRpcMessage,
17541    session: &crate::session::ClientSession,
17542) -> Result<Value, String> {
17543    let threshold = msg
17544        .params
17545        .get("threshold")
17546        .and_then(|v| v.as_f64())
17547        .unwrap_or(0.6);
17548    let engine = session.memgine.lock().await;
17549    let domains = engine.domains_needing_evolution(threshold);
17550    serde_json::to_value(&domains).map_err(|e| e.to_string())
17551}
17552
17553/// Ingest distilled/evolved skills as PROVISIONAL candidates on trial
17554/// (validation-gated optimization — see docs/solutions/gated-skill-optimization.md).
17555/// Unlike `skills.ingest_distilled`, these must prove themselves before the
17556/// promotion gate makes them Active. Returns `{ ingested: n }` (drops
17557/// already-rejected or already-trialing candidates).
17558async fn handle_skills_ingest_provisional(
17559    msg: &JsonRpcMessage,
17560    session: &crate::session::ClientSession,
17561) -> Result<Value, String> {
17562    let skills: Vec<car_memgine::DistilledSkill> = serde_json::from_value(
17563        msg.params
17564            .get("skills")
17565            .cloned()
17566            .unwrap_or(msg.params.clone()),
17567    )
17568    .map_err(|e| format!("invalid skills: {}", e))?;
17569    let tenant = msg.params.get("tenant").and_then(|v| v.as_str());
17570    let mut engine = session.memgine.lock().await;
17571    let ingested = engine.ingest_provisional_candidates(&skills, tenant);
17572    Ok(serde_json::json!({ "ingested": ingested }))
17573}
17574
17575/// Run the skill promotion gate against this session's memgine: provisional
17576/// candidates with enough trial outcomes are promoted (strictly-better Wilson
17577/// lower bound) or rejected. Normally fires automatically in `consolidate()`;
17578/// this exposes a manual trigger. Returns `{ promoted: [...], rejected: [...] }`.
17579async fn handle_skills_gate(
17580    _msg: &JsonRpcMessage,
17581    session: &crate::session::ClientSession,
17582) -> Result<Value, String> {
17583    let mut engine = session.memgine.lock().await;
17584    let (promoted, rejected) = engine.gate_skill_candidates();
17585    Ok(serde_json::json!({ "promoted": promoted, "rejected": rejected }))
17586}
17587
17588/// Fetch a skill's full `SkillMeta` by key — including lifecycle `status`
17589/// (active/provisional), `incumbent`, `version`, and `stats`. Returns the JSON
17590/// SkillMeta, or `null` if no active skill node holds the key.
17591async fn handle_skill_meta(
17592    msg: &JsonRpcMessage,
17593    session: &crate::session::ClientSession,
17594) -> Result<Value, String> {
17595    let key = msg
17596        .params
17597        .get("key")
17598        .and_then(|v| v.as_str())
17599        .ok_or("missing 'key' parameter")?;
17600    let engine = session.memgine.lock().await;
17601    match engine.skill_meta(key) {
17602        Some(meta) => serde_json::to_value(&meta).map_err(|e| e.to_string()),
17603        None => Ok(Value::Null),
17604    }
17605}
17606
17607/// Export a VALIDATED skill as a portable markdown document (the SkillOpt
17608/// best_skill.md analog). Only Active, healthy skills export; returns the
17609/// markdown string, or `null` if the key is absent / not exportable.
17610async fn handle_skill_export(
17611    msg: &JsonRpcMessage,
17612    session: &crate::session::ClientSession,
17613) -> Result<Value, String> {
17614    let key = msg
17615        .params
17616        .get("key")
17617        .and_then(|v| v.as_str())
17618        .ok_or("missing 'key' parameter")?;
17619    let engine = session.memgine.lock().await;
17620    Ok(engine
17621        .export_skill(key)
17622        .map(Value::String)
17623        .unwrap_or(Value::Null))
17624}
17625
17626/// Import a skill from a portable markdown document (digest-verified). Ingests
17627/// as a fresh Active skill. Returns `{ imported: true }`, or a JSON-RPC error if
17628/// the document is malformed or its content digest doesn't verify.
17629async fn handle_skill_import(
17630    msg: &JsonRpcMessage,
17631    session: &crate::session::ClientSession,
17632) -> Result<Value, String> {
17633    let md = msg
17634        .params
17635        .get("markdown")
17636        .and_then(|v| v.as_str())
17637        .ok_or("missing 'markdown' parameter")?;
17638    let mut engine = session.memgine.lock().await;
17639    engine.import_skill_markdown(md)?;
17640    Ok(serde_json::json!({ "imported": true }))
17641}
17642
17643/// Rerank documents against a query using a cross-encoder model.
17644async fn handle_rerank(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17645    let engine = get_inference_engine(state);
17646    let req: car_inference::RerankRequest = typed_params(&msg.params)?;
17647    let _permit = state.admission.acquire().await;
17648    let result = engine.rerank(req).await.map_err(|e| e.to_string())?;
17649    serde_json::to_value(&result).map_err(|e| e.to_string())
17650}
17651
17652/// Transcribe audio at the given path. The path is interpreted on
17653/// the daemon's filesystem, not the FFI caller's — Daemon-mode
17654/// callers must pass a path the daemon can read (typically a
17655/// shared `~/.car/...` location or stdin push via the streaming
17656/// API).
17657/// `search` — web search, fulfilled natively (Parslee-hosted when signed in,
17658/// else a bring-your-own Tavily key). Engine-independent; resolves the provider
17659/// from the environment. See `car_inference::search`.
17660async fn handle_search(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17661    let req: car_inference::search::SearchRequest =
17662        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17663    let _permit = state.admission.acquire().await;
17664    let result = car_inference::search::web_search(&req.query, req.max_results)
17665        .await
17666        .map_err(|e| e.to_string())?;
17667    serde_json::to_value(&result).map_err(|e| e.to_string())
17668}
17669
17670/// `web_fetch` — fetch a URL and extract readable text. Keyless; the companion
17671/// to `search`. See `car_inference::search::web_fetch`.
17672async fn handle_web_fetch(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17673    let req: car_inference::search::FetchRequest =
17674        serde_json::from_value(msg.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17675    let _permit = state.admission.acquire().await;
17676    let result = car_inference::search::web_fetch(&req.url)
17677        .await
17678        .map_err(|e| e.to_string())?;
17679    serde_json::to_value(&result).map_err(|e| e.to_string())
17680}
17681
17682async fn handle_transcribe(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17683    use base64::Engine as _;
17684    let engine = get_inference_engine(state);
17685
17686    // Sandbox-crossing escape hatch (Parslee-ai/car-releases#31): when
17687    // the caller can't share a filesystem view with the daemon (e.g.
17688    // unsandboxed Milo talking to a sandboxed car-host), they pass
17689    // `audio_b64` instead of `audio_path`. We decode to a tempfile,
17690    // run transcribe against the path the engine expects, and clean up
17691    // on drop. Accepts either form; `audio_b64` wins if both are set.
17692    let mut params = msg.params.clone();
17693    let audio_b64 = params
17694        .as_object_mut()
17695        .and_then(|m| m.remove("audio_b64"))
17696        .and_then(|v| v.as_str().map(str::to_string));
17697    let _tmp_audio = if let Some(b64) = audio_b64 {
17698        let bytes = base64::engine::general_purpose::STANDARD
17699            .decode(b64.as_bytes())
17700            .map_err(|e| format!("audio_b64 decode failed: {e}"))?;
17701        let tmp = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?;
17702        std::fs::write(tmp.path(), &bytes).map_err(|e| e.to_string())?;
17703        let path = tmp.path().to_string_lossy().into_owned();
17704        if let Some(obj) = params.as_object_mut() {
17705            obj.insert("audio_path".to_string(), Value::String(path));
17706        }
17707        Some(tmp)
17708    } else {
17709        None
17710    };
17711
17712    let req: car_inference::TranscribeRequest =
17713        serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17714    let _permit = state.admission.acquire().await;
17715    let result = engine.transcribe(req).await.map_err(|e| e.to_string())?;
17716    serde_json::to_value(&result).map_err(|e| e.to_string())
17717}
17718
17719/// Synthesize speech. By default writes to `output_path` on the
17720/// daemon's filesystem; when `return_b64: true` (or no `output_path`
17721/// was supplied) the result also includes an `audio_b64` field with
17722/// the rendered bytes inline so cross-sandbox callers can avoid
17723/// filesystem coordination. Closes Parslee-ai/car-releases#31.
17724async fn handle_synthesize(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17725    use base64::Engine as _;
17726    let engine = get_inference_engine(state);
17727
17728    let mut params = msg.params.clone();
17729    let return_b64 = params
17730        .as_object_mut()
17731        .and_then(|m| m.remove("return_b64"))
17732        .and_then(|v| v.as_bool())
17733        .unwrap_or(false);
17734    let no_output_path = params
17735        .as_object()
17736        .map(|m| !m.contains_key("output_path"))
17737        .unwrap_or(true);
17738
17739    let req: car_inference::SynthesizeRequest =
17740        serde_json::from_value(params).map_err(|e| format!("invalid params: {}", e))?;
17741    let _permit = state.admission.acquire().await;
17742    let result = engine.synthesize(req).await.map_err(|e| e.to_string())?;
17743    let mut value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
17744
17745    // Inline the bytes when the caller asked for them OR when no
17746    // output_path was specified (typical sandbox-crossing case —
17747    // they didn't pick a path because they have no shared one).
17748    if return_b64 || no_output_path {
17749        let bytes = std::fs::read(&result.audio_path).map_err(|e| {
17750            format!(
17751                "synthesize: failed to read rendered audio at {}: {e}",
17752                result.audio_path
17753            )
17754        })?;
17755        let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
17756        if let Some(obj) = value.as_object_mut() {
17757            obj.insert("audio_b64".to_string(), Value::String(encoded));
17758        }
17759    }
17760    Ok(value)
17761}
17762
17763/// Provision the managed speech runtime. Returns its root path as a JSON
17764/// string, mirroring the embedded `prepare_speech_runtime` shape — the same
17765/// root `speech.health` reports. Can take minutes on a fresh machine (venv +
17766/// pip). Not a success signal on Apple Silicon, where the runtime is a
17767/// fallback behind the native MLX backends and a failed bootstrap degrades
17768/// rather than errors; read `speech.health.runtime.installed` (car#649).
17769async fn handle_speech_prepare(state: &ServerState) -> Result<Value, String> {
17770    let engine = get_inference_engine(state);
17771    let status = engine
17772        .prepare_speech_runtime()
17773        .await
17774        .map_err(|e| e.to_string())?;
17775    serde_json::to_value(&status).map_err(|e| e.to_string())
17776}
17777
17778/// Adaptive route decision for a prompt — returns the routing
17779/// JSON the FFI's `route_model` returns.
17780async fn handle_models_route(msg: &JsonRpcMessage, state: &ServerState) -> Result<Value, String> {
17781    let prompt = msg
17782        .params
17783        .get("prompt")
17784        .and_then(|v| v.as_str())
17785        .ok_or("missing 'prompt' parameter")?;
17786    // Optional routing intent — notably `exclude_models` for
17787    // adversarial-reviewer separation (car#358). Absent = no intent.
17788    let intent: Option<car_inference::IntentHint> = match msg.params.get("intent") {
17789        Some(v) if !v.is_null() => {
17790            Some(serde_json::from_value(v.clone()).map_err(|e| format!("invalid 'intent': {e}"))?)
17791        }
17792        _ => None,
17793    };
17794    let engine = get_inference_engine(state);
17795    let decision = engine.route_adaptive_with_intent(prompt, intent).await;
17796    serde_json::to_value(&decision).map_err(|e| e.to_string())
17797}
17798
17799/// Model performance profiles snapshot.
17800async fn handle_models_stats(state: &ServerState) -> Result<Value, String> {
17801    let engine = get_inference_engine(state);
17802    let profiles = engine.export_profiles().await;
17803    Ok(serde_json::json!({ "profiles": models_stats_view(&profiles) }))
17804}
17805
17806/// `outcomes.scoreboard` — the persistent, OUTCOME-DENOMINATED scoreboard
17807/// folded from the durable outcome ledger: per-model cost-per-success,
17808/// tokens-per-success, and success-rate, plus the deployment headline
17809/// `overall_usd_per_success`. Unlike `models.stats` (the live in-memory
17810/// profiles), this reads the cross-session ledger so it survives restart and is
17811/// de-biased by the pending-sweep. The dollars-per-correct-outcome view the
17812/// "results, not KPIs" thesis is legible in.
17813async fn handle_outcomes_scoreboard(state: &ServerState) -> Result<Value, String> {
17814    let engine = get_inference_engine(state);
17815    let scoreboard = engine.outcome_scoreboard();
17816    serde_json::to_value(&scoreboard).map_err(|e| e.to_string())
17817}
17818
17819/// Build the `models.stats` per-profile view. The raw `ModelProfile`
17820/// serializes counts (`success_count`/`fail_count`) but not the
17821/// `success_rate` / `avg_latency_ms` the tool schema promised — so the schema
17822/// was a contract lie (#335). Rather than drop fields (the CLI's cost join
17823/// needs `total_output_tokens` etc.), serialize the full profile and *add* the
17824/// two derived values. `success_rate` is `number | null` (`null` until
17825/// something resolves — never the router's 0.5 prior), consistent with the
17826/// `concierge.status` health contract.
17827fn models_stats_view(profiles: &[car_inference::ModelProfile]) -> Vec<Value> {
17828    profiles
17829        .iter()
17830        .map(|p| {
17831            let mut v = serde_json::to_value(p).expect("ModelProfile is infallibly serializable");
17832            if let Some(obj) = v.as_object_mut() {
17833                obj.insert(
17834                    "success_rate".into(),
17835                    serde_json::json!(p.success_rate_resolved()),
17836                );
17837                obj.insert(
17838                    "avg_latency_ms".into(),
17839                    serde_json::json!(p.avg_latency_ms()),
17840                );
17841            }
17842            v
17843        })
17844        .collect()
17845}
17846
17847#[cfg(test)]
17848mod models_stats_view_tests {
17849    use super::models_stats_view;
17850
17851    #[test]
17852    fn success_rate_is_null_until_resolved_then_the_real_ratio() {
17853        let mut unmeasured = car_inference::ModelProfile::new("m-new".into());
17854        unmeasured.total_calls = 5; // calls logged, nothing resolved yet
17855
17856        let mut measured = car_inference::ModelProfile::new("m-seen".into());
17857        measured.total_calls = 4;
17858        measured.success_count = 3;
17859        measured.fail_count = 1;
17860
17861        let view = models_stats_view(&[unmeasured, measured]);
17862
17863        // Never-measured: explicit null, NOT a fabricated 0.5.
17864        assert!(view[0]["success_rate"].is_null());
17865        assert_eq!(view[0]["success_count"], 0);
17866        assert_eq!(view[0]["total_calls"], 5);
17867
17868        // Measured: the real resolved ratio, and the declared field names.
17869        assert_eq!(view[1]["success_rate"], 0.75);
17870        assert_eq!(view[1]["fail_count"], 1);
17871        assert_eq!(view[1]["model_id"], "m-seen");
17872    }
17873
17874    #[test]
17875    fn view_is_non_lossy_and_round_trips_into_model_profile() {
17876        // Regression: the CLI (`car models stats`) deserializes the daemon
17877        // payload back into `Vec<ModelProfile>` for its cost join. The derived
17878        // view must therefore stay a *superset* of ModelProfile — adding
17879        // success_rate/avg_latency_ms, never dropping fields like
17880        // total_output_tokens. Guards the wire shape against silent breakage.
17881        let mut p = car_inference::ModelProfile::new("m".into());
17882        p.total_calls = 2;
17883        p.success_count = 1;
17884        p.total_output_tokens = 4096;
17885
17886        let view = models_stats_view(&[p]);
17887        assert_eq!(view[0]["avg_latency_ms"], 0.0); // derived field present
17888        let round: Vec<car_inference::ModelProfile> =
17889            serde_json::from_value(serde_json::Value::Array(view)).expect("round-trips");
17890        assert_eq!(round[0].total_output_tokens, 4096, "field not dropped");
17891        assert_eq!(round[0].model_id, "m");
17892    }
17893}
17894
17895#[derive(Deserialize)]
17896#[serde(rename_all = "camelCase")]
17897struct OutcomesResolvePendingParams {
17898    /// Flat `(trace_id, success, confidence, output)` tuples from the
17899    /// caller. Same shape `car-reason`'s session produces from its
17900    /// `ActionOutcome` vector. Daemon side runs the inference rules
17901    /// and writes resolved outcomes back to the shared tracker.
17902    action_results: Vec<(String, bool, f64, String)>,
17903}
17904
17905/// `outcomes.resolve_pending` — write inferred outcomes back to the
17906/// shared engine's `OutcomeTracker` (Parslee-ai/car#189 follow-up).
17907///
17908/// Symmetric to the in-process path
17909/// `ReasoningInferenceHandle::record_inferred_outcomes` on
17910/// `InferenceEngine`: takes the per-action result tuples the
17911/// reasoning session produces, runs
17912/// `OutcomeTracker::infer_outcomes_from_action_sequence` to convert
17913/// them into `InferredOutcome` records, and calls
17914/// `resolve_pending_from_signals` under the tracker write lock. The
17915/// learning loop that adjusts routing decisions therefore survives
17916/// daemon-routed reasoning runs (previously a best-effort no-op on
17917/// the daemon side).
17918///
17919/// Returns `{ recorded: N }` where N is the number of action results
17920/// the caller passed. The tracker doesn't surface how many of those
17921/// actually had pending entries to resolve; that count would require
17922/// expanding the tracker API and isn't load-bearing for any caller
17923/// yet.
17924async fn handle_outcomes_resolve_pending(
17925    req: &JsonRpcMessage,
17926    state: &ServerState,
17927) -> Result<Value, String> {
17928    let params: OutcomesResolvePendingParams =
17929        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
17930    let engine = get_inference_engine(state);
17931    let mut tracker = engine.outcome_tracker.write().await;
17932    let inferred = tracker.infer_outcomes_from_action_sequence(&params.action_results);
17933    tracker.resolve_pending_from_signals(inferred);
17934    Ok(serde_json::json!({ "recorded": params.action_results.len() }))
17935}
17936
17937/// Per-session event log size.
17938async fn handle_events_count(session: &crate::session::ClientSession) -> Result<Value, String> {
17939    let n = session.runtime.log.lock().await.len();
17940    Ok(Value::from(n as u64))
17941}
17942
17943/// `events.query` — structured audit query over the session event log
17944/// (EPIC G / G2). Params are a `car_eventlog::EventQuery`
17945/// (`kinds`/`action_id`/`proposal_id`/`since`/`until`/`data_matches`/`limit`);
17946/// returns the matching events, most-recent-first. `ActionFailed.data` exposes
17947/// `params_digest`, `expected_effects`, and normalized `error_class`
17948/// (`timeout|rejected_by_policy|tool_error|validation|unknown`) without raw
17949/// parameters; `ActionSucceeded.data` carries the first two. Answers "who ran
17950/// what tool when, and which approvals applied" against the SessionScope /
17951/// PermissionDecision / ApprovalRecorded / action trail.
17952async fn handle_events_query(
17953    msg: &JsonRpcMessage,
17954    session: &crate::session::ClientSession,
17955) -> Result<Value, String> {
17956    let query: car_eventlog::EventQuery = if msg.params.is_null() {
17957        Default::default()
17958    } else {
17959        serde_json::from_value(msg.params.clone())
17960            .map_err(|e| format!("invalid events.query params: {e}"))?
17961    };
17962    let log = session.runtime.log.lock().await;
17963    let events = log.query(&query);
17964    Ok(serde_json::json!({
17965        "count": events.len(),
17966        "events": events,
17967    }))
17968}
17969
17970/// Which NLP primitive a `nlp.*` request selects (EPIC F / F4).
17971enum NlpOp {
17972    IdentifyLanguage,
17973    Tokenize,
17974    ExtractEntities,
17975}
17976
17977/// `nlp.identify_language` / `nlp.tokenize` / `nlp.extract_entities` — run a
17978/// stateless NLP primitive over `params.text` (EPIC F / F4). Backed by Apple's
17979/// NaturalLanguage framework on macOS and a pure-Rust fallback elsewhere, so
17980/// non-macOS agents get a real (if lighter) result instead of an error. Every
17981/// result carries `backend` (`"apple"`|`"fallback"`).
17982fn handle_nlp(req: &JsonRpcMessage, op: NlpOp) -> Result<Value, String> {
17983    let text = req
17984        .params
17985        .get("text")
17986        .and_then(|v| v.as_str())
17987        .ok_or("missing 'text'")?;
17988    let json = match op {
17989        NlpOp::IdentifyLanguage => car_ffi_common::nlp::identify_language(text),
17990        NlpOp::Tokenize => car_ffi_common::nlp::tokenize_words(text),
17991        NlpOp::ExtractEntities => car_ffi_common::nlp::extract_named_entities(text),
17992    }?;
17993    serde_json::from_str(&json).map_err(|e| format!("nlp result parse: {e}"))
17994}
17995
17996/// `metrics.summary` — live operational rollup of the session event stream
17997/// (EPIC G / G1): success/error rate, cost, tokens, avg latency, approvals,
17998/// gate rejections, policy violations, and the per-agent cost breakdown. One
17999/// call renders the whole live picture for a host dashboard.
18000async fn handle_metrics_summary(session: &crate::session::ClientSession) -> Result<Value, String> {
18001    let log = session.runtime.log.lock().await;
18002    // summarize_log (not a bare fold over events()) so `cumulative_cost_usd`
18003    // carries the monotonic counter that survives retention trims (G1).
18004    let summary = car_eventlog::summarize_log(&log);
18005    serde_json::to_value(&summary).map_err(|e| format!("serialize metrics.summary: {e}"))
18006}
18007
18008/// `metrics.alerts` — evaluate the live summary against operational thresholds
18009/// (EPIC G / G1). Params are `AlertThresholds`
18010/// (`max_cost_usd`/`max_error_rate`/`max_avg_latency_ms`/
18011/// `max_goals_ungrounded`/`min_actions`).
18012/// Returns `{ summary, alerts }`; every fired alert is also written to the
18013/// operational log (`tracing::warn`) so an operator sees it out-of-band. A
18014/// synthetic cost overage fires the `cost_overage` alert; an ungrounded goal
18015/// verifier pass can fire `goal_ungrounded`.
18016async fn handle_metrics_alerts(
18017    req: &JsonRpcMessage,
18018    session: &crate::session::ClientSession,
18019) -> Result<Value, String> {
18020    let thresholds: car_eventlog::AlertThresholds = if req.params.is_null() {
18021        Default::default()
18022    } else {
18023        serde_json::from_value(req.params.clone())
18024            .map_err(|e| format!("invalid metrics.alerts thresholds: {e}"))?
18025    };
18026    let log = session.runtime.log.lock().await;
18027    // The budget alert reads the monotonic cumulative cost, not a fold over
18028    // the retention-trimmed window — a trim must never un-fire it (G1).
18029    let summary = car_eventlog::summarize_log(&log);
18030    let alerts = car_eventlog::evaluate_alerts(&summary, &thresholds);
18031    for a in &alerts {
18032        tracing::warn!(target: "car::alerts", kind = ?a.kind, "operational alert: {}", a.message);
18033    }
18034    Ok(serde_json::json!({ "summary": summary, "alerts": alerts }))
18035}
18036
18037/// `events.cost_by_agent` — per-agent token/cost report (EPIC G / G3),
18038/// folded from the session log's `InferenceMetered` events by their `agent`
18039/// field. Answers "how much did each agent cost this run" (e.g. Researcher
18040/// $2, Coordinator $0.5). Provenance (which tool/workflow) is queryable via
18041/// `events.query {data_matches:{agent}}`.
18042async fn handle_events_cost_by_agent(
18043    session: &crate::session::ClientSession,
18044) -> Result<Value, String> {
18045    let log = session.runtime.log.lock().await;
18046    let report = log.cost_by_agent();
18047    serde_json::to_value(&report).map_err(|e| format!("serialize cost_by_agent: {e}"))
18048}
18049
18050/// `events.retention` — get or set the session event log's auto-retention
18051/// policy (EPIC G / G2). With a `policy` object (`{max_events, max_age_secs}`)
18052/// installs it and immediately enforces the age bound, returning how many
18053/// events were reaped; with no `policy` returns the current policy. Bounds the
18054/// log by size (auto on append) and age (on this call).
18055async fn handle_events_retention(
18056    msg: &JsonRpcMessage,
18057    session: &crate::session::ClientSession,
18058) -> Result<Value, String> {
18059    let mut log = session.runtime.log.lock().await;
18060    if let Some(p) = msg.params.get("policy") {
18061        let policy: car_eventlog::RetentionPolicy = serde_json::from_value(p.clone())
18062            .map_err(|e| format!("invalid retention policy: {e}"))?;
18063        let removed = log.enforce_retention(&policy, chrono::Utc::now());
18064        log.set_retention(Some(policy.clone()));
18065        Ok(serde_json::json!({ "policy": policy, "removed": removed }))
18066    } else {
18067        Ok(serde_json::json!({ "policy": log.retention() }))
18068    }
18069}
18070
18071/// `events.chain.enable` — turn on tamper-evident hash chaining for the
18072/// session event log (EPIC A / A9). Every event appended from now on is
18073/// linked to its predecessor by a content hash. Opt-in and idempotent;
18074/// existing events stay byte-identical.
18075async fn handle_events_chain_enable(
18076    session: &crate::session::ClientSession,
18077) -> Result<Value, String> {
18078    session.runtime.enable_event_log_hash_chaining().await;
18079    Ok(serde_json::json!({ "ok": true }))
18080}
18081
18082/// `events.chain.verify` — verify the session event log's tamper-evidence
18083/// chain (EPIC A / A9). Returns `{verified: n}` (chained events verified) or
18084/// `{tampered_at: i}` naming the first event whose hash/linkage doesn't
18085/// match — an interior edit, deletion, or reordering. Head/tail truncation
18086/// is not detectable (no anchored head hash).
18087async fn handle_events_chain_verify(
18088    session: &crate::session::ClientSession,
18089) -> Result<Value, String> {
18090    match session.runtime.verify_event_log_chain().await {
18091        Ok(n) => Ok(serde_json::json!({ "verified": n })),
18092        Err(i) => Ok(serde_json::json!({ "tampered_at": i })),
18093    }
18094}
18095
18096async fn handle_events_stats(session: &crate::session::ClientSession) -> Result<Value, String> {
18097    let stats = session.runtime.log.lock().await.stats();
18098    serde_json::to_value(stats).map_err(|e| e.to_string())
18099}
18100
18101#[derive(Deserialize)]
18102#[serde(rename_all = "camelCase")]
18103struct EventsTruncateParams {
18104    #[serde(default)]
18105    max_events: Option<usize>,
18106    #[serde(default)]
18107    max_spans: Option<usize>,
18108}
18109
18110async fn handle_events_truncate(
18111    msg: &JsonRpcMessage,
18112    session: &crate::session::ClientSession,
18113) -> Result<Value, String> {
18114    let params: EventsTruncateParams =
18115        serde_json::from_value(msg.params.clone()).unwrap_or(EventsTruncateParams {
18116            max_events: None,
18117            max_spans: None,
18118        });
18119    let mut log = session.runtime.log.lock().await;
18120    let removed_events = params
18121        .max_events
18122        .map(|max| log.truncate_events_keep_last(max))
18123        .unwrap_or(0);
18124    let removed_spans = params
18125        .max_spans
18126        .map(|max| log.truncate_spans_keep_last(max))
18127        .unwrap_or(0);
18128    let stats = log.stats();
18129    Ok(serde_json::json!({
18130        "removedEvents": removed_events,
18131        "removedSpans": removed_spans,
18132        "stats": stats,
18133    }))
18134}
18135
18136async fn handle_events_clear(session: &crate::session::ClientSession) -> Result<Value, String> {
18137    let mut log = session.runtime.log.lock().await;
18138    let removed = log.clear();
18139    Ok(serde_json::json!({ "removed": removed, "stats": log.stats() }))
18140}
18141
18142// ---------------------------------------------------------------------------
18143// Agent run tracing — run lifecycle (U1).
18144//
18145// `runs.start` brackets the beginning of an agent run: it mints a durable
18146// `run_id`, resolves the owning `agent_id`, tags it as the session's
18147// current run BEFORE responding (so the U2 per-turn recorder always reads
18148// the run_id the bracket set — KTD3), records `RunStarted`, and returns
18149// `{ run_id, agent_id }`. `runs.complete` records the terminal
18150// `AgentOutcome` and acks. On a mid-run disconnect with no
18151// `runs.complete`, `remove_session` sweeps the run to `Incomplete` after
18152// a short grace window (R5) so a healthy in-flight complete is not raced.
18153// ---------------------------------------------------------------------------
18154
18155/// Resolve the owning `agent_id` for a `runs.start` call, in priority
18156/// order: the connection's bound agent (`session.auth {agent_id}`), the
18157/// `CAR_AGENT_ID` env (supervised one-shot), then a deterministic id
18158/// synthesized from the supplied `agent_name` (unsupervised one-shot, so
18159/// `run_scenarios.py` runs still record). Returns `Err` only when none of
18160/// these resolve — a run with no identity has no durable key and is
18161/// rejected rather than recorded under an ambiguous id.
18162async fn resolve_run_agent_id(
18163    session: &crate::session::ClientSession,
18164    req: &car_proto::RunStartRequest,
18165) -> Result<String, String> {
18166    // 1. The connection's bound agent from `session.auth {agent_id}` is
18167    //    AUTHORITATIVE and wins over any caller-supplied `agent_id` (FIX 5).
18168    //    A bound session must not be able to record a run under a DIFFERENT
18169    //    agent — that's trace forgery (writing into another agent's run
18170    //    history). A matching explicit param is fine (redundant); a
18171    //    mismatching one is rejected so the forgery attempt is loud rather
18172    //    than silently misattributed.
18173    if let Some(bound) = session.agent_id.lock().await.clone() {
18174        if let Some(id) = req.agent_id.as_deref() {
18175            let id = id.trim();
18176            if !id.is_empty() && id != bound {
18177                return Err(format!(
18178                    "runs.start `agent_id` (`{id}`) does not match this session's bound \
18179                     agent: a bound connection can only record runs under its own agent"
18180                ));
18181            }
18182        }
18183        return Ok(bound);
18184    }
18185    // 2. UNBOUND session only: an explicit `agent_id` param wins. This is
18186    //    the one-shot path that legitimately names its agent (no binding to
18187    //    derive from).
18188    if let Some(id) = req.agent_id.as_deref() {
18189        let id = id.trim();
18190        if !id.is_empty() {
18191            return Ok(id.to_string());
18192        }
18193    }
18194    // 3. The supervisor-injected env for supervised one-shot runs.
18195    if let Ok(env_id) = std::env::var("CAR_AGENT_ID") {
18196        let env_id = env_id.trim().to_string();
18197        if !env_id.is_empty() {
18198            return Ok(env_id);
18199        }
18200    }
18201    // 4. Unsupervised one-shot fallback: synthesize a deterministic id
18202    //    from the agent's name so the run still has a stable key.
18203    if let Some(name) = req.agent_name.as_deref() {
18204        if let Some(synth) = synthesize_agent_id(name) {
18205            return Ok(synth);
18206        }
18207    }
18208    Err(
18209        "runs.start could not resolve an agent_id: no `agent_id` param, no bound \
18210         session.auth {agent_id}, no CAR_AGENT_ID env, and no usable `agent_name` \
18211         to synthesize one from"
18212            .to_string(),
18213    )
18214}
18215
18216/// Deterministically derive a stable agent id from a display name for
18217/// the unsupervised one-shot path. Lowercases, collapses any run of
18218/// non-alphanumeric characters to a single `-`, trims leading/trailing
18219/// `-`, and prefixes `name:` so a synthesized id is recognizable as a
18220/// name-derived fallback (and never collides with a real supervised
18221/// agent id, which has no `name:` prefix). Returns `None` when the name
18222/// has no alphanumeric content to key on.
18223fn synthesize_agent_id(name: &str) -> Option<String> {
18224    let mut slug = String::new();
18225    let mut prev_dash = false;
18226    for ch in name.chars() {
18227        if ch.is_ascii_alphanumeric() {
18228            slug.push(ch.to_ascii_lowercase());
18229            prev_dash = false;
18230        } else if !prev_dash {
18231            slug.push('-');
18232            prev_dash = true;
18233        }
18234    }
18235    let slug = slug.trim_matches('-');
18236    if slug.is_empty() {
18237        return None;
18238    }
18239    Some(format!("name:{slug}"))
18240}
18241
18242async fn release_new_run_reservation_after_error(
18243    state: &ServerState,
18244    session: &crate::session::ClientSession,
18245    requested: &crate::session::RunMeta,
18246    error: String,
18247) -> String {
18248    match state
18249        .release_unpersisted_run_reservation(session, requested)
18250        .await
18251    {
18252        Ok(()) => error,
18253        Err(cleanup) => format!("{error}; failed to release new run reservation: {cleanup}"),
18254    }
18255}
18256
18257async fn handle_runs_start(
18258    req: &JsonRpcMessage,
18259    session: &crate::session::ClientSession,
18260    state: &Arc<ServerState>,
18261) -> Result<Value, String> {
18262    let params: car_proto::RunStartRequest = serde_json::from_value(req.params.clone())
18263        .map_err(|e| format!("runs.start requires {{ intent, agent_id?, agent_name?, outcome_description?, idempotency_key? }}: {e}"))?;
18264    if params.intent.trim().is_empty() {
18265        return Err("runs.start requires a non-empty `intent`".to_string());
18266    }
18267
18268    let agent_id = resolve_run_agent_id(session, &params).await?;
18269    let _run_guard = session.run_lifecycle_guard.lock().await;
18270    let idempotency_key = params
18271        .idempotency_key
18272        .as_deref()
18273        .map(str::trim)
18274        .filter(|k| !k.is_empty())
18275        .map(str::to_string);
18276    let run_id = idempotency_key.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
18277    let current_run = session.current_run_id.lock().await.clone();
18278    let requested_meta = crate::session::RunMeta {
18279        run_id: run_id.clone(),
18280        agent_id: agent_id.clone(),
18281        client_id: session.client_id.clone(),
18282        active_client_id: session.client_id.clone(),
18283        resume_predecessor_client_id: None,
18284        resume_lease: None,
18285        intent: params.intent.clone(),
18286        outcome_description: params.outcome_description.clone(),
18287        started_at: chrono::Utc::now(),
18288        termination: None,
18289        ended_at: None,
18290        turns: Vec::new(),
18291        start_committed: false,
18292        pending_terminal: None,
18293        cancellation_pending: None,
18294        cancellation_receipt: None,
18295        trace_corruption: None,
18296        durability_generation: 0,
18297    };
18298    if let Some(current) = current_run.as_deref() {
18299        if let Some(pending) = state
18300            .run_store
18301            .pending_proposal(current)
18302            .map_err(|error| proposal_durability_quarantine(current, "finalization", &error))?
18303        {
18304            return Err(format!(
18305                "proposal finalization pending for run_id `{current}` / original submission `{}`",
18306                pending.original_proposal_id
18307            ));
18308        }
18309        if let Some(marker) = state
18310            .run_store
18311            .execution_marker(current)
18312            .map_err(|error| proposal_durability_quarantine(current, "execution", &error))?
18313        {
18314            return Err(format!(
18315                "proposal execution outcome unknown for run_id `{current}` / original submission `{}`; CAR will not replace or terminalize the quarantined run",
18316                marker.original_proposal_id
18317            ));
18318        }
18319    }
18320    if let Some(pending) = state
18321        .run_store
18322        .pending_proposal(&run_id)
18323        .map_err(|error| proposal_durability_quarantine(&run_id, "finalization", &error))?
18324    {
18325        return Err(format!(
18326            "proposal finalization pending for run_id `{run_id}` / original submission `{}`",
18327            pending.original_proposal_id
18328        ));
18329    }
18330    if let Some(marker) = state
18331        .run_store
18332        .execution_marker(&run_id)
18333        .map_err(|error| proposal_durability_quarantine(&run_id, "execution", &error))?
18334    {
18335        return Err(format!(
18336            "proposal execution outcome unknown for run_id `{run_id}` / original submission `{}`; CAR will not reopen or redispatch automatically",
18337            marker.original_proposal_id
18338        ));
18339    }
18340    // Reserve first so a stale/conflicting target is rejected without
18341    // terminalizing the current run. Every later error before the candidate
18342    // owns a journal/RunStore boundary releases a New reservation exactly.
18343    let reservation = state.reserve_run(requested_meta.clone()).await?;
18344    let reservation_is_new = matches!(&reservation, crate::session::RunReservation::New);
18345
18346    if let crate::session::RunReservation::Existing(existing) = &reservation {
18347        if let Some(error) = &existing.trace_corruption {
18348            return Err(error.clone());
18349        }
18350        if existing.is_terminal() {
18351            return Err(format!(
18352                "{} run `{run_id}` is already terminal",
18353                car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18354            ));
18355        }
18356    }
18357
18358    let current_resolution: Result<Option<Value>, String> = async {
18359        if let Some(current) = current_run.as_deref() {
18360            if current == run_id {
18361                let (owner, terminal, committed, pending) = state
18362                    .run_lifecycle_state(current)
18363                    .await
18364                    .ok_or_else(|| {
18365                        format!("reserved run `{current}` is absent from CAR registry")
18366                    })?;
18367                if owner != session.client_id || terminal {
18368                    return Err(format!(
18369                        "{} run `{current}` is not resumable by this client",
18370                        car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18371                    ));
18372                }
18373                session.require_run_journal_binding(current).await?;
18374                if pending {
18375                    let ended = state
18376                        .prepare_run_incomplete(current)
18377                        .await
18378                        .ok_or_else(|| {
18379                            format!("active run `{current}` could not resume its pending terminal")
18380                        })?;
18381                    session.append_run_terminal_event(&ended).await?;
18382                    state.commit_run_completion(&ended).await?;
18383                    session.clear_run_journal_binding(current).await?;
18384                    *session.current_run_id.lock().await = None;
18385                    return Err(format!(
18386                        "{} run `{current}` is already terminal",
18387                        car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18388                    ));
18389                }
18390                if committed {
18391                    return serde_json::to_value(car_proto::RunStartResponse {
18392                        run_id: current.to_string(),
18393                        agent_id: agent_id.clone(),
18394                        client_id: session.client_id.clone(),
18395                    })
18396                    .map(Some)
18397                    .map_err(|e| e.to_string());
18398                }
18399            } else {
18400                let (owner, terminal, committed, _pending) = state
18401                    .run_lifecycle_state(current)
18402                    .await
18403                    .ok_or_else(|| {
18404                        format!("active run `{current}` is absent from CAR registry")
18405                    })?;
18406                if owner != session.client_id {
18407                    return Err(format!(
18408                        "{} active run `{current}` belongs to another client",
18409                        car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18410                    ));
18411                }
18412                session.require_run_journal_binding(current).await?;
18413                if !terminal {
18414                    if !committed {
18415                        return Err(format!(
18416                            "{} active run `{current}` has an unacknowledged durable start; retry its exact idempotency key and occurrence-defining payload before starting `{run_id}`",
18417                            car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18418                        ));
18419                    }
18420                    // Reuse an already prepared terminal so a retry finishes
18421                    // its exact outcome rather than overwriting it.
18422                    let ended = state
18423                        .prepare_run_incomplete(current)
18424                        .await
18425                        .ok_or_else(|| {
18426                            format!("active run `{current}` could not be terminalized")
18427                        })?;
18428                    session.append_run_terminal_event(&ended).await?;
18429                    state.commit_run_completion(&ended).await?;
18430                }
18431                session.clear_run_journal_binding(current).await?;
18432                *session.current_run_id.lock().await = None;
18433            }
18434        }
18435        Ok(None)
18436    }
18437    .await;
18438    match current_resolution {
18439        Ok(Some(response)) => return Ok(response),
18440        Ok(None) => {}
18441        Err(error) => {
18442            if reservation_is_new {
18443                return Err(release_new_run_reservation_after_error(
18444                    state,
18445                    session,
18446                    &requested_meta,
18447                    error,
18448                )
18449                .await);
18450            }
18451            return Err(error);
18452        }
18453    }
18454
18455    let binding_preexisting = current_run.as_deref() == Some(run_id.as_str());
18456    if !binding_preexisting {
18457        if let Err(error) = session.bind_run_journal(&run_id).await {
18458            if reservation_is_new {
18459                return Err(release_new_run_reservation_after_error(
18460                    state,
18461                    session,
18462                    &requested_meta,
18463                    error,
18464                )
18465                .await);
18466            }
18467            return Err(error);
18468        }
18469    }
18470
18471    *session.current_run_id.lock().await = Some(run_id.clone());
18472    let start_result = async {
18473        let started = state.persist_run_start(&run_id).await?;
18474        session.append_run_started_event(&started).await?;
18475        state.commit_run_start(&run_id).await
18476    }
18477    .await;
18478    if let Err(error) = start_result {
18479        match state.run_store.run_started(&run_id) {
18480            Ok(None) => {
18481                match state
18482                    .reconcile_or_release_unacknowledged_start(session, &run_id)
18483                    .await
18484                {
18485                    Ok(true) => return Err(error),
18486                    Ok(false) => {
18487                        return Err(format!(
18488                            "{error}; run `{run_id}` became durable during rollback; retry the exact idempotency key and occurrence-defining payload"
18489                        ));
18490                    }
18491                    Err(cleanup) => {
18492                        return Err(format!(
18493                            "{error}; failed to release rejected start reservation: {cleanup}"
18494                        ));
18495                    }
18496                }
18497            }
18498            Ok(Some(_)) => {
18499                return Err(format!(
18500                    "{error}; {} run `{run_id}` has an unacknowledged durable start; retry the exact idempotency key and occurrence-defining payload",
18501                    car_proto::RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX
18502                ));
18503            }
18504            Err(read_error) => {
18505                return Err(format!(
18506                    "{error}; durable start state for run `{run_id}` is unreadable ({read_error}); retry only the exact idempotency key and occurrence-defining payload"
18507                ));
18508            }
18509        }
18510    }
18511
18512    serde_json::to_value(car_proto::RunStartResponse {
18513        run_id,
18514        agent_id,
18515        client_id: session.client_id.clone(),
18516    })
18517    .map_err(|e| e.to_string())
18518}
18519
18520async fn handle_runs_resume(
18521    req: &JsonRpcMessage,
18522    session: &crate::session::ClientSession,
18523    state: &Arc<ServerState>,
18524) -> Result<Value, String> {
18525    if !session_has_capability(session, car_proto::RUNS_RESUME_CAPABILITY) {
18526        return Err(format!(
18527            "runs.resume requires negotiated capability `{}`",
18528            car_proto::RUNS_RESUME_CAPABILITY
18529        ));
18530    }
18531    let params: car_proto::RunResumeRequest = serde_json::from_value(req.params.clone())
18532        .map_err(|error| format!("runs.resume requires exactly {{ run_id }}: {error}"))?;
18533    if params.run_id.trim().is_empty() {
18534        return Err("runs.resume run_id must be non-empty".into());
18535    }
18536    if !session.authenticated.load(Ordering::Acquire) {
18537        return Err("runs.resume requires an authenticated agent session".into());
18538    }
18539    let agent_id = session
18540        .agent_id
18541        .lock()
18542        .await
18543        .clone()
18544        .ok_or_else(|| "runs.resume requires session.auth with an agent_id".to_string())?;
18545
18546    let _run_guard = session.run_lifecycle_guard.lock().await;
18547    let current = session.current_run_id.lock().await.clone();
18548    if current
18549        .as_deref()
18550        .is_some_and(|current| current != params.run_id)
18551    {
18552        return Err(format!(
18553            "runs.resume cannot replace this socket's active run `{}`",
18554            current.expect("checked as present")
18555        ));
18556    }
18557
18558    let binding_preexisting = current.as_deref() == Some(params.run_id.as_str());
18559    if !binding_preexisting {
18560        session.bind_run_journal(&params.run_id).await?;
18561    }
18562    let resumed = match state
18563        .resume_run(&params.run_id, &agent_id, &session.client_id)
18564        .await
18565    {
18566        Ok(binding) => binding,
18567        Err(error) => {
18568            if !binding_preexisting {
18569                let _ = session.clear_run_journal_binding(&params.run_id).await;
18570            }
18571            return Err(error);
18572        }
18573    };
18574    *session.current_run_id.lock().await = Some(params.run_id.clone());
18575
18576    serde_json::to_value(car_proto::RunResumeResponse {
18577        run_id: resumed.run_id,
18578        agent_id: resumed.agent_id,
18579        client_id: resumed.active_client_id,
18580        resumed_from_client_id: resumed.resumed_from_client_id,
18581    })
18582    .map_err(|error| error.to_string())
18583}
18584
18585async fn handle_runs_complete(
18586    req: &JsonRpcMessage,
18587    session: &crate::session::ClientSession,
18588    state: &Arc<ServerState>,
18589) -> Result<Value, String> {
18590    let params: car_proto::RunCompleteRequest = serde_json::from_value(req.params.clone())
18591        .map_err(|e| format!("runs.complete requires {{ run_id, outcome }}: {e}"))?;
18592
18593    let _run_guard = session.run_lifecycle_guard.lock().await;
18594    let current = session.current_run_id.lock().await.clone();
18595    if current.as_deref() != Some(params.run_id.as_str()) {
18596        return Err(format!(
18597            "runs.complete run_id `{}` is not this client's active run",
18598            params.run_id
18599        ));
18600    }
18601    let (owner_client, _terminal, start_committed, _completion_pending) = state
18602        .run_lifecycle_state(&params.run_id)
18603        .await
18604        .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18605    let (durable_client, active_client) = state
18606        .run_owner_binding(&params.run_id)
18607        .await
18608        .ok_or_else(|| format!("unknown run_id `{}`", params.run_id))?;
18609    if owner_client != session.client_id || !start_committed {
18610        return Err("runs.complete client/run binding mismatch".into());
18611    }
18612    if active_client != session.client_id {
18613        return Err("runs.complete active owner mismatch".into());
18614    }
18615    session.require_run_journal_binding(&params.run_id).await?;
18616    if let Some(pending) = state
18617        .run_store
18618        .pending_proposal(&params.run_id)
18619        .map_err(|error| proposal_durability_quarantine(&params.run_id, "finalization", &error))?
18620    {
18621        return Err(format!(
18622            "proposal finalization pending for run_id `{}` / original submission `{}`",
18623            params.run_id, pending.original_proposal_id
18624        ));
18625    }
18626    if let Some(marker) = state
18627        .run_store
18628        .execution_marker(&params.run_id)
18629        .map_err(|error| proposal_durability_quarantine(&params.run_id, "execution", &error))?
18630    {
18631        return Err(format!(
18632            "proposal execution outcome unknown for run_id `{}` / original submission `{}`; runs.complete is quarantined",
18633            params.run_id, marker.original_proposal_id
18634        ));
18635    }
18636
18637    let termination = car_proto::RunTermination::Outcome {
18638        status: params.outcome.status,
18639        outcome: params.outcome.clone(),
18640    };
18641    let ended = state
18642        .prepare_run_completion_for_active_owner(
18643            &params.run_id,
18644            termination,
18645            Some(&session.client_id),
18646        )
18647        .await?;
18648    if durable_client == session.client_id {
18649        session.append_run_terminal_event(&ended).await?;
18650    } else {
18651        session
18652            .append_resumed_run_terminal_event(&ended, &durable_client)
18653            .await?;
18654    }
18655    state.commit_run_completion(&ended).await?;
18656    let completion_digest = ended
18657        .completion_digest
18658        .clone()
18659        .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18660
18661    // Both durable surfaces are now acknowledged. Only at this point may the
18662    // authenticated journal/session binding be released; every earlier error
18663    // leaves it intact so the owner can retry the same prepared terminal.
18664    session.clear_run_journal_binding(&params.run_id).await?;
18665    *session.current_run_id.lock().await = None;
18666
18667    serde_json::to_value(car_proto::RunCompleteResponse {
18668        run_id: params.run_id,
18669        ok: true,
18670        completion_digest,
18671    })
18672    .map_err(|e| e.to_string())
18673}
18674
18675#[derive(Serialize)]
18676struct RunCancelReceiptPreimage<'a> {
18677    receipt_version: u32,
18678    run_id: &'a str,
18679    idempotency_key: &'a str,
18680    reason_digest: &'a str,
18681    principal: &'a str,
18682    status: car_proto::RunCancellationStatus,
18683    terminal_digest: &'a Option<String>,
18684    action_id: &'a Option<String>,
18685    request_id: &'a Option<String>,
18686}
18687
18688fn run_cancel_receipt(
18689    requested: &car_proto::RunCancellationRequested,
18690    status: car_proto::RunCancellationStatus,
18691    terminal_digest: Option<String>,
18692) -> Result<car_proto::RunCancelResponse, String> {
18693    let preimage = RunCancelReceiptPreimage {
18694        receipt_version: requested.receipt_version,
18695        run_id: &requested.run_id,
18696        idempotency_key: &requested.idempotency_key,
18697        reason_digest: &requested.reason_digest,
18698        principal: &requested.principal,
18699        status,
18700        terminal_digest: &terminal_digest,
18701        action_id: &requested.action_id,
18702        request_id: &requested.request_id,
18703    };
18704    let receipt_digest = car_proto::canonical_sha256(&preimage)?;
18705    Ok(car_proto::RunCancelResponse {
18706        receipt_version: requested.receipt_version,
18707        run_id: requested.run_id.clone(),
18708        idempotency_key: requested.idempotency_key.clone(),
18709        reason_digest: requested.reason_digest.clone(),
18710        principal: requested.principal.clone(),
18711        status,
18712        terminal_digest,
18713        action_id: requested.action_id.clone(),
18714        request_id: requested.request_id.clone(),
18715        receipt_digest,
18716    })
18717}
18718
18719fn run_cancel_terminal_receipt(
18720    params: &car_proto::RunCancelRequest,
18721    reason_digest: &str,
18722    principal: &str,
18723    ended: &car_proto::RunEnded,
18724) -> Result<car_proto::RunCancelResponse, String> {
18725    let terminal_digest = ended
18726        .completion_digest
18727        .clone()
18728        .ok_or_else(|| "terminal run is missing completion_digest".to_string())?;
18729    let (requested, status) = match &ended.termination {
18730        car_proto::RunTermination::Cancelled { cancellation }
18731            if cancellation.idempotency_key == params.idempotency_key
18732                && cancellation.reason_digest == reason_digest
18733                && cancellation.principal == principal =>
18734        {
18735            (
18736                car_proto::RunCancellationRequested {
18737                    receipt_version: cancellation.receipt_version,
18738                    run_id: cancellation.run_id.clone(),
18739                    idempotency_key: cancellation.idempotency_key.clone(),
18740                    reason_digest: cancellation.reason_digest.clone(),
18741                    principal: cancellation.principal.clone(),
18742                    action_id: cancellation.action_id.clone(),
18743                    request_id: cancellation.request_id.clone(),
18744                },
18745                car_proto::RunCancellationStatus::CancelledConfirmed,
18746            )
18747        }
18748        car_proto::RunTermination::Cancelled { cancellation } => (
18749            car_proto::RunCancellationRequested {
18750                receipt_version: 1,
18751                run_id: params.run_id.clone(),
18752                idempotency_key: params.idempotency_key.clone(),
18753                reason_digest: reason_digest.to_string(),
18754                principal: principal.to_string(),
18755                action_id: cancellation.action_id.clone(),
18756                request_id: cancellation.request_id.clone(),
18757            },
18758            car_proto::RunCancellationStatus::AlreadyTerminal,
18759        ),
18760        _ => (
18761            car_proto::RunCancellationRequested {
18762                receipt_version: 1,
18763                run_id: params.run_id.clone(),
18764                idempotency_key: params.idempotency_key.clone(),
18765                reason_digest: reason_digest.to_string(),
18766                principal: principal.to_string(),
18767                action_id: None,
18768                request_id: None,
18769            },
18770            car_proto::RunCancellationStatus::AlreadyTerminal,
18771        ),
18772    };
18773    run_cancel_receipt(&requested, status, Some(terminal_digest))
18774}
18775
18776async fn recover_host_orphan_cancellation(
18777    is_host: bool,
18778    state: &Arc<ServerState>,
18779    owning_agent: &str,
18780    params: &car_proto::RunCancelRequest,
18781    reason_digest: &str,
18782    principal: &str,
18783) -> Result<Value, String> {
18784    if !is_host {
18785        return Err("run cancellation control is unavailable".into());
18786    }
18787    let requested = car_proto::RunCancellationRequested {
18788        receipt_version: 1,
18789        run_id: params.run_id.clone(),
18790        idempotency_key: params.idempotency_key.clone(),
18791        reason_digest: reason_digest.to_string(),
18792        principal: principal.to_string(),
18793        action_id: None,
18794        request_id: None,
18795    };
18796    // No owner session remains to acknowledge a stop. The honest terminal is
18797    // therefore Incomplete, not Cancelled: the host closes the orphan without
18798    // claiming CAR confirmed an external child process stopped.
18799    let terminal_digest =
18800        crate::session::run_completion_digest(&car_proto::RunTermination::Incomplete)?;
18801    let response = run_cancel_receipt(
18802        &requested,
18803        car_proto::RunCancellationStatus::AlreadyTerminal,
18804        Some(terminal_digest),
18805    )?;
18806    state
18807        .persist_recovered_run_cancellation_result(owning_agent, &response)
18808        .await?;
18809    serde_json::to_value(response).map_err(|error| error.to_string())
18810}
18811
18812async fn handle_runs_cancel(
18813    req: &JsonRpcMessage,
18814    session: &crate::session::ClientSession,
18815    state: &Arc<ServerState>,
18816) -> Result<Value, String> {
18817    if !session_has_capability(session, car_proto::RUNS_CANCEL_CAPABILITY) {
18818        return Err(format!(
18819            "runs.cancel requires negotiated capability `{}`",
18820            car_proto::RUNS_CANCEL_CAPABILITY
18821        ));
18822    }
18823    let params: car_proto::RunCancelRequest =
18824        serde_json::from_value(req.params.clone()).map_err(|error| {
18825            format!("runs.cancel requires exactly {{ run_id, idempotency_key, reason }}: {error}")
18826        })?;
18827    if params.run_id.trim().is_empty()
18828        || params.idempotency_key.trim().is_empty()
18829        || params.reason.trim().is_empty()
18830    {
18831        return Err("runs.cancel fields must be non-empty".into());
18832    }
18833    if params.idempotency_key.len() > 128 {
18834        return Err("runs.cancel idempotency_key exceeds 128 bytes".into());
18835    }
18836    if params.reason.len() > 1024 {
18837        return Err("runs.cancel reason exceeds 1024 bytes".into());
18838    }
18839
18840    let meta = state.run_meta(&params.run_id).await;
18841    let owning_agent = meta
18842        .as_ref()
18843        .map(|meta| meta.agent_id.clone())
18844        .or_else(|| state.run_store.agent_for_run(&params.run_id));
18845    let Some(owning_agent) = owning_agent else {
18846        return Err("run not found or not authorized".into());
18847    };
18848    if authorize_run_access(session, state, &owning_agent)
18849        .await
18850        .is_err()
18851    {
18852        return Err("run not found or not authorized".into());
18853    }
18854    // `runs.cancel` remains owner-callable for a live run; only the orphan
18855    // recovery branch below requires host authority. Pass the resolved value
18856    // into that helper so the RPC manifest does not classify the whole method
18857    // as host-only by following a conditional helper call.
18858    let is_host = session.is_host.load(Ordering::Acquire);
18859    let principal = if is_host {
18860        "host".to_string()
18861    } else {
18862        format!("agent:{owning_agent}")
18863    };
18864    let reason_digest = format!("{:x}", Sha256::digest(params.reason.as_bytes()));
18865
18866    let records = state
18867        .run_store
18868        .get_run_trace_for_checked(&owning_agent, &params.run_id)
18869        .map_err(|error| format!("runs.cancel trace read failed: {error}"))?
18870        .ok_or_else(|| "run not found or not authorized".to_string())?;
18871    let existing_result = records.iter().find_map(|record| match record {
18872        car_proto::RunRecord::CancellationResult(result) => Some(result),
18873        _ => None,
18874    });
18875    if let Some(existing) = existing_result {
18876        if existing.idempotency_key != params.idempotency_key
18877            || existing.reason_digest != reason_digest
18878            || existing.principal != principal
18879        {
18880            return Err("run already has a different cancellation request".into());
18881        }
18882    }
18883    if let Some(ended) = records.iter().find_map(|record| match record {
18884        car_proto::RunRecord::Ended(ended) => Some(ended),
18885        _ => None,
18886    }) {
18887        return serde_json::to_value(run_cancel_terminal_receipt(
18888            &params,
18889            &reason_digest,
18890            &principal,
18891            ended,
18892        )?)
18893        .map_err(|error| error.to_string());
18894    }
18895
18896    let existing_requested = records.iter().find_map(|record| match record {
18897        car_proto::RunRecord::CancellationRequested(requested) => Some(requested),
18898        _ => None,
18899    });
18900    if let Some(existing) = existing_result {
18901        let owner_session = match meta.as_ref() {
18902            Some(meta) => state
18903                .sessions
18904                .lock()
18905                .await
18906                .get(&meta.active_client_id)
18907                .cloned(),
18908            None => None,
18909        };
18910        match owner_session {
18911            Some(owner_session) => {
18912                state
18913                    .persist_run_cancellation_result(&owner_session, existing)
18914                    .await?;
18915            }
18916            None => {
18917                state
18918                    .persist_recovered_run_cancellation_result(&owning_agent, existing)
18919                    .await?;
18920            }
18921        }
18922        return serde_json::to_value(existing).map_err(|error| error.to_string());
18923    }
18924    let Some(meta) = meta else {
18925        let Some(requested) = existing_requested else {
18926            return recover_host_orphan_cancellation(
18927                is_host,
18928                state,
18929                &owning_agent,
18930                &params,
18931                &reason_digest,
18932                &principal,
18933            )
18934            .await;
18935        };
18936        if requested.idempotency_key != params.idempotency_key
18937            || requested.reason_digest != reason_digest
18938            || requested.principal != principal
18939        {
18940            return Err("run already has a different cancellation request".into());
18941        }
18942        if is_host
18943            && requested.principal == "host"
18944            && requested.action_id.is_none()
18945            && requested.request_id.is_none()
18946        {
18947            return recover_host_orphan_cancellation(
18948                is_host,
18949                state,
18950                &owning_agent,
18951                &params,
18952                &reason_digest,
18953                &principal,
18954            )
18955            .await;
18956        }
18957        let response = run_cancel_receipt(
18958            requested,
18959            car_proto::RunCancellationStatus::TerminationUnconfirmed,
18960            None,
18961        )?;
18962        state
18963            .persist_recovered_run_cancellation_result(&owning_agent, &response)
18964            .await?;
18965        return serde_json::to_value(response).map_err(|error| error.to_string());
18966    };
18967    let owner_session = state
18968        .sessions
18969        .lock()
18970        .await
18971        .get(&meta.active_client_id)
18972        .cloned();
18973    let Some(owner_session) = owner_session else {
18974        let Some(requested) = existing_requested else {
18975            return recover_host_orphan_cancellation(
18976                is_host,
18977                state,
18978                &owning_agent,
18979                &params,
18980                &reason_digest,
18981                &principal,
18982            )
18983            .await;
18984        };
18985        if requested.idempotency_key != params.idempotency_key
18986            || requested.reason_digest != reason_digest
18987            || requested.principal != principal
18988        {
18989            return Err("run already has a different cancellation request".into());
18990        }
18991        if is_host
18992            && requested.principal == "host"
18993            && requested.action_id.is_none()
18994            && requested.request_id.is_none()
18995        {
18996            return recover_host_orphan_cancellation(
18997                is_host,
18998                state,
18999                &owning_agent,
19000                &params,
19001                &reason_digest,
19002                &principal,
19003            )
19004            .await;
19005        }
19006        let response = run_cancel_receipt(
19007            requested,
19008            car_proto::RunCancellationStatus::TerminationUnconfirmed,
19009            None,
19010        )?;
19011        state
19012            .persist_recovered_run_cancellation_result(&owning_agent, &response)
19013            .await?;
19014        return serde_json::to_value(response).map_err(|error| error.to_string());
19015    };
19016
19017    // Serialize against runs.complete for the opening session. Re-read the
19018    // durable trace after acquiring the guard: a completion that won before
19019    // cancellation must return the closed already_terminal vocabulary, while
19020    // a cancellation that wins keeps completion blocked until its durable
19021    // terminal or quarantine receipt is committed.
19022    let _run_guard = owner_session.run_lifecycle_guard.lock().await;
19023    let guarded_records = state
19024        .run_store
19025        .get_run_trace_for_checked(&owning_agent, &params.run_id)
19026        .map_err(|error| format!("runs.cancel guarded trace read failed: {error}"))?
19027        .ok_or_else(|| "run not found or not authorized".to_string())?;
19028    if let Some(ended) = guarded_records.iter().find_map(|record| match record {
19029        car_proto::RunRecord::Ended(ended) => Some(ended),
19030        _ => None,
19031    }) {
19032        return serde_json::to_value(run_cancel_terminal_receipt(
19033            &params,
19034            &reason_digest,
19035            &principal,
19036            ended,
19037        )?)
19038        .map_err(|error| error.to_string());
19039    }
19040
19041    let mut active: Vec<(String, String)> = owner_session
19042        .channel
19043        .active_actions
19044        .lock()
19045        .await
19046        .iter()
19047        .map(|(request_id, action_id)| (request_id.clone(), action_id.clone()))
19048        .collect();
19049    active.sort();
19050    let active_inferences = owner_session
19051        .inference_control
19052        .active_for_run(&params.run_id);
19053    let (request_id, action_id) = active
19054        .first()
19055        .map(|(request_id, action_id)| (Some(request_id.clone()), Some(action_id.clone())))
19056        .or_else(|| {
19057            active_inferences.first().map(|(inference_id, request_id)| {
19058                (
19059                    request_id.clone().or_else(|| Some(inference_id.clone())),
19060                    None,
19061                )
19062            })
19063        })
19064        .unwrap_or((None, None));
19065    let requested = if let Some(existing) = existing_requested {
19066        if existing.idempotency_key != params.idempotency_key
19067            || existing.reason_digest != reason_digest
19068            || existing.principal != principal
19069        {
19070            return Err("run already has a different cancellation request".into());
19071        }
19072        existing.clone()
19073    } else {
19074        car_proto::RunCancellationRequested {
19075            receipt_version: 1,
19076            run_id: params.run_id.clone(),
19077            idempotency_key: params.idempotency_key.clone(),
19078            reason_digest,
19079            principal,
19080            action_id,
19081            request_id,
19082        }
19083    };
19084    state
19085        .persist_run_cancellation_requested(&owner_session, &requested)
19086        .await?;
19087
19088    for (request_id, action_id) in &active {
19089        crate::session::write_tool_cancel(
19090            &owner_session.channel,
19091            request_id.clone(),
19092            action_id.clone(),
19093            params.reason.clone(),
19094        )
19095        .await;
19096    }
19097    let mut inference_stopped = true;
19098    for (inference_id, _) in &active_inferences {
19099        let _pending = owner_session
19100            .inference_control
19101            .try_acquire_control()
19102            .map_err(|error| format!("runs.cancel inference control rejected: {error:?}"))?;
19103        let status = owner_session
19104            .inference_control
19105            .control(
19106                inference_id,
19107                crate::inference_control::ControlCause::Cancel,
19108                exact_backend_termination_ack(inference_id),
19109            )
19110            .await;
19111        if !matches!(
19112            status,
19113            car_proto::InferenceControlStatus::CancelledConfirmed
19114                | car_proto::InferenceControlStatus::AlreadyTerminal
19115        ) {
19116            inference_stopped = false;
19117        }
19118    }
19119    let callbacks_stopped = if active.is_empty() {
19120        true
19121    } else {
19122        tokio::time::timeout(std::time::Duration::from_secs(5), async {
19123            loop {
19124                let pending = owner_session.channel.pending.lock().await;
19125                let any_active = active
19126                    .iter()
19127                    .any(|(request_id, _)| pending.contains_key(request_id));
19128                drop(pending);
19129                if !any_active {
19130                    break;
19131                }
19132                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
19133            }
19134        })
19135        .await
19136        .is_ok()
19137    };
19138    let stopped = callbacks_stopped && inference_stopped;
19139
19140    if !stopped {
19141        let response = run_cancel_receipt(
19142            &requested,
19143            car_proto::RunCancellationStatus::TerminationUnconfirmed,
19144            None,
19145        )?;
19146        state
19147            .persist_run_cancellation_result(&owner_session, &response)
19148            .await?;
19149        return serde_json::to_value(response).map_err(|error| error.to_string());
19150    }
19151
19152    let cancellation = car_proto::RunCancellationIdentity {
19153        receipt_version: requested.receipt_version,
19154        run_id: requested.run_id.clone(),
19155        idempotency_key: requested.idempotency_key.clone(),
19156        reason_digest: requested.reason_digest.clone(),
19157        principal: requested.principal.clone(),
19158        action_id: requested.action_id.clone(),
19159        request_id: requested.request_id.clone(),
19160    };
19161    let ended = state
19162        .prepare_run_completion(
19163            &params.run_id,
19164            car_proto::RunTermination::Cancelled { cancellation },
19165        )
19166        .await?;
19167    owner_session.append_run_terminal_event(&ended).await?;
19168    state.commit_run_completion(&ended).await?;
19169    let terminal_digest = ended
19170        .completion_digest
19171        .clone()
19172        .ok_or_else(|| "cancelled terminal is missing completion_digest".to_string())?;
19173    let response = run_cancel_receipt(
19174        &requested,
19175        car_proto::RunCancellationStatus::CancelledConfirmed,
19176        Some(terminal_digest),
19177    )?;
19178    owner_session
19179        .append_run_cancellation_result_event(&response)
19180        .await?;
19181    owner_session
19182        .clear_run_journal_binding(&params.run_id)
19183        .await?;
19184    let mut current = owner_session.current_run_id.lock().await;
19185    if current.as_deref() == Some(params.run_id.as_str()) {
19186        *current = None;
19187    }
19188    serde_json::to_value(response).map_err(|error| error.to_string())
19189}
19190
19191/// Daemon-owned size invariants for `runs.record_turns` (R8a). The daemon
19192/// owns turn size the way it owns the turn index (`record_run_turns`,
19193/// session.rs FIX 6) and the way `A2uiLimits` owns surface size — it never
19194/// trusts the client's truncation for data that lands on disk.
19195
19196/// Per-string-field byte cap. Each turn's `prompt` and `output` text and
19197/// any oversized `parameters` string is truncated to this with a marker
19198/// before the turn is appended. Sized to match U3's client-side cap with
19199/// headroom (the client caps at ~8 KB; the daemon's 16 KB ceiling catches
19200/// a misbehaving or older client without trimming healthy turns).
19201const RECORD_TURN_FIELD_CAP_BYTES: usize = 16 * 1024;
19202
19203/// Marker appended to a daemon-truncated field so a reader can tell the
19204/// value was cut, not merely short.
19205const RECORD_TURN_TRUNC_MARKER: &str = "…[truncated]";
19206
19207/// Aggregate per-turn byte cap. The per-field 16 KiB cap bounds each leaf
19208/// string, but a turn can carry MANY leaves (e.g. a `parameters` array of
19209/// thousands of sub-cap strings), so the per-field cap alone does not
19210/// bound the persisted JSONL line. After the typed decode each candidate
19211/// turn is serialized; one whose encoded form exceeds this cap gets its
19212/// heavy free-form fields (`parameters`, `output`, `prompt`) replaced
19213/// whole with [`RECORD_TURN_OVERSIZE_MARKER`] — see
19214/// [`enforce_turn_byte_cap`].
19215const RECORD_TURN_MAX_BYTES: usize = 256 * 1024;
19216
19217/// Replacement value for a free-form field dropped whole by the aggregate
19218/// per-turn cap ([`RECORD_TURN_MAX_BYTES`]) — distinct from the per-field
19219/// marker so a reader can tell WHICH invariant fired.
19220const RECORD_TURN_OVERSIZE_MARKER: &str = "…[truncated: turn exceeded 256 KiB]";
19221
19222/// Max turns in a single `runs.record_turns` batch. A larger batch is a
19223/// client bug (the agent flushes a bounded queue per cycle); reject it
19224/// loudly rather than admit an unbounded append under one lock.
19225const RECORD_TURNS_MAX_BATCH: usize = 256;
19226
19227/// Per-run turn ceiling — a runaway-loop backstop sized well above any
19228/// healthy main-agent-only cycle (tens of turns), never a trimmer. This is
19229/// a true hard cap: a batch that would take the run PAST this many
19230/// recorded turns is refused whole with `dropped: "run_turn_limit"`, which
19231/// the agent treats as stop-sending. (Losing the straddling batch is
19232/// correct — at this depth the run is a runaway, not a healthy cycle.)
19233///
19234/// The AUTHORITATIVE enforcement lives in
19235/// [`crate::session::ServerState::record_run_turns`], under the `runs` lock,
19236/// because the dispatcher spawns a task per frame and the handler's
19237/// pre-check below reads a lock-free snapshot that pipelined batches all
19238/// pass before any append lands (ADV-1). The handler pre-check is kept only
19239/// as a fast path that avoids a doomed decode+append for the obvious
19240/// already-over case. We alias the single source of truth here.
19241const RECORD_TURNS_RUN_CEILING: usize = crate::session::RECORD_TURNS_RUN_CEILING;
19242
19243/// Truncate one string in place to the daemon field-byte cap, appending
19244/// [`RECORD_TURN_TRUNC_MARKER`] when it was cut. Cuts on a UTF-8 char
19245/// boundary at or below the cap so the result is always valid.
19246fn truncate_turn_string(s: &mut String) {
19247    if s.len() > RECORD_TURN_FIELD_CAP_BYTES {
19248        // Find the largest char boundary ≤ the cap.
19249        let mut end = RECORD_TURN_FIELD_CAP_BYTES;
19250        while end > 0 && !s.is_char_boundary(end) {
19251            end -= 1;
19252        }
19253        s.truncate(end);
19254        s.push_str(RECORD_TURN_TRUNC_MARKER);
19255    }
19256}
19257
19258/// Truncate a JSON string value in place to the daemon field-byte cap,
19259/// appending [`RECORD_TURN_TRUNC_MARKER`] when it was cut. Non-string
19260/// values are recursively walked (arrays/objects) so a large blob nested
19261/// inside `parameters`/`output` is still bounded; numbers/bools are left
19262/// as-is.
19263fn truncate_turn_value(value: &mut Value) {
19264    match value {
19265        Value::String(s) => truncate_turn_string(s),
19266        Value::Array(items) => {
19267            for item in items.iter_mut() {
19268                truncate_turn_value(item);
19269            }
19270        }
19271        Value::Object(map) => {
19272            for (_k, v) in map.iter_mut() {
19273                truncate_turn_value(v);
19274            }
19275        }
19276        _ => {}
19277    }
19278}
19279
19280/// Headroom (bytes) subtracted from [`RECORD_TURN_MAX_BYTES`] when MEASURING
19281/// a turn, to cover the re-stamp slack (ADV-4). The cap is enforced here with
19282/// the turn's placeholder `index: 0`, but `record_run_turns` re-stamps the
19283/// index to the live append position under the `runs` lock — up to a
19284/// 4-digit-plus number near the run ceiling. So `"index":0` (9 bytes) can
19285/// grow to e.g. `"index":1999` (12 bytes): a turn measured at exactly the cap
19286/// would persist a few bytes OVER it. 16 bytes is comfortably above the
19287/// worst-case index-digit growth (the ceiling is 2000 → 4 digits → +3 bytes),
19288/// with margin to spare.
19289const RECORD_TURN_REINDEX_HEADROOM: usize = 16;
19290
19291/// Effective measurement cap: a turn is treated as "within the persisted
19292/// cap" only when its encoded length (with the placeholder index) is at or
19293/// below this, leaving [`RECORD_TURN_REINDEX_HEADROOM`] for the live re-stamp.
19294const RECORD_TURN_MEASURE_CAP: usize = RECORD_TURN_MAX_BYTES - RECORD_TURN_REINDEX_HEADROOM;
19295
19296/// Encoded size of a turn as it would persist (one JSONL line, minus the
19297/// trailing newline). A turn that fails to serialize reports `usize::MAX`
19298/// so the cap path treats it as oversized rather than waving it through.
19299fn turn_encoded_len(turn: &car_proto::RunTurn) -> usize {
19300    serde_json::to_vec(turn).map_or(usize::MAX, |v| v.len())
19301}
19302
19303/// Enforce the aggregate per-turn byte cap ([`RECORD_TURN_MAX_BYTES`]) on
19304/// a decoded turn — the invariant the per-field pass cannot give: the
19305/// per-field cap bounds each leaf string, but not how many leaves a turn
19306/// carries, so a `parameters` array of thousands of sub-cap strings (or a
19307/// multi-MB `tool` scalar the per-field pass never visits) would still
19308/// produce an unbounded JSONL line.
19309///
19310/// When the encoded turn exceeds the cap, the heavy free-form fields
19311/// (`parameters`, `output`, `prompt`) are replaced WHOLE with
19312/// [`RECORD_TURN_OVERSIZE_MARKER`] (no partial salvage — at this size the
19313/// content is a misbehaving client, not signal). If the turn is somehow
19314/// STILL over cap, the remaining string scalars the per-field pass never
19315/// truncates (`tool`, `policy_rejected.rule`/`param`) are field-capped too.
19316///
19317/// Returns `true` when the turn is within the persisted cap (keep it) and
19318/// `false` when it is STILL over cap after both passes (ADV-5: reject/drop
19319/// it — a serialize failure or a future free-form field this pass doesn't
19320/// bound). The caller drops a rejected turn with a logged reason and a
19321/// structured drop rather than appending an over-cap line. The measurement
19322/// uses [`RECORD_TURN_MEASURE_CAP`] (ADV-4 re-stamp headroom).
19323pub(crate) fn enforce_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
19324    // ADV-5: exhaustively destructure so a future new `RunTurn` field is a
19325    // COMPILE error here, not a silently-unbounded persisted line. Every
19326    // field below is either a free-form string/JSON we bound, or a
19327    // number/C-like enum that is bounded by construction.
19328    let car_proto::RunTurn {
19329        index: _,               // usize — bounded; daemon re-stamps it anyway
19330        proposal_id: _,         // semantic identity — never truncate; reject if oversized
19331        action_id: _,           // semantic identity — never truncate; reject if oversized
19332        action_status: _,       // closed enum — bounded by construction
19333        action_duration_ms: _,  // f64 — bounded by construction
19334        action_completed_at: _, // timestamp — bounded by construction
19335        depends_on: _,          // semantic graph — never truncate; reject if oversized
19336        state_dependencies: _,  // semantic graph — never truncate; reject if oversized
19337        prompt: _,              // bounded below (replaced whole when oversize)
19338        tool: _,                // bounded below (field-capped when oversize)
19339        parameters: _,          // bounded below (replaced whole when oversize)
19340        output: _,              // bounded below (replaced whole when oversize)
19341        cli_outcome: _,         // C-like enum + i64 — bounded
19342        verifier_verdict: _,    // C-like enum — bounded
19343        policy_rejected: _,     // bounded below (field-capped when oversize)
19344    } = turn;
19345
19346    if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
19347        return true;
19348    }
19349    // Replace the big three free-form fields whole.
19350    if !turn.parameters.is_null() {
19351        turn.parameters = Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string());
19352    }
19353    if turn.output.is_some() {
19354        turn.output = Some(Value::String(RECORD_TURN_OVERSIZE_MARKER.to_string()));
19355    }
19356    if turn.prompt.is_some() {
19357        turn.prompt = Some(RECORD_TURN_OVERSIZE_MARKER.to_string());
19358    }
19359    if turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP {
19360        return true;
19361    }
19362    // Still over cap: the bytes live in the scalars the per-field pass
19363    // never visits. Field-cap them.
19364    if let Some(tool) = turn.tool.as_mut() {
19365        truncate_turn_string(tool);
19366    }
19367    if let Some(pr) = turn.policy_rejected.as_mut() {
19368        truncate_turn_string(&mut pr.rule);
19369        if let Some(param) = pr.param.as_mut() {
19370            truncate_turn_string(param);
19371        }
19372    }
19373    // ADV-5: if the turn is STILL over cap, every free-form field has already
19374    // been replaced/capped, so the only ways to land here are a serialize
19375    // failure (`turn_encoded_len` → usize::MAX) or a future free-form field
19376    // the exhaustive destructure above will have forced us to handle. Reject
19377    // the turn (hard, not a release-compiled-out `debug_assert`) so an
19378    // unbounded line never reaches disk.
19379    turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19380}
19381
19382/// Bound a proposal-produced turn without changing its authenticated action
19383/// parameters. Output/prompt may be compacted, but if the immutable proposal
19384/// metadata itself cannot fit the durable line cap, finalization fails closed.
19385pub(crate) fn enforce_proposal_turn_byte_cap(turn: &mut car_proto::RunTurn) -> bool {
19386    let parameters = turn.parameters.clone();
19387    if !enforce_turn_byte_cap(turn) {
19388        return false;
19389    }
19390    turn.parameters = parameters;
19391    turn_encoded_len(turn) <= RECORD_TURN_MEASURE_CAP
19392}
19393
19394/// Build a non-fatal `runs.record_turns` rejection: `ok: false`, nothing
19395/// appended, with the machine-readable `dropped` reason the agent treats
19396/// as "stop sending for this run".
19397fn record_turns_dropped(run_id: &str, reason: &str) -> Result<Value, String> {
19398    serde_json::to_value(car_proto::RunRecordTurnsResponse {
19399        run_id: run_id.to_string(),
19400        base_index: 0,
19401        count: 0,
19402        ok: false,
19403        dropped: Some(reason.to_string()),
19404    })
19405    .map_err(|e| e.to_string())
19406}
19407
19408/// `runs.record_turns {run_id, turns}` — WS-only batch append of
19409/// client-narrated turns (feedback-agent A2UI/runs plan, U1).
19410///
19411/// The turn source is an out-of-pipeline agent whose work happens inside
19412/// its own subprocess (e.g. a `claude -p` resolver), invisible to the
19413/// proposal-path recorder. It builds full `RunTurn`s itself and pushes
19414/// them here in batches; the daemon appends them through the SAME
19415/// [`ServerState::record_run_turns`] the proposal recorder uses, so the
19416/// index re-stamp (FIX 6), JSONL persist, and `runs.trace.event` fanout
19417/// are byte-identical — this handler never re-implements locking, append,
19418/// or fanout.
19419///
19420/// Authorization (KTD): write access is the OWNING-agent binding only. The
19421/// read path's host-token gate must NOT apply here — a host-token or
19422/// unbound session writing another agent's turns is trace forgery. An
19423/// unknown run AND an unauthorized run collapse to the same uniform
19424/// `dropped: "run_not_found"` (mirroring `handle_runs_subscribe`'s FIX 3
19425/// not-found), so the response is never an existence/owner oracle.
19426///
19427/// Daemon-owned size invariants (R8a): per-field truncation, an aggregate
19428/// per-turn byte cap ([`enforce_turn_byte_cap`]), a batch-size cap (hard
19429/// error above), and a per-run turn ceiling (`run_turn_limit` — a hard
19430/// cap: no batch is accepted that would take the run past the ceiling).
19431/// Run existence/terminality is pre-checked to return a distinguishable
19432/// `ok: false` + reason rather than `record_run_turns`'s silent
19433/// zero-count drop; the benign TOCTOU (a run going terminal between the
19434/// check and the append) still drops silently inside `record_run_turns`,
19435/// matching existing recorder semantics.
19436async fn handle_runs_record_turns(
19437    req: &JsonRpcMessage,
19438    session: &crate::session::ClientSession,
19439    state: &Arc<ServerState>,
19440) -> Result<Value, String> {
19441    // Parse leniently: the wire `RunTurn` may omit `verifier_verdict`
19442    // (client-narrated turns default it to `not_run`) and carries a
19443    // client-supplied `index` we ignore. We deserialize each turn from a
19444    // generic object so we can inject the verdict default before the typed
19445    // `RunTurn` deserialize, without adding a serde default to the shared
19446    // proto type (the proposal recorder always sets the field).
19447    let raw_run_id = req
19448        .params
19449        .get("run_id")
19450        .and_then(Value::as_str)
19451        .map(str::to_string);
19452    let run_id = match raw_run_id {
19453        Some(id) if !id.trim().is_empty() => id,
19454        _ => return Err("runs.record_turns requires { run_id, turns: [RunTurn] }".to_string()),
19455    };
19456
19457    let raw_turns =
19458        match req.params.get("turns").and_then(Value::as_array) {
19459            Some(arr) => arr,
19460            None => return Err(
19461                "runs.record_turns requires { run_id, turns: [RunTurn] }: `turns` must be an array"
19462                    .to_string(),
19463            ),
19464        };
19465    if raw_turns.is_empty() {
19466        return Err("runs.record_turns requires a non-empty `turns` array".to_string());
19467    }
19468    if raw_turns.len() > RECORD_TURNS_MAX_BATCH {
19469        return Err(format!(
19470            "runs.record_turns batch too large: {} turns exceeds the per-call cap of {}",
19471            raw_turns.len(),
19472            RECORD_TURNS_MAX_BATCH
19473        ));
19474    }
19475
19476    // Decode each wire turn into a typed RunTurn, defaulting an absent
19477    // `verifier_verdict` to `not_run` and truncating its oversized string
19478    // fields to the daemon cap (R8a) — the daemon does not trust client
19479    // truncation for data that lands on disk.
19480    let mut turns: Vec<car_proto::RunTurn> = Vec::with_capacity(raw_turns.len());
19481    for (i, raw) in raw_turns.iter().enumerate() {
19482        let mut obj = raw.clone();
19483        match obj.as_object_mut() {
19484            Some(map) => {
19485                // The daemon owns the index (re-stamped under the `runs`
19486                // lock in `record_run_turns`), so a client-narrated turn's
19487                // `index` is genuinely IGNORED — the protocol doc says so.
19488                // ADV-6: overwrite it UNCONDITIONALLY with a placeholder `0`
19489                // (not `entry().or_insert`), so a present-but-invalid value
19490                // (`-1`, `"5"`, `1.5`) can't fail the typed `usize` decode and
19491                // reject the whole batch. The placeholder is re-stamped to the
19492                // live append position on append regardless.
19493                map.insert("index".to_string(), Value::Number(0.into()));
19494                map.entry("verifier_verdict")
19495                    .or_insert_with(|| Value::String("not_run".to_string()));
19496                // Truncate the heavy free-text / blob fields before the
19497                // typed decode so the bounded values are what gets stored.
19498                if let Some(p) = map.get_mut("prompt") {
19499                    truncate_turn_value(p);
19500                }
19501                if let Some(o) = map.get_mut("output") {
19502                    truncate_turn_value(o);
19503                }
19504                if let Some(params) = map.get_mut("parameters") {
19505                    truncate_turn_value(params);
19506                }
19507            }
19508            None => {
19509                return Err(format!(
19510                    "runs.record_turns requires {{ run_id, turns: [RunTurn] }}: \
19511                     turn {i} is not an object"
19512                ));
19513            }
19514        }
19515        let mut turn: car_proto::RunTurn = serde_json::from_value(obj).map_err(|e| {
19516            format!("runs.record_turns requires {{ run_id, turns: [RunTurn] }}: turn {i}: {e}")
19517        })?;
19518        // Aggregate cap (R8a): the per-field pass bounds each leaf, not the
19519        // sum of leaves — bound the whole encoded turn before it is stored.
19520        // ADV-5: a turn that is STILL over cap after the replace+field-cap
19521        // pass (a serialize failure, or a future free-form field) is REJECTED,
19522        // not waved through. Drop the whole batch with a structured reason and
19523        // log it server-side — an unbounded line must never reach disk.
19524        if !enforce_turn_byte_cap(&mut turn) {
19525            tracing::warn!(
19526                run_id = %run_id,
19527                turn = i,
19528                "runs.record_turns: turn could not be bounded under the per-turn byte cap; dropping batch"
19529            );
19530            return record_turns_dropped(&run_id, "turn_too_large");
19531        }
19532        turns.push(turn);
19533    }
19534
19535    // Resolve the run's owning agent: in-memory registry (live run) first,
19536    // disk fallback for a run whose RunMeta isn't in this process — the
19537    // `handle_runs_subscribe` pattern. Unknown run → uniform not-found.
19538    //
19539    // ADV-2: read the run HEADER only — agent, terminal, count, corruption —
19540    // not the whole `RunMeta`. The previous `run_meta` clone copied the
19541    // entire `turns` buffer (full prompts + CLI output) under the global
19542    // `runs` lock on every batch RPC; the handler needs only those three
19543    // facts.
19544    let header = state.run_header(&run_id).await;
19545    let owning_agent = match &header {
19546        Some((agent_id, _terminal, _len, _corruption)) => agent_id.clone(),
19547        None => match state.run_store.agent_for_run(&run_id) {
19548            Some(a) => a,
19549            // FIX 3 uniform not-found: an unknown run and an unauthorized
19550            // run are indistinguishable to the caller.
19551            None => return record_turns_dropped(&run_id, "run_not_found"),
19552        },
19553    };
19554
19555    // WRITE authz (KTD): owning-agent binding ONLY. A host-token or unbound
19556    // session must NOT be able to write another agent's turns — that is
19557    // forgery, the inverse of the read path's host-token allowance. An
19558    // unauthorized write collapses to the SAME `run_not_found` as an
19559    // unknown run (no existence/owner oracle); logged server-side only.
19560    let bound = session.agent_id.lock().await.clone();
19561    if bound.as_deref() != Some(owning_agent.as_str()) {
19562        tracing::debug!(
19563            run_id = %run_id,
19564            owning_agent = %owning_agent,
19565            client_id = %session.client_id,
19566            "runs.record_turns denied (not the owning agent); returning uniform not-found"
19567        );
19568        return record_turns_dropped(&run_id, "run_not_found");
19569    }
19570
19571    if let Some((_agent_id, _terminal, _len, Some(error))) = &header {
19572        return Err(error.clone());
19573    }
19574
19575    // Pre-check terminality and the ceiling as a FAST PATH only. These read
19576    // the lock-free `run_header` snapshot, so a write against an obviously
19577    // closed / already-over-ceiling run skips the decode+append. They are
19578    // NOT authoritative: the dispatcher spawns a task per frame, so pipelined
19579    // batches can each pass this snapshot before any of them appends (ADV-1).
19580    // The authoritative terminal+ceiling enforcement is under the `runs` lock
19581    // inside `record_run_turns`, whose `RecordRunTurnsOutcome` we map below.
19582    if let Some((_agent_id, terminal, len, _corruption)) = &header {
19583        if *terminal {
19584            return record_turns_dropped(&run_id, "run_terminal");
19585        }
19586        // Per-run turn ceiling (R8a) fast path: a runaway-loop backstop and a
19587        // TRUE hard cap — refuse any batch that would take the run PAST the
19588        // ceiling, whole, and tell the agent to stop sending. (Healthy
19589        // cycles are tens of turns; losing the straddling batch of a
19590        // 2000-turn runaway is correct, not data loss.) The under-lock check
19591        // in `record_run_turns` is the one that actually bounds the run; this
19592        // only short-circuits the common already-over case.
19593        if *len + turns.len() > RECORD_TURNS_RUN_CEILING {
19594            return record_turns_dropped(&run_id, "run_turn_limit");
19595        }
19596    }
19597
19598    // Append through the shared path — index re-stamp, JSONL persist,
19599    // `runs.trace.event` fanout, AND the authoritative under-lock ceiling all
19600    // happen inside `record_run_turns` under its own lock discipline. It
19601    // returns a `RecordRunTurnsOutcome` that distinguishes a healthy append
19602    // from a ceiling refusal and from an unknown/terminal run (ADV-1) — we
19603    // must NOT let an under-lock ceiling refusal masquerade as `run_terminal`.
19604    let count = turns.len();
19605    let records: Vec<car_proto::RunRecord> =
19606        turns.into_iter().map(car_proto::RunRecord::Turn).collect();
19607    let new_total = match state
19608        .record_run_turns_for_active_owner(&run_id, &session.client_id, records)
19609        .await
19610    {
19611        crate::session::RecordRunTurnsOutcome::Appended { new_total } => new_total,
19612        crate::session::RecordRunTurnsOutcome::RefusedCeiling => {
19613            // A pipelined batch crossed the ceiling under the lock even
19614            // though our fast-path snapshot was sub-ceiling — the runaway
19615            // backstop fired authoritatively. Tell the agent to stop.
19616            return record_turns_dropped(&run_id, "run_turn_limit");
19617        }
19618        crate::session::RecordRunTurnsOutcome::UnknownOrTerminal => {
19619            // `record_run_turns` appended nothing because the run is unknown
19620            // or went terminal. We resolved the run above, so the benign
19621            // cause is the TOCTOU: the run went terminal between our
19622            // fast-path check and the append. Surface a non-fatal drop.
19623            return record_turns_dropped(&run_id, "run_terminal");
19624        }
19625        crate::session::RecordRunTurnsOutcome::PersistenceFailed(error) => {
19626            if error.starts_with(car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX) {
19627                return Err(error);
19628            }
19629            return Err(format!(
19630                "runs.record_turns persistence failed; retry exact batch safely: {error}"
19631            ));
19632        }
19633    };
19634
19635    serde_json::to_value(car_proto::RunRecordTurnsResponse {
19636        run_id,
19637        base_index: new_total - count,
19638        count,
19639        ok: true,
19640        dropped: None,
19641    })
19642    .map_err(|e| e.to_string())
19643}
19644
19645/// Authorize the calling connection to read/subscribe a run owned by
19646/// `agent_id` (R16 / KTD10 / Parslee-ai/car#254). A connection is
19647/// entitled when:
19648///
19649/// 1. It **owns** the agent — its `session.auth {agent_id}` binding
19650///    matches the run's owning `agent_id` (the supervised agent reading
19651///    its own trace), or
19652/// 2. It holds the **host-management role** — it authenticated via
19653///    `session.auth { host_token }` with the per-launch host token
19654///    (`ClientSession::is_host`).
19655///
19656/// What this gate buys, and its bound (be honest):
19657///
19658/// - It distinguishes agent-bound sessions, so one supervised agent's
19659///   connection cannot read a *different* agent's runs by guessing ids.
19660/// - Host-role is gated by the **host token**, NOT by `host.subscribe`
19661///   membership. This is the #254 fix: `host.subscribe` has no authz, so
19662///   keying on `is_subscribed` let ANY authenticated connection
19663///   self-elevate and read every agent's traces. The host token is read
19664///   only from the `0600` `host-token` file (never served over
19665///   `GET /auth-token`), so a different local user — or a client that
19666///   scraped the auth token off the HTTP endpoint — cannot obtain it.
19667/// - It is therefore a real confidentiality boundary against *another
19668///   local user* (multi-user / CI / remote daemon). It is NOT an
19669///   isolation boundary against a *malicious same-user process*: that
19670///   process can read the `0600` host-token (and the `0600` run files)
19671///   directly. Closing same-user isolation would need DPAPI/Keychain or
19672///   process-cred (SO_PEERCRED) auth — tracked separately, out of scope.
19673///
19674/// NOTE: lower-sensitivity host *metadata* — the agent roster, approvals,
19675/// and host events delivered by `host.subscribe` / `host.agents` /
19676/// `host.approvals` — is intentionally still available to any
19677/// authenticated connection (the local UI consumes it). Only run-trace
19678/// *content* (prompts, CLI output) requires host-role. Tightening that
19679/// metadata surface is a separate decision from #254.
19680async fn authorize_run_access(
19681    session: &crate::session::ClientSession,
19682    _state: &Arc<ServerState>,
19683    owning_agent_id: &str,
19684) -> Result<(), String> {
19685    // 1. The connection owns the agent (its own run).
19686    if let Some(bound) = session.agent_id.lock().await.clone() {
19687        if bound == owning_agent_id {
19688            return Ok(());
19689        }
19690    }
19691    // 2. The connection authenticated as the host-management client by
19692    //    presenting the per-launch host token (Parslee-ai/car#254).
19693    //    NOTE: this is deliberately NOT `host.is_subscribed(...)` — that
19694    //    check let any authenticated connection self-elevate just by
19695    //    calling `host.subscribe` (which has no authz), reading every
19696    //    agent's run traces. host.subscribe still works for host *events*;
19697    //    it just no longer grants the cross-agent run-trace read.
19698    if session.is_host.load(std::sync::atomic::Ordering::Acquire) {
19699        return Ok(());
19700    }
19701    // Do NOT name the owning `agent_id` in the error (FIX 3): a caller-
19702    // supplied or resolved id is not a transparent key, and echoing it back
19703    // turns a rejection into an existence/owner oracle. Log it server-side
19704    // for diagnosis; keep the wire message agent-agnostic.
19705    tracing::debug!(
19706        owning_agent = %owning_agent_id,
19707        client_id = %session.client_id,
19708        "run access denied: connection neither owns the agent nor is the host client"
19709    );
19710    Err(
19711        "not authorized to access this run: this connection neither owns the \
19712         owning agent (via session.auth) nor is the host management client"
19713            .to_string(),
19714    )
19715}
19716
19717/// `runs.subscribe {run_id,cursor,limit}` — return one bounded catch-up page
19718/// and register for `runs.trace.event` only when the page reaches live (U4).
19719///
19720/// Authorizes the caller against the run's owning `agent_id` (R16), then
19721/// atomically pages the run's turns and, on the final page, registers the
19722/// subscriber under the `runs` lock (invariant #1, in
19723/// [`ServerState::subscribe_run_page`]).
19724///
19725/// FIX 3 (existence/owner oracle): an unknown `run_id` and an
19726/// exists-but-unauthorized `run_id` must be INDISTINGUISHABLE. Both return
19727/// the same uniform not-found marker (`{ run_id, not_found: true }`,
19728/// mirroring [`handle_runs_get_trace`]) — never an error frame that varies
19729/// by case, and never the owning `agent_id`. Otherwise an unentitled caller
19730/// could probe which `run_id`s exist (and learn the owning agent) by
19731/// telling "unknown" apart from "not authorized".
19732async fn handle_runs_subscribe(
19733    req: &JsonRpcMessage,
19734    session: &crate::session::ClientSession,
19735    state: &Arc<ServerState>,
19736) -> Result<Value, String> {
19737    const MAX_LIMIT: usize = 500;
19738    #[derive(Deserialize)]
19739    struct LegacyRequest {
19740        run_id: String,
19741    }
19742    let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
19743    let (run_id, cursor, limit) = if paginated {
19744        let params: car_proto::RunSubscribeRequest = serde_json::from_value(req.params.clone())
19745            .map_err(|e| format!("runs.subscribe requires {{ run_id, cursor, limit }}: {e}"))?;
19746        (params.run_id, params.cursor, params.limit)
19747    } else {
19748        let params: LegacyRequest = serde_json::from_value(req.params.clone())
19749            .map_err(|e| format!("runs.subscribe requires {{ run_id }}: {e}"))?;
19750        (params.run_id, 0, crate::session::RECORD_TURNS_RUN_CEILING)
19751    };
19752    if !(1..=MAX_LIMIT).contains(&limit) && paginated {
19753        return Err(format!(
19754            "runs.subscribe limit must be between 1 and {MAX_LIMIT}"
19755        ));
19756    }
19757
19758    // Uniform not-found marker shared by the unknown-run and unauthorized
19759    // cases so the two are indistinguishable to the caller (FIX 3).
19760    let not_found = || {
19761        serde_json::to_value(serde_json::json!({
19762            "run_id": run_id,
19763            "not_found": true,
19764        }))
19765        .map_err(|e| e.to_string())
19766    };
19767
19768    // Resolve the run's owning agent_id. Prefer the in-memory registry
19769    // (live run); fall back to the disk store (a run that exists but whose
19770    // RunMeta isn't in this process). Unknown run → uniform not-found.
19771    let owning_agent = match state.run_header(&run_id).await {
19772        Some((agent_id, _, _, _)) => agent_id,
19773        None => {
19774            let run_store = state.run_store.clone();
19775            let durable_run_id = run_id.clone();
19776            match run_store_blocking("runs.subscribe owner lookup", move || {
19777                Ok(run_store.agent_for_run(&durable_run_id))
19778            })
19779            .await?
19780            {
19781                Some(agent_id) => agent_id,
19782                None => return not_found(),
19783            }
19784        }
19785    };
19786
19787    // R16/KTD10: gate before snapshotting. An unauthorized caller gets the
19788    // SAME uniform not-found as an unknown run — no distinguishable outcome,
19789    // no leaked owning `agent_id` (logged server-side only).
19790    if authorize_run_access(session, state, &owning_agent)
19791        .await
19792        .is_err()
19793    {
19794        tracing::debug!(
19795            run_id = %run_id,
19796            owning_agent = %owning_agent,
19797            client_id = %session.client_id,
19798            "runs.subscribe denied (unauthorized); returning uniform not-found"
19799        );
19800        return not_found();
19801    }
19802
19803    let page = match state
19804        .subscribe_run_page(
19805            &run_id,
19806            &session.client_id,
19807            session.channel.clone(),
19808            cursor,
19809            limit,
19810        )
19811        .await?
19812    {
19813        Some(crate::session::RunSubscribePageResult::Ready(page)) => page,
19814        Some(crate::session::RunSubscribePageResult::Durable { agent_id, status }) => {
19815            let run_store = state.run_store.clone();
19816            let durable_run_id = run_id.clone();
19817            let page = strict_run_trace_read_blocking(
19818                "runs.subscribe durable page",
19819                state,
19820                run_id.clone(),
19821                move || run_store.get_run_turn_page_for(&agent_id, &durable_run_id, cursor, limit),
19822            )
19823            .await?;
19824            let Some((turns, next_cursor, live_cursor, durable_status)) = page else {
19825                return not_found();
19826            };
19827            let status = match durable_status {
19828                crate::run_store::RunStatus::InProgress => status,
19829                crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19830                crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19831                crate::run_store::RunStatus::CancellationPending => {
19832                    car_proto::RunLiveStatus::CancellationPending
19833                }
19834                crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19835            };
19836            car_proto::RunSubscribeResponse {
19837                run_id: run_id.clone(),
19838                agent_id: owning_agent.clone(),
19839                turns,
19840                cursor,
19841                limit,
19842                next_cursor,
19843                live_cursor,
19844                subscribed: next_cursor.is_none(),
19845                status,
19846            }
19847        }
19848        // A restart has no in-memory RunMeta for retained terminal history.
19849        // The owner lookup above already proved the durable file exists, so
19850        // page it directly instead of turning a valid replay subscription into
19851        // a false not-found (and, critically, run the strict corruption scan).
19852        None => {
19853            let run_store = state.run_store.clone();
19854            let durable_run_id = run_id.clone();
19855            let durable_agent = owning_agent.clone();
19856            let page = strict_run_trace_read_blocking(
19857                "runs.subscribe retained durable page",
19858                state,
19859                run_id.clone(),
19860                move || {
19861                    run_store.get_run_turn_page_for(&durable_agent, &durable_run_id, cursor, limit)
19862                },
19863            )
19864            .await?;
19865            if let Some((turns, next_cursor, live_cursor, durable_status)) = page {
19866                let status = match durable_status {
19867                    crate::run_store::RunStatus::InProgress => car_proto::RunLiveStatus::InProgress,
19868                    crate::run_store::RunStatus::Completed => car_proto::RunLiveStatus::Completed,
19869                    crate::run_store::RunStatus::Incomplete => car_proto::RunLiveStatus::Incomplete,
19870                    crate::run_store::RunStatus::CancellationPending => {
19871                        car_proto::RunLiveStatus::CancellationPending
19872                    }
19873                    crate::run_store::RunStatus::Cancelled => car_proto::RunLiveStatus::Cancelled,
19874                };
19875                car_proto::RunSubscribeResponse {
19876                    run_id: run_id.clone(),
19877                    agent_id: owning_agent.clone(),
19878                    turns,
19879                    cursor,
19880                    limit,
19881                    next_cursor,
19882                    live_cursor,
19883                    subscribed: next_cursor.is_none(),
19884                    status,
19885                }
19886            } else if !paginated {
19887                // Pre-v3 durable traces have no summary sidecar. The legacy
19888                // no-capability lane intentionally preserves v2's complete,
19889                // unbounded snapshot semantics, so recover the turns and
19890                // status from the strict JSONL read instead of hiding valid
19891                // persisted user history behind a false not-found.
19892                let run_store = state.run_store.clone();
19893                let durable_run_id = run_id.clone();
19894                let durable_agent = owning_agent.clone();
19895                let records = strict_run_trace_read_blocking(
19896                    "runs.subscribe pre-v3 durable trace",
19897                    state,
19898                    run_id.clone(),
19899                    move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
19900                )
19901                .await?;
19902                let Some(records) = records else {
19903                    return not_found();
19904                };
19905                let mut status = car_proto::RunLiveStatus::InProgress;
19906                let mut turns = Vec::new();
19907                for record in records {
19908                    match record {
19909                        turn @ car_proto::RunRecord::Turn(_) => turns.push(turn),
19910                        car_proto::RunRecord::CancellationRequested(_)
19911                        | car_proto::RunRecord::CancellationResult(_) => {
19912                            if status == car_proto::RunLiveStatus::InProgress {
19913                                status = car_proto::RunLiveStatus::CancellationPending;
19914                            }
19915                        }
19916                        car_proto::RunRecord::Ended(ended) => {
19917                            status = match ended.termination {
19918                                car_proto::RunTermination::Outcome { .. } => {
19919                                    car_proto::RunLiveStatus::Completed
19920                                }
19921                                car_proto::RunTermination::Incomplete => {
19922                                    car_proto::RunLiveStatus::Incomplete
19923                                }
19924                                car_proto::RunTermination::Cancelled { .. } => {
19925                                    car_proto::RunLiveStatus::Cancelled
19926                                }
19927                            };
19928                        }
19929                        car_proto::RunRecord::Started(_) => {}
19930                    }
19931                }
19932                let live_cursor = turns.len();
19933                car_proto::RunSubscribeResponse {
19934                    run_id: run_id.clone(),
19935                    agent_id: owning_agent.clone(),
19936                    turns,
19937                    cursor: 0,
19938                    limit,
19939                    next_cursor: None,
19940                    live_cursor,
19941                    subscribed: false,
19942                    status,
19943                }
19944            } else {
19945                return not_found();
19946            }
19947        }
19948    };
19949    if paginated {
19950        serde_json::to_value(page).map_err(|e| e.to_string())
19951    } else {
19952        serde_json::to_value(serde_json::json!({
19953            "run_id": page.run_id,
19954            "agent_id": page.agent_id,
19955            "turns_so_far": page.turns,
19956            "cursor": page.live_cursor,
19957            "status": page.status,
19958        }))
19959        .map_err(|e| e.to_string())
19960    }
19961}
19962
19963/// `runs.unsubscribe {run_id}` — drop this connection's live run-trace
19964/// subscription for `run_id` (U4). Idempotent: returns `removed: false`
19965/// if there was nothing to remove. No authorization gate is needed —
19966/// removing your own subscription leaks nothing.
19967async fn handle_runs_unsubscribe(
19968    req: &JsonRpcMessage,
19969    session: &crate::session::ClientSession,
19970    state: &Arc<ServerState>,
19971) -> Result<Value, String> {
19972    let params: car_proto::RunUnsubscribeRequest = serde_json::from_value(req.params.clone())
19973        .map_err(|e| format!("runs.unsubscribe requires {{ run_id }}: {e}"))?;
19974    let removed = state
19975        .unsubscribe_run(&params.run_id, &session.client_id)
19976        .await;
19977    serde_json::to_value(car_proto::RunUnsubscribeResponse {
19978        run_id: params.run_id,
19979        removed,
19980    })
19981    .map_err(|e| e.to_string())
19982}
19983
19984/// `runs.list {agent_id,cursor,limit}` — list one bounded page of an agent's
19985/// runs newest-first for replay (U5). Reads the disk store's durable summary
19986/// index ([`RunStore::list_runs_page`]), so it works
19987/// across daemon restart / `client_id` churn — `agent_id` is the durable
19988/// key, not the connection.
19989///
19990/// R16/KTD10: the `agent_id` is **not** a transparent key. We authorize
19991/// the caller for it FIRST — it must own the agent (`session.auth
19992/// {agent_id}`) or be the CarHost host-client — so an unentitled caller
19993/// can't enumerate other agents' run lists. An authorized caller for an
19994/// agent with no runs gets an empty list (the empty state, R13).
19995async fn handle_runs_list(
19996    req: &JsonRpcMessage,
19997    session: &crate::session::ClientSession,
19998    state: &Arc<ServerState>,
19999) -> Result<Value, String> {
20000    #[derive(Deserialize)]
20001    struct LegacyRequest {
20002        agent_id: String,
20003    }
20004    let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
20005    let (agent_id, page) = if paginated {
20006        let params: car_proto::RunListRequest = serde_json::from_value(req.params.clone())
20007            .map_err(|e| format!("runs.list requires {{ agent_id, cursor, limit }}: {e}"))?;
20008        (params.agent_id, Some((params.cursor, params.limit)))
20009    } else {
20010        let params: LegacyRequest = serde_json::from_value(req.params.clone())
20011            .map_err(|e| format!("runs.list requires {{ agent_id }}: {e}"))?;
20012        (params.agent_id, None)
20013    };
20014
20015    // Gate before reading — the param is an authorization subject, not a
20016    // lookup key. An unentitled caller is rejected, never served a list.
20017    authorize_run_access(session, state, &agent_id).await?;
20018
20019    const MAX_LIMIT: usize = 200;
20020    if let Some((_, limit)) = page {
20021        if !(1..=MAX_LIMIT).contains(&limit) {
20022            return Err(format!("runs.list limit must be between 1 and {MAX_LIMIT}"));
20023        }
20024    }
20025    let run_store = state.run_store.clone();
20026    let durable_agent_id = agent_id.clone();
20027    let Some((cursor, limit)) = page else {
20028        let runs = run_store_blocking("runs.list legacy complete read", move || {
20029            Ok(run_store.list_runs(&durable_agent_id))
20030        })
20031        .await?;
20032        return Ok(serde_json::json!({"agent_id": agent_id, "runs": runs}));
20033    };
20034    let (runs, next_cursor) = run_store_blocking("runs.list page", move || {
20035        run_store
20036            .list_runs_page(&durable_agent_id, cursor, limit)
20037            .map_err(|error| format!("runs.list durable index read failed: {error}"))
20038    })
20039    .await?;
20040    let mut response = serde_json::json!({
20041        "agent_id": agent_id,
20042        "runs": runs,
20043        "cursor": cursor,
20044        "limit": limit,
20045    });
20046    if let Some(next_cursor) = next_cursor {
20047        response["next_cursor"] = serde_json::json!(next_cursor);
20048    }
20049    Ok(response)
20050}
20051
20052/// `runs.get_trace {run_id,cursor,limit}` — fetch one bounded page of a
20053/// run's ordered trace from the disk store for replay (U5). Sequential pages
20054/// replay identically to what the live stream delivered, including after a
20055/// fresh daemon restart with empty memory.
20056///
20057/// R16/KTD10: resolve the run's owning `agent_id` from disk and authorize
20058/// the caller against it before serving any record — a `run_id` is not a
20059/// transparent key. An unknown `run_id` (no file on disk) returns a clear
20060/// not-found marker (`{ run_id, not_found: true }`), not an error frame,
20061/// so a UI can distinguish "no such run" from a transport failure.
20062///
20063/// A `timeout`/`Incomplete` run is **not** an error: its persisted trail
20064/// (the turns recorded so far plus the terminal `Ended`/`Incomplete`
20065/// marker) is returned, so the dashboard renders the partial run.
20066async fn handle_runs_get_trace(
20067    req: &JsonRpcMessage,
20068    session: &crate::session::ClientSession,
20069    state: &Arc<ServerState>,
20070) -> Result<Value, String> {
20071    #[derive(Deserialize)]
20072    struct LegacyRequest {
20073        run_id: String,
20074        #[serde(default)]
20075        cursor: Option<usize>,
20076    }
20077    let paginated = session_has_capability(session, car_proto::RUNS_PAGINATION_CAPABILITY);
20078    let (run_id, cursor, limit) = if paginated {
20079        let params: car_proto::RunGetTraceRequest = serde_json::from_value(req.params.clone())
20080            .map_err(|e| format!("runs.get_trace requires {{ run_id, cursor, limit }}: {e}"))?;
20081        (params.run_id, params.cursor, Some(params.limit))
20082    } else {
20083        let params: LegacyRequest = serde_json::from_value(req.params.clone())
20084            .map_err(|e| format!("runs.get_trace requires {{ run_id }}: {e}"))?;
20085        (params.run_id, params.cursor.unwrap_or(0), None)
20086    };
20087    const MAX_LIMIT: usize = 500;
20088    if let Some(limit) = limit {
20089        if !(1..=MAX_LIMIT).contains(&limit) {
20090            return Err(format!(
20091                "runs.get_trace limit must be between 1 and {MAX_LIMIT}"
20092            ));
20093        }
20094    }
20095
20096    // Resolve the owning agent from disk (the durable key path — works
20097    // after a restart). An unknown run_id has no owner: not-found, and we
20098    // never reach the store read. We don't reveal whether the id exists to
20099    // an unauthorized caller — authorization is checked against the
20100    // resolved owner, and resolution failure is the same not-found either
20101    // way.
20102    let run_store = state.run_store.clone();
20103    let durable_run_id = run_id.clone();
20104    let owning_agent = run_store_blocking("runs.get_trace owner lookup", move || {
20105        Ok(run_store.agent_for_run(&durable_run_id))
20106    })
20107    .await?;
20108    let Some(owning_agent) = owning_agent else {
20109        return serde_json::to_value(serde_json::json!({
20110            "run_id": run_id,
20111            "not_found": true,
20112        }))
20113        .map_err(|e| e.to_string());
20114    };
20115
20116    // R16/KTD10: gate before serving any record.
20117    authorize_run_access(session, state, &owning_agent).await?;
20118
20119    // Stream one bounded page from disk. `agent_for_run` just resolved the
20120    // file, so this is the cheaper keyed read.
20121    let run_store = state.run_store.clone();
20122    let durable_agent = owning_agent.clone();
20123    let durable_run_id = run_id.clone();
20124    if limit.is_none() {
20125        let records = strict_run_trace_read_blocking(
20126            "runs.get_trace legacy complete read",
20127            state,
20128            run_id.clone(),
20129            move || run_store.get_run_trace_for_checked(&durable_agent, &durable_run_id),
20130        )
20131        .await?;
20132        let Some(records) = records else {
20133            return Ok(serde_json::json!({"run_id": run_id, "not_found": true}));
20134        };
20135        let records = records.into_iter().skip(cursor).collect::<Vec<_>>();
20136        return Ok(serde_json::json!({
20137            "run_id": run_id,
20138            "agent_id": owning_agent,
20139            "records": records,
20140            "cursor": cursor,
20141        }));
20142    }
20143    let limit = limit.expect("paginated path has a limit");
20144    let page =
20145        strict_run_trace_read_blocking("runs.get_trace page", state, run_id.clone(), move || {
20146            run_store.get_run_trace_page_for(&durable_agent, &durable_run_id, cursor, limit)
20147        })
20148        .await?;
20149    let Some((paged, next_cursor)) = page else {
20150        return serde_json::to_value(serde_json::json!({
20151            "run_id": run_id,
20152            "not_found": true,
20153        }))
20154        .map_err(|e| e.to_string());
20155    };
20156
20157    let mut response = serde_json::json!({
20158        "run_id": run_id,
20159        "agent_id": owning_agent,
20160        "records": paged,
20161        "cursor": cursor,
20162        "limit": limit,
20163    });
20164    if let Some(next_cursor) = next_cursor {
20165        response["next_cursor"] = serde_json::json!(next_cursor);
20166    }
20167    Ok(response)
20168}
20169
20170fn run_trace_read_error(run_id: &str, error: std::io::Error) -> String {
20171    if crate::run_store::is_trace_corruption_error(&error) {
20172        format!(
20173            "{} run `{run_id}`: {error}",
20174            car_proto::RUN_TRACE_CORRUPTION_MESSAGE_PREFIX
20175        )
20176    } else {
20177        format!("run trace read failed for `{run_id}`: {error}")
20178    }
20179}
20180
20181/// Update the per-session replan config. Wire shape mirrors the
20182/// FFI's positional `set_replan_config` arguments — the engine
20183/// crate's `ReplanConfig` struct doesn't derive Serialize, so we
20184/// reconstruct it from a flat object here.
20185async fn handle_replan_set_config(
20186    msg: &JsonRpcMessage,
20187    session: &crate::session::ClientSession,
20188) -> Result<Value, String> {
20189    let max_replans = msg
20190        .params
20191        .get("max_replans")
20192        .and_then(|v| v.as_u64())
20193        .unwrap_or(0) as u32;
20194    let delay_ms = msg
20195        .params
20196        .get("delay_ms")
20197        .and_then(|v| v.as_u64())
20198        .unwrap_or(0);
20199    let verify_before_execute = msg
20200        .params
20201        .get("verify_before_execute")
20202        .and_then(|v| v.as_bool())
20203        .unwrap_or(true);
20204    let replan_on_rejected = msg
20205        .params
20206        .get("replan_on_rejected")
20207        .and_then(|v| v.as_bool())
20208        .unwrap_or(false);
20209    let cfg = car_engine::ReplanConfig {
20210        max_replans,
20211        delay_ms,
20212        verify_before_execute,
20213        replan_on_rejected,
20214    };
20215    session.runtime.set_replan_config(cfg).await;
20216    Ok(Value::Null)
20217}
20218
20219async fn handle_skills_list(
20220    msg: &JsonRpcMessage,
20221    session: &crate::session::ClientSession,
20222) -> Result<Value, String> {
20223    let domain = msg.params.get("domain").and_then(|v| v.as_str());
20224    let engine = session.memgine.lock().await;
20225    let skills: Vec<serde_json::Value> = engine
20226        .graph
20227        .inner
20228        .node_indices()
20229        .filter_map(|nix| {
20230            let node = engine.graph.inner.node_weight(nix)?;
20231            if node.kind != car_memgine::MemKind::Skill {
20232                return None;
20233            }
20234            let meta = car_memgine::SkillMeta::from_node(node)?;
20235            if let Some(d) = domain {
20236                match &meta.scope {
20237                    car_memgine::SkillScope::Global => {}
20238                    car_memgine::SkillScope::Domain(sd) if sd == d => {}
20239                    _ => return None,
20240                }
20241            }
20242            Some(serde_json::to_value(&meta).unwrap_or_default())
20243        })
20244        .collect();
20245    serde_json::to_value(&skills).map_err(|e| e.to_string())
20246}
20247
20248#[derive(serde::Deserialize)]
20249struct SecretParams {
20250    #[serde(default)]
20251    service: Option<String>,
20252    key: String,
20253    #[serde(default)]
20254    value: Option<String>,
20255    /// Set only by the local CLI reader after it loaded the owner-only host
20256    /// token. This is authorization intent, not identity; the authenticated
20257    /// session remains the proof.
20258    #[serde(default)]
20259    operator_broker: bool,
20260}
20261
20262fn canonical_secret_key(key: &str) -> &str {
20263    if key.eq_ignore_ascii_case("openrouter") {
20264        car_inference::openrouter::API_KEY_ENV
20265    } else {
20266        key
20267    }
20268}
20269
20270fn reject_reserved_oauth_secret(service: Option<&str>, key: &str) -> Result<(), String> {
20271    if car_inference::openrouter::is_reserved_oauth_secret(service, key) {
20272        Err("reserved_private_secret: the OpenRouter OAuth credential is managed only by openrouter.auth_start/openrouter.disconnect".to_string())
20273    } else {
20274        Ok(())
20275    }
20276}
20277
20278fn is_openrouter_pasted_authority_slot(service: Option<&str>, key: &str) -> bool {
20279    let is_default_service = service.is_none() || service == Some(car_secrets::DEFAULT_SERVICE);
20280    is_default_service && key == car_inference::openrouter::API_KEY_ENV
20281}
20282
20283async fn handle_secret_put(req: &JsonRpcMessage) -> Result<Value, String> {
20284    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20285    let value = p.value.ok_or_else(|| "missing 'value'".to_string())?;
20286    let key = canonical_secret_key(&p.key);
20287    reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20288    if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
20289        return car_ffi_common::secrets::put(p.service.as_deref(), key, &value);
20290    }
20291    let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(true, || {
20292        car_ffi_common::secrets::put(p.service.as_deref(), key, &value)
20293    })
20294    .await;
20295    result.map(|value| with_openrouter_authority_generation(value, &final_status))
20296}
20297
20298async fn handle_secret_get(
20299    req: &JsonRpcMessage,
20300    session: &crate::session::ClientSession,
20301    state: &crate::session::ServerState,
20302) -> Result<Value, String> {
20303    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20304    let key = canonical_secret_key(&p.key);
20305    let agent_id = session.agent_id.lock().await.clone();
20306
20307    if p.operator_broker {
20308        // The auth gate has already validated this session. Host authority is
20309        // backed by the owner-only local token file (the ordinary token is
20310        // exposed to dashboard clients), which is the same-UID proof. Requiring
20311        // auth-enabled state closes `--no-auth`; rejecting a bound agent keeps
20312        // it from self-promoting past policy.
20313        if state.auth_token.get().is_none()
20314            || !session
20315                .authenticated
20316                .load(std::sync::atomic::Ordering::Acquire)
20317            || !session.is_host.load(std::sync::atomic::Ordering::Acquire)
20318            || agent_id.is_some()
20319        {
20320            return Err(
20321                "operator_broker_denied: secret.get operator broker requires an authenticated local host session"
20322                    .to_string(),
20323            );
20324        }
20325        let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
20326        let action = car_ir::Action::tool_call("secret.get")
20327            .with_param("service", Value::String(service.to_string()))
20328            .with_param("key", Value::String(key.to_string()))
20329            .with_param("caller_role", Value::String("operator".to_string()));
20330        let result =
20331            car_ffi_common::secrets::get_for_daemon_operator_broker(p.service.as_deref(), key);
20332        let (kind, outcome) = if result.is_ok() {
20333            (car_eventlog::EventKind::ActionSucceeded, "read")
20334        } else {
20335            (car_eventlog::EventKind::ActionFailed, "secret_store_error")
20336        };
20337        session.runtime.log.lock().await.append(
20338            kind,
20339            Some(&action.id),
20340            None,
20341            operator_secret_read_audit_data(service, key, outcome),
20342        );
20343        return result;
20344    }
20345
20346    reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20347
20348    let Some(agent_id) = agent_id else {
20349        // Hosts and user-run standalone clients keep the existing generic
20350        // secret.get contract. The broker boundary applies to authenticated
20351        // supervised-agent sessions, whose identity the daemon can enforce and
20352        // audit rather than trusting a caller-supplied label.
20353        return car_ffi_common::secrets::get(p.service.as_deref(), key);
20354    };
20355    let service = p.service.as_deref().unwrap_or(car_secrets::DEFAULT_SERVICE);
20356    let action = car_ir::Action::tool_call("secret.get")
20357        .with_param("service", Value::String(service.to_string()))
20358        .with_param("key", Value::String(key.to_string()))
20359        .with_param("agent_id", Value::String(agent_id.clone()));
20360    let violations = session
20361        .runtime
20362        .policies
20363        .read()
20364        .await
20365        .check(&action, &session.runtime.state);
20366    if !violations.is_empty() {
20367        let policies: Vec<Value> = violations
20368            .iter()
20369            .map(|violation| Value::String(violation.policy_name.clone()))
20370            .collect();
20371        let mut data = secret_read_audit_data(service, key, &agent_id, "policy_denied");
20372        data.insert("policies".to_string(), Value::Array(policies.clone()));
20373        session.runtime.log.lock().await.append(
20374            car_eventlog::EventKind::PolicyViolation,
20375            Some(&action.id),
20376            None,
20377            data,
20378        );
20379        return Err(serde_json::json!({
20380            "code": "policy_denied",
20381            "message": "secret.get was denied by the daemon policy engine",
20382            "context": {"service": service, "key": key, "agent_id": agent_id},
20383            "policies": policies,
20384        })
20385        .to_string());
20386    }
20387
20388    let result = car_ffi_common::secrets::get(p.service.as_deref(), key);
20389    let (kind, outcome) = if result.is_ok() {
20390        (car_eventlog::EventKind::ActionSucceeded, "read")
20391    } else {
20392        (car_eventlog::EventKind::ActionFailed, "secret_store_error")
20393    };
20394    session.runtime.log.lock().await.append(
20395        kind,
20396        Some(&action.id),
20397        None,
20398        secret_read_audit_data(service, key, &agent_id, outcome),
20399    );
20400    result
20401}
20402
20403fn secret_read_audit_data(
20404    service: &str,
20405    key: &str,
20406    agent_id: &str,
20407    outcome: &str,
20408) -> HashMap<String, Value> {
20409    HashMap::from([
20410        ("tool".to_string(), Value::String("secret.get".to_string())),
20411        ("service".to_string(), Value::String(service.to_string())),
20412        ("key".to_string(), Value::String(key.to_string())),
20413        ("agent_id".to_string(), Value::String(agent_id.to_string())),
20414        ("outcome".to_string(), Value::String(outcome.to_string())),
20415    ])
20416}
20417
20418fn operator_secret_read_audit_data(
20419    service: &str,
20420    key: &str,
20421    outcome: &str,
20422) -> HashMap<String, Value> {
20423    HashMap::from([
20424        ("tool".to_string(), Value::String("secret.get".to_string())),
20425        ("service".to_string(), Value::String(service.to_string())),
20426        ("key".to_string(), Value::String(key.to_string())),
20427        (
20428            "caller_role".to_string(),
20429            Value::String("operator".to_string()),
20430        ),
20431        ("outcome".to_string(), Value::String(outcome.to_string())),
20432    ])
20433}
20434
20435async fn handle_secret_delete(req: &JsonRpcMessage) -> Result<Value, String> {
20436    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20437    let key = canonical_secret_key(&p.key);
20438    reject_reserved_oauth_secret(p.service.as_deref(), key)?;
20439    if !is_openrouter_pasted_authority_slot(p.service.as_deref(), key) {
20440        return car_ffi_common::secrets::delete(p.service.as_deref(), key);
20441    }
20442    let (result, final_status) = crate::openrouter_auth::mutate_pasted_credential(false, || {
20443        car_ffi_common::secrets::delete(p.service.as_deref(), key)
20444    })
20445    .await;
20446    result.map(|value| with_openrouter_authority_generation(value, &final_status))
20447}
20448
20449fn with_openrouter_authority_generation(
20450    mut value: Value,
20451    status: &crate::openrouter_auth::Status,
20452) -> Value {
20453    if let Value::Object(fields) = &mut value {
20454        fields.insert(
20455            "authority_generation".to_string(),
20456            Value::from(status.authority_generation),
20457        );
20458        return value;
20459    }
20460    serde_json::json!({
20461        "ok": true,
20462        "authority_generation": status.authority_generation,
20463        "result": value,
20464    })
20465}
20466
20467fn handle_secret_status(req: &JsonRpcMessage) -> Result<Value, String> {
20468    let p: SecretParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20469    car_ffi_common::secrets::status(p.service.as_deref(), canonical_secret_key(&p.key))
20470}
20471
20472#[cfg(test)]
20473mod openrouter_secret_service_authority_tests {
20474    use super::{handle_secret_delete, handle_secret_put, handle_secret_status, JsonRpcMessage};
20475    use serde_json::{json, Value};
20476
20477    static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
20478
20479    struct FileSecretEnvironment(Option<std::ffi::OsString>);
20480
20481    impl FileSecretEnvironment {
20482        fn install(path: &std::path::Path) -> Self {
20483            let previous = std::env::var_os("CAR_SECRETS_FILE_DIR");
20484            unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", path) };
20485            Self(previous)
20486        }
20487    }
20488
20489    impl Drop for FileSecretEnvironment {
20490        fn drop(&mut self) {
20491            match self.0.take() {
20492                Some(previous) => unsafe { std::env::set_var("CAR_SECRETS_FILE_DIR", previous) },
20493                None => unsafe { std::env::remove_var("CAR_SECRETS_FILE_DIR") },
20494            }
20495        }
20496    }
20497
20498    fn req(params: Value) -> JsonRpcMessage {
20499        JsonRpcMessage {
20500            jsonrpc: "2.0".into(),
20501            method: None,
20502            params,
20503            id: json!(1),
20504            result: None,
20505            error: None,
20506        }
20507    }
20508
20509    #[tokio::test(flavor = "current_thread")]
20510    async fn non_default_service_put_is_generic_and_does_not_publish_pasted_authority() {
20511        let _env_lock = ENV_LOCK.lock().await;
20512        let temp = tempfile::TempDir::new().unwrap();
20513        let _environment = FileSecretEnvironment::install(temp.path());
20514        let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20515
20516        let result = handle_secret_put(&req(json!({
20517            "service": "integration-test-vault",
20518            "key": "openrouter",
20519            "value": "custom-service-only"
20520        })))
20521        .await
20522        .unwrap();
20523        assert_eq!(result["service"], "integration-test-vault");
20524        assert_eq!(result["key"], car_inference::openrouter::API_KEY_ENV);
20525        assert!(
20526            result.get("authority_generation").is_none(),
20527            "a generic service write must not claim it changed OpenRouter authority: {result}"
20528        );
20529        let generic_status = handle_secret_status(&req(json!({
20530            "service": "integration-test-vault",
20531            "key": "openrouter"
20532        })))
20533        .unwrap();
20534        assert_eq!(generic_status["exists"], true);
20535        assert_eq!(
20536            serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20537            authority_before,
20538            "a non-default service cannot supersede OAuth/default-service resolution"
20539        );
20540    }
20541
20542    #[tokio::test(flavor = "current_thread")]
20543    async fn non_default_service_delete_is_generic_and_does_not_publish_false_absence() {
20544        let _env_lock = ENV_LOCK.lock().await;
20545        let temp = tempfile::TempDir::new().unwrap();
20546        let _environment = FileSecretEnvironment::install(temp.path());
20547        car_ffi_common::secrets::put(
20548            Some("integration-test-vault-delete"),
20549            car_inference::openrouter::API_KEY_ENV,
20550            "custom-service-only",
20551        )
20552        .unwrap();
20553        let authority_before = serde_json::to_value(crate::openrouter_auth::status()).unwrap();
20554
20555        let result = handle_secret_delete(&req(json!({
20556            "service": "integration-test-vault-delete",
20557            "key": "openrouter"
20558        })))
20559        .await
20560        .unwrap();
20561        assert_eq!(result["service"], "integration-test-vault-delete");
20562        assert!(
20563            result.get("authority_generation").is_none(),
20564            "a generic service delete must not claim it changed OpenRouter authority: {result}"
20565        );
20566        let generic_status = handle_secret_status(&req(json!({
20567            "service": "integration-test-vault-delete",
20568            "key": "openrouter"
20569        })))
20570        .unwrap();
20571        assert_eq!(generic_status["exists"], false);
20572        assert_eq!(
20573            serde_json::to_value(crate::openrouter_auth::status()).unwrap(),
20574            authority_before,
20575            "deleting another service's key cannot publish false default-service absence"
20576        );
20577    }
20578}
20579
20580#[derive(serde::Deserialize)]
20581struct PermParams {
20582    domain: String,
20583    #[serde(default)]
20584    target_bundle_id: Option<String>,
20585}
20586
20587fn handle_perm_status(req: &JsonRpcMessage) -> Result<Value, String> {
20588    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20589    car_ffi_common::permissions::status(&p.domain, p.target_bundle_id.as_deref())
20590}
20591
20592fn handle_perm_request(req: &JsonRpcMessage) -> Result<Value, String> {
20593    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20594    car_ffi_common::permissions::request(&p.domain, p.target_bundle_id.as_deref())
20595}
20596
20597fn handle_perm_explain(req: &JsonRpcMessage) -> Result<Value, String> {
20598    let p: PermParams = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20599    car_ffi_common::permissions::explain(&p.domain, p.target_bundle_id.as_deref())
20600}
20601
20602fn handle_calendar_events(req: &JsonRpcMessage) -> Result<Value, String> {
20603    #[derive(serde::Deserialize)]
20604    struct P {
20605        start: String,
20606        end: String,
20607        #[serde(default)]
20608        calendar_ids: Vec<String>,
20609    }
20610    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20611    let start = chrono::DateTime::parse_from_rfc3339(&p.start)
20612        .map_err(|e| format!("parse start: {}", e))?
20613        .with_timezone(&chrono::Utc);
20614    let end = chrono::DateTime::parse_from_rfc3339(&p.end)
20615        .map_err(|e| format!("parse end: {}", e))?
20616        .with_timezone(&chrono::Utc);
20617    car_ffi_common::integrations::calendar_events(start, end, &p.calendar_ids)
20618}
20619
20620fn handle_calendar_create_event(req: &JsonRpcMessage) -> Result<Value, String> {
20621    let raw = req.params.to_string();
20622    car_ffi_common::integrations::calendar_create_event(&raw)
20623}
20624
20625fn handle_calendar_update_event(req: &JsonRpcMessage) -> Result<Value, String> {
20626    let raw = req.params.to_string();
20627    car_ffi_common::integrations::calendar_update_event(&raw)
20628}
20629
20630fn handle_calendar_delete_event(req: &JsonRpcMessage) -> Result<Value, String> {
20631    #[derive(serde::Deserialize)]
20632    struct P {
20633        event_id: String,
20634    }
20635    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20636    car_ffi_common::integrations::calendar_delete_event(&p.event_id)
20637}
20638
20639fn handle_contacts_find(req: &JsonRpcMessage) -> Result<Value, String> {
20640    #[derive(serde::Deserialize)]
20641    struct P {
20642        query: String,
20643        #[serde(default = "default_limit")]
20644        limit: usize,
20645        #[serde(default)]
20646        container_ids: Vec<String>,
20647    }
20648    fn default_limit() -> usize {
20649        50
20650    }
20651    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20652    car_ffi_common::integrations::contacts_list(&p.query, &p.container_ids, p.limit)
20653}
20654
20655fn handle_mail_inbox(req: &JsonRpcMessage) -> Result<Value, String> {
20656    #[derive(serde::Deserialize, Default)]
20657    struct P {
20658        #[serde(default)]
20659        account_ids: Vec<String>,
20660    }
20661    let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20662    car_ffi_common::integrations::mail_inbox(&p.account_ids)
20663}
20664
20665fn handle_mail_mailboxes(req: &JsonRpcMessage) -> Result<Value, String> {
20666    #[derive(serde::Deserialize, Default)]
20667    struct P {
20668        #[serde(default)]
20669        account_ids: Vec<String>,
20670    }
20671    let p: P = serde_json::from_value(req.params.clone()).unwrap_or_default();
20672    car_ffi_common::integrations::mail_mailboxes(&p.account_ids)
20673}
20674
20675/// The params ARE the `MessageQuery`, so they go through verbatim — every
20676/// field defaults, and `{}` reads the INBOX exactly as `mail.inbox` does.
20677/// Omitted params deserialize as `Value::Null`, which is not a `MessageQuery`;
20678/// treat that as the all-defaults query rather than a parse error.
20679fn handle_mail_messages(req: &JsonRpcMessage) -> Result<Value, String> {
20680    let raw = if req.params.is_null() {
20681        "{}".to_string()
20682    } else {
20683        req.params.to_string()
20684    };
20685    car_ffi_common::integrations::mail_messages(&raw)
20686}
20687
20688fn handle_mail_message_body(req: &JsonRpcMessage) -> Result<Value, String> {
20689    #[derive(serde::Deserialize)]
20690    struct P {
20691        message_id: String,
20692    }
20693    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20694    car_ffi_common::integrations::mail_message_body(&p.message_id)
20695}
20696
20697fn handle_mail_send(req: &JsonRpcMessage) -> Result<Value, String> {
20698    let raw = req.params.to_string();
20699    car_ffi_common::integrations::mail_send(&raw)
20700}
20701
20702fn handle_messages_chats(req: &JsonRpcMessage) -> Result<Value, String> {
20703    #[derive(serde::Deserialize)]
20704    struct P {
20705        #[serde(default = "default_limit")]
20706        limit: usize,
20707    }
20708    fn default_limit() -> usize {
20709        50
20710    }
20711    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20712    car_ffi_common::integrations::messages_chats(p.limit)
20713}
20714
20715/// The params object is the all-optional MessagesReadQuery. As with
20716/// `mail.messages`, omitted JSON-RPC params mean the default `{}` query.
20717fn handle_messages_read(req: &JsonRpcMessage) -> Result<Value, String> {
20718    let raw = if req.params.is_null() {
20719        "{}".to_string()
20720    } else {
20721        req.params.to_string()
20722    };
20723    car_ffi_common::integrations::messages_read(&raw)
20724}
20725
20726fn handle_messages_send(req: &JsonRpcMessage) -> Result<Value, String> {
20727    let raw = req.params.to_string();
20728    car_ffi_common::integrations::messages_send(&raw)
20729}
20730
20731#[cfg(test)]
20732mod personal_message_read_limit_tests {
20733    use super::{
20734        handle_mail_messages, handle_messages_read, json_rpc_response_for_handler_result,
20735        HandlerFailure, JsonRpcMessage,
20736    };
20737    use serde_json::{json, to_value};
20738
20739    #[test]
20740    fn daemon_rejects_over_cap_reads_as_invalid_params() {
20741        let request = JsonRpcMessage {
20742            jsonrpc: "2.0".into(),
20743            method: None,
20744            params: json!({"limit": car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP + 1}),
20745            id: json!(7),
20746            result: None,
20747            error: None,
20748        };
20749
20750        for (method, result) in [
20751            ("mail.messages", handle_mail_messages(&request)),
20752            ("messages.read", handle_messages_read(&request)),
20753        ] {
20754            let failure = HandlerFailure::from_dispatch(
20755                result.expect_err("an over-cap daemon read must be rejected"),
20756            );
20757            let response = to_value(json_rpc_response_for_handler_result(
20758                request.id.clone(),
20759                Err(failure),
20760            ))
20761            .expect("serialize JSON-RPC response");
20762
20763            assert_eq!(response["error"]["code"], -32602, "{method}");
20764            assert_eq!(
20765                response["error"]["message"],
20766                format!(
20767                    "invalid params: {method} limit must not exceed {}",
20768                    car_ffi_common::integrations::MESSAGE_READ_LIMIT_CAP
20769                )
20770            );
20771        }
20772    }
20773}
20774
20775fn handle_notes_find(req: &JsonRpcMessage) -> Result<Value, String> {
20776    #[derive(serde::Deserialize)]
20777    struct P {
20778        query: String,
20779        #[serde(default = "default_limit")]
20780        limit: usize,
20781    }
20782    fn default_limit() -> usize {
20783        50
20784    }
20785    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20786    car_ffi_common::integrations::notes_find(&p.query, p.limit)
20787}
20788
20789fn handle_reminders_items(req: &JsonRpcMessage) -> Result<Value, String> {
20790    #[derive(serde::Deserialize)]
20791    struct P {
20792        #[serde(default = "default_limit")]
20793        limit: usize,
20794    }
20795    fn default_limit() -> usize {
20796        50
20797    }
20798    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 50 });
20799    car_ffi_common::integrations::reminders_items(p.limit)
20800}
20801
20802fn handle_bookmarks_list(req: &JsonRpcMessage) -> Result<Value, String> {
20803    #[derive(serde::Deserialize)]
20804    struct P {
20805        #[serde(default = "default_limit")]
20806        limit: usize,
20807    }
20808    fn default_limit() -> usize {
20809        100
20810    }
20811    let p: P = serde_json::from_value(req.params.clone()).unwrap_or(P { limit: 100 });
20812    car_ffi_common::integrations::bookmarks_list(p.limit)
20813}
20814
20815fn handle_health_sleep(req: &JsonRpcMessage) -> Result<Value, String> {
20816    #[derive(serde::Deserialize)]
20817    struct P {
20818        start: String,
20819        end: String,
20820    }
20821    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20822    let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20823        .map_err(|e| format!("parse start: {}", e))?
20824        .with_timezone(&chrono::Utc);
20825    let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20826        .map_err(|e| format!("parse end: {}", e))?
20827        .with_timezone(&chrono::Utc);
20828    car_ffi_common::health::sleep_windows(s, e)
20829}
20830
20831fn handle_health_workouts(req: &JsonRpcMessage) -> Result<Value, String> {
20832    #[derive(serde::Deserialize)]
20833    struct P {
20834        start: String,
20835        end: String,
20836    }
20837    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20838    let s = chrono::DateTime::parse_from_rfc3339(&p.start)
20839        .map_err(|e| format!("parse start: {}", e))?
20840        .with_timezone(&chrono::Utc);
20841    let e = chrono::DateTime::parse_from_rfc3339(&p.end)
20842        .map_err(|e| format!("parse end: {}", e))?
20843        .with_timezone(&chrono::Utc);
20844    car_ffi_common::health::workouts(s, e)
20845}
20846
20847fn handle_health_activity(req: &JsonRpcMessage) -> Result<Value, String> {
20848    #[derive(serde::Deserialize)]
20849    struct P {
20850        start: String,
20851        end: String,
20852    }
20853    let p: P = serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20854    let s = chrono::NaiveDate::parse_from_str(&p.start, "%Y-%m-%d")
20855        .map_err(|e| format!("parse start: {}", e))?;
20856    let e = chrono::NaiveDate::parse_from_str(&p.end, "%Y-%m-%d")
20857        .map_err(|e| format!("parse end: {}", e))?;
20858    car_ffi_common::health::activity(s, e)
20859}
20860
20861async fn handle_browser_close(session: &crate::session::ClientSession) -> Result<Value, String> {
20862    let closed = session.browser.close().await?;
20863    Ok(serde_json::json!({"closed": closed}))
20864}
20865
20866async fn handle_browser_run(
20867    req: &JsonRpcMessage,
20868    session: &crate::session::ClientSession,
20869) -> Result<Value, String> {
20870    #[derive(serde::Deserialize)]
20871    struct BrowserRunParams {
20872        /// Inline JSON string (CLI-compatible), OR the structured object.
20873        script: Value,
20874        #[serde(default)]
20875        width: Option<u32>,
20876        #[serde(default)]
20877        height: Option<u32>,
20878        /// When true, launches a visible Chromium window for interactive
20879        /// flows (first-time auth, 2FA, supervised runs). Only honored on
20880        /// the call that first launches the browser session — subsequent
20881        /// calls reuse the existing browser regardless.
20882        #[serde(default)]
20883        headed: Option<bool>,
20884        /// Extra Chromium command-line flags appended verbatim at
20885        /// launch (#112). Honoured only on the launch call.
20886        #[serde(default)]
20887        extra_args: Option<Vec<String>>,
20888    }
20889    let params: BrowserRunParams =
20890        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20891
20892    // Validate before launching Chromium. The wire contract accepts an inline
20893    // JSON string or a structured object, not arbitrary JSON scalars/arrays.
20894    // Launching first made malformed calls wait on browser startup before the
20895    // script parser could reject them.
20896    let script_json = browser_script_json(params.script)?;
20897
20898    let browser_session = session
20899        .browser
20900        .get_or_launch(car_ffi_common::browser::BrowserLaunchOptions {
20901            width: params.width.unwrap_or(1280),
20902            height: params.height.unwrap_or(720),
20903            headless: !params.headed.unwrap_or(false),
20904            extra_args: params.extra_args.unwrap_or_default(),
20905            // `browser.run` is a per-CONNECTION browser and stays fully
20906            // independent of the drawer's and the agents': no persistent
20907            // profile, so nothing it does can collide with theirs, and
20908            // nothing they do can repoint it.
20909            profile_dir: None,
20910        })
20911        .await?;
20912
20913    let trace_json = browser_session.run(&script_json).await?;
20914    serde_json::from_str(&trace_json).map_err(|e| e.to_string())
20915}
20916
20917fn browser_script_json(script: Value) -> Result<String, String> {
20918    match script {
20919        Value::String(script) => Ok(script),
20920        script @ Value::Object(_) => Ok(script.to_string()),
20921        _ => Err("browser.run script must be a string or object".to_string()),
20922    }
20923}
20924
20925#[cfg(test)]
20926mod browser_run_script_tests {
20927    use super::browser_script_json;
20928    use serde_json::{json, Value};
20929
20930    #[test]
20931    fn accepts_only_the_documented_script_shapes_before_browser_launch() {
20932        assert_eq!(
20933            browser_script_json(Value::String(r#"{"operations":[]}"#.to_string()))
20934                .expect("inline JSON string"),
20935            r#"{"operations":[]}"#
20936        );
20937        assert_eq!(
20938            serde_json::from_str::<Value>(
20939                &browser_script_json(json!({"operations": []})).expect("structured object")
20940            )
20941            .expect("serialized object"),
20942            json!({"operations": []})
20943        );
20944
20945        for unsupported in [Value::Null, json!(1), json!(true), json!([])] {
20946            assert_eq!(
20947                browser_script_json(unsupported).expect_err("unsupported JSON shape"),
20948                "browser.run script must be a string or object"
20949            );
20950        }
20951    }
20952}
20953
20954// ---------------------------------------------------------------------------
20955// Voice streaming JSON-RPC methods
20956//
20957// Events are pushed back to the originating client as JSON-RPC notifications:
20958//   { "jsonrpc": "2.0", "method": "voice.event",
20959//     "params": { "session_id": "...", "event": {...} } }
20960//
20961// The session registry is process-wide (ServerState.voice_sessions); per-call
20962// WsVoiceEventSink instances bind each session to its originating WS so a
20963// client only ever sees events for sessions it started.
20964// ---------------------------------------------------------------------------
20965
20966#[derive(Deserialize)]
20967struct VoiceStartParams {
20968    session_id: String,
20969    audio_source: Value,
20970    #[serde(default)]
20971    options: Option<Value>,
20972}
20973
20974async fn handle_voice_transcribe_stream_start(
20975    req: &JsonRpcMessage,
20976    state: &Arc<ServerState>,
20977    session: &Arc<crate::session::ClientSession>,
20978) -> Result<Value, String> {
20979    let params: VoiceStartParams =
20980        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
20981    let audio_source_json =
20982        serde_json::to_string(&params.audio_source).map_err(|e| e.to_string())?;
20983    let options_json = params
20984        .options
20985        .as_ref()
20986        .map(|v| serde_json::to_string(v).map_err(|e| e.to_string()))
20987        .transpose()?;
20988    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
20989        channel: session.channel.clone(),
20990    });
20991    let json = car_ffi_common::voice::transcribe_stream_start(
20992        &params.session_id,
20993        &audio_source_json,
20994        options_json.as_deref(),
20995        state.voice_sessions.clone(),
20996        sink,
20997    )
20998    .await?;
20999    serde_json::from_str(&json).map_err(|e| e.to_string())
21000}
21001
21002#[derive(Deserialize)]
21003struct VoiceStopParams {
21004    session_id: String,
21005}
21006
21007async fn handle_voice_transcribe_stream_stop(
21008    req: &JsonRpcMessage,
21009    state: &Arc<ServerState>,
21010) -> Result<Value, String> {
21011    let params: VoiceStopParams =
21012        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21013    let json = car_ffi_common::voice::transcribe_stream_stop(
21014        &params.session_id,
21015        state.voice_sessions.clone(),
21016    )
21017    .await?;
21018    serde_json::from_str(&json).map_err(|e| e.to_string())
21019}
21020
21021#[derive(Deserialize)]
21022struct VoicePushParams {
21023    session_id: String,
21024    /// Base64-encoded 16-bit signed PCM frame. JSON-RPC is text, so binary
21025    /// audio frames have to be encoded; clients in WS-binary contexts that
21026    /// want to skip the round trip can call the FFI directly.
21027    pcm_b64: String,
21028}
21029
21030async fn handle_voice_transcribe_stream_push(
21031    req: &JsonRpcMessage,
21032    state: &Arc<ServerState>,
21033) -> Result<Value, String> {
21034    use base64::Engine;
21035    let params: VoicePushParams =
21036        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21037    let pcm = base64::engine::general_purpose::STANDARD
21038        .decode(&params.pcm_b64)
21039        .map_err(|e| format!("invalid pcm_b64: {}", e))?;
21040    let json = car_ffi_common::voice::transcribe_stream_push(
21041        &params.session_id,
21042        &pcm,
21043        state.voice_sessions.clone(),
21044    )
21045    .await?;
21046    serde_json::from_str(&json).map_err(|e| e.to_string())
21047}
21048
21049fn handle_voice_sessions_list(state: &Arc<ServerState>) -> Value {
21050    let json = car_ffi_common::voice::list_voice_sessions(state.voice_sessions.clone());
21051    serde_json::from_str(&json).unwrap_or(Value::Null)
21052}
21053
21054#[derive(Deserialize)]
21055struct VoiceTtsStreamStartParams {
21056    /// Caller-chosen opaque id for this stream. Used both as the
21057    /// session_id wrapped onto each `voice.event` notification AND as
21058    /// the key for `voice.tts_stream.cancel`.
21059    stream_id: String,
21060    /// Text to synthesize. Splitting into multiple synth calls (for
21061    /// long-form narration) is the caller's responsibility.
21062    text: String,
21063    /// Optional [`car_ffi_common::voice::TtsStreamOptions`] as a raw
21064    /// JSON value (provider, voice_id, binary_frames).
21065    #[serde(default)]
21066    options: Option<Value>,
21067}
21068
21069async fn handle_voice_tts_stream_start(
21070    req: &JsonRpcMessage,
21071    session: &Arc<crate::session::ClientSession>,
21072) -> Result<Value, String> {
21073    let params: VoiceTtsStreamStartParams =
21074        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21075    let opts_str = params
21076        .options
21077        .as_ref()
21078        .map(|v| v.to_string())
21079        .filter(|s| !s.is_empty());
21080    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
21081        channel: session.channel.clone(),
21082    });
21083    let json = car_ffi_common::voice::tts_stream_start(
21084        &params.stream_id,
21085        &params.text,
21086        opts_str.as_deref(),
21087        sink,
21088    )
21089    .await?;
21090    serde_json::from_str(&json).map_err(|e| e.to_string())
21091}
21092
21093#[derive(Deserialize)]
21094struct VoiceTtsStreamCancelParams {
21095    stream_id: String,
21096}
21097
21098async fn handle_voice_tts_stream_cancel(req: &JsonRpcMessage) -> Result<Value, String> {
21099    let params: VoiceTtsStreamCancelParams =
21100        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21101    let json = car_ffi_common::voice::tts_stream_cancel(&params.stream_id).await?;
21102    serde_json::from_str(&json).map_err(|e| e.to_string())
21103}
21104
21105fn handle_voice_tts_stream_list() -> Value {
21106    let json = car_ffi_common::voice::list_tts_streams();
21107    serde_json::from_str(&json).unwrap_or(Value::Null)
21108}
21109
21110async fn handle_voice_dispatch_turn(
21111    req: &JsonRpcMessage,
21112    state: &Arc<ServerState>,
21113    session: &Arc<crate::session::ClientSession>,
21114) -> Result<Value, String> {
21115    let req_value = req.params.clone();
21116    let request: crate::voice_turn::DispatchVoiceTurnRequest =
21117        serde_json::from_value(req_value).map_err(|e| e.to_string())?;
21118    let engine = get_inference_engine(state).clone();
21119    let sink: Arc<dyn car_voice::VoiceEventSink> = Arc::new(crate::session::WsVoiceEventSink {
21120        channel: session.channel.clone(),
21121    });
21122    let resp = crate::voice_turn::dispatch(engine, request, sink).await?;
21123    serde_json::to_value(resp).map_err(|e| e.to_string())
21124}
21125
21126async fn handle_voice_cancel_turn() -> Result<Value, String> {
21127    crate::voice_turn::cancel().await;
21128    Ok(serde_json::json!({"cancelled": true}))
21129}
21130
21131async fn handle_voice_prewarm_turn(state: &Arc<ServerState>) -> Result<Value, String> {
21132    let engine = get_inference_engine(state).clone();
21133    crate::voice_turn::prewarm(engine).await;
21134    Ok(serde_json::json!({"prewarmed": true}))
21135}
21136
21137// ---------------------------------------------------------------------------
21138// Inference runner over WebSocket — closes Parslee-ai/car-releases#24
21139//
21140// Bidirectional protocol shape:
21141//   1. Client → server: `inference.register_runner` (no params). The
21142//      session that calls this becomes the host for delegated models.
21143//   2. Server → client: `inference.runner.invoke` notification with
21144//      {call_id, request} when CAR needs to dispatch a delegated turn.
21145//   3. Client → server: `inference.runner.event` with {call_id, event}
21146//      for each chunk; `inference.runner.complete` with {call_id, result}
21147//      on success; `inference.runner.fail` with {call_id, error} on
21148//      failure.
21149//
21150// The server-side data is process-wide because only one inference
21151// runner can be registered at a time (matches the FFI bindings'
21152// constraint). The per-call mailboxes live in dedicated DashMaps.
21153// ---------------------------------------------------------------------------
21154
21155/// Runners keyed by the `client_id` of the session that registered them.
21156///
21157/// Replaces a single process-global slot that the last registrant overwrote —
21158/// so two hosts sharing one daemon dispatched each other's delegated calls, and
21159/// the loser saw `no bridge context for <uuid>` (Parslee-ai/car-releases#77).
21160///
21161/// This is ROUTING only. It is deliberately NOT a concurrency control: bounding
21162/// how many generations run at once is `crate::admission`'s job, which sizes
21163/// permits from host RAM. The old singleton conflated the two, and as a resource
21164/// guard it was both redundant and worse than admission — a hard global 1
21165/// regardless of how much memory the machine actually had.
21166fn ws_runner_sessions() -> &'static dashmap::DashMap<String, Arc<crate::session::WsChannel>> {
21167    static MAP: std::sync::OnceLock<dashmap::DashMap<String, Arc<crate::session::WsChannel>>> =
21168        std::sync::OnceLock::new();
21169    MAP.get_or_init(dashmap::DashMap::new)
21170}
21171
21172/// The runner that serves calls whose originator registered none of its own.
21173///
21174/// Preserves the pre-#77 use case: a dedicated runner process serving calls that
21175/// originate elsewhere (a CLI invocation, a scheduled agent). Last registrant
21176/// wins here, as before — but now only for unclaimed traffic, not for calls a
21177/// host made itself.
21178fn ws_runner_fallback() -> &'static std::sync::RwLock<Option<Arc<crate::session::WsChannel>>> {
21179    static SLOT: std::sync::OnceLock<std::sync::RwLock<Option<Arc<crate::session::WsChannel>>>> =
21180        std::sync::OnceLock::new();
21181    SLOT.get_or_init(|| std::sync::RwLock::new(None))
21182}
21183
21184/// Resolve the channel for a delegated call: the caller's own runner when it has
21185/// one, else the fallback.
21186fn resolve_runner_channel(caller: Option<&str>) -> Option<Arc<crate::session::WsChannel>> {
21187    if let Some(id) = caller {
21188        if let Some(entry) = ws_runner_sessions().get(id) {
21189            return Some(entry.value().clone());
21190        }
21191    }
21192    ws_runner_fallback().read().ok().and_then(|g| g.clone())
21193}
21194
21195fn ws_runner_calls() -> &'static dashmap::DashMap<String, car_inference::EventEmitter> {
21196    static MAP: std::sync::OnceLock<dashmap::DashMap<String, car_inference::EventEmitter>> =
21197        std::sync::OnceLock::new();
21198    MAP.get_or_init(dashmap::DashMap::new)
21199}
21200
21201fn ws_runner_completions() -> &'static dashmap::DashMap<
21202    String,
21203    tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
21204> {
21205    static MAP: std::sync::OnceLock<
21206        dashmap::DashMap<
21207            String,
21208            tokio::sync::oneshot::Sender<std::result::Result<car_inference::RunnerResult, String>>,
21209        >,
21210    > = std::sync::OnceLock::new();
21211    MAP.get_or_init(dashmap::DashMap::new)
21212}
21213
21214struct WsInferenceRunner;
21215
21216#[async_trait::async_trait]
21217impl car_inference::InferenceRunner for WsInferenceRunner {
21218    async fn run(
21219        &self,
21220        request: car_inference::tasks::generate::GenerateRequest,
21221        emitter: car_inference::EventEmitter,
21222    ) -> std::result::Result<car_inference::RunnerResult, car_inference::RunnerError> {
21223        // Route to the runner the ORIGINATING session registered; fall back to
21224        // the global one only when it registered none (car-releases#77).
21225        let channel = resolve_runner_channel(request.caller.as_deref()).ok_or_else(|| {
21226            car_inference::RunnerError::Declined(
21227                "no WebSocket inference runner registered — call inference.register_runner first"
21228                    .into(),
21229            )
21230        })?;
21231
21232        let call_id = uuid::Uuid::new_v4().to_string();
21233        let request_json = serde_json::to_value(&request)
21234            .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
21235        let (tx, rx) = tokio::sync::oneshot::channel();
21236        ws_runner_calls().insert(call_id.clone(), emitter);
21237        ws_runner_completions().insert(call_id.clone(), tx);
21238
21239        // Fire the invoke notification.
21240        use futures::SinkExt;
21241        let notification = serde_json::json!({
21242            "jsonrpc": "2.0",
21243            "method": "inference.runner.invoke",
21244            "params": {
21245                "call_id": call_id,
21246                "request": request_json,
21247            },
21248        });
21249        let text = serde_json::to_string(&notification)
21250            .map_err(|e| car_inference::RunnerError::Failed(e.to_string()))?;
21251        let _ = channel
21252            .write
21253            .lock()
21254            .await
21255            .send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
21256            .await;
21257
21258        let result = rx.await.map_err(|_| {
21259            car_inference::RunnerError::Failed("runner completion channel dropped".into())
21260        })?;
21261        ws_runner_calls().remove(&call_id);
21262        result.map_err(car_inference::RunnerError::Failed)
21263    }
21264}
21265
21266async fn handle_inference_register_runner(
21267    session: &Arc<crate::session::ClientSession>,
21268) -> Result<Value, String> {
21269    ws_runner_sessions().insert(session.client_id.clone(), session.channel.clone());
21270    // Also the fallback for calls whose originator registered no runner. Last
21271    // registrant wins here, as before — but that now only affects unclaimed
21272    // traffic, not another host's own calls.
21273    let mut guard = ws_runner_fallback()
21274        .write()
21275        .map_err(|e| format!("ws runner slot poisoned: {e}"))?;
21276    let displaced = guard.is_some();
21277    *guard = Some(session.channel.clone());
21278    drop(guard);
21279    car_inference::set_inference_runner(Some(Arc::new(WsInferenceRunner)));
21280    // Report what happened. A bare `{"registered": true}` to a second registrant
21281    // was indistinguishable from being the only one, which is how two hosts
21282    // silently stole each other's calls (car-releases#77).
21283    Ok(serde_json::json!({
21284        "registered": true,
21285        "scope": "session",
21286        "runners_registered": ws_runner_sessions().len(),
21287        "became_fallback": true,
21288        "displaced_fallback": displaced,
21289    }))
21290}
21291
21292#[derive(serde::Deserialize)]
21293struct InferenceRunnerEventParams {
21294    call_id: String,
21295    event: Value,
21296}
21297
21298async fn handle_inference_runner_event(req: &JsonRpcMessage) -> Result<Value, String> {
21299    let params: InferenceRunnerEventParams =
21300        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21301    let stream_event = match parse_runner_event_value(&params.event) {
21302        Some(e) => e,
21303        None => return Err("unrecognised runner event shape".into()),
21304    };
21305    if let Some(entry) = ws_runner_calls().get(&params.call_id) {
21306        let emitter = entry.value().clone();
21307        tokio::spawn(async move { emitter.emit(stream_event).await });
21308    }
21309    Ok(serde_json::json!({"emitted": true}))
21310}
21311
21312#[derive(serde::Deserialize)]
21313struct InferenceRunnerCompleteParams {
21314    call_id: String,
21315    result: Value,
21316}
21317
21318async fn handle_inference_runner_complete(req: &JsonRpcMessage) -> Result<Value, String> {
21319    let params: InferenceRunnerCompleteParams =
21320        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21321    let result: std::result::Result<car_inference::RunnerResult, String> =
21322        serde_json::from_value(params.result)
21323            .map_err(|e| format!("invalid RunnerResult JSON: {e}"));
21324    if let Some((_, tx)) = ws_runner_completions().remove(&params.call_id) {
21325        let _ = tx.send(result);
21326    }
21327    Ok(serde_json::json!({"completed": true}))
21328}
21329
21330#[derive(serde::Deserialize)]
21331struct InferenceRunnerFailParams {
21332    call_id: String,
21333    error: String,
21334}
21335
21336async fn handle_inference_runner_fail(req: &JsonRpcMessage) -> Result<Value, String> {
21337    let params: InferenceRunnerFailParams =
21338        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21339    if let Some((_, tx)) = ws_runner_completions().remove(&params.call_id) {
21340        let _ = tx.send(Err(params.error));
21341    }
21342    Ok(serde_json::json!({"failed": true}))
21343}
21344
21345fn parse_runner_event_value(v: &Value) -> Option<car_inference::StreamEvent> {
21346    let ty = v.get("type").and_then(|t| t.as_str())?;
21347    match ty {
21348        "text" => Some(car_inference::StreamEvent::TextDelta(
21349            v.get("data")?.as_str()?.to_string(),
21350        )),
21351        "tool_start" => Some(car_inference::StreamEvent::ToolCallStart {
21352            name: v.get("name")?.as_str()?.to_string(),
21353            index: v.get("index")?.as_u64()? as usize,
21354            id: v.get("id").and_then(|i| i.as_str()).map(str::to_string),
21355        }),
21356        "tool_delta" => Some(car_inference::StreamEvent::ToolCallDelta {
21357            index: v.get("index")?.as_u64()? as usize,
21358            arguments_delta: v.get("data")?.as_str()?.to_string(),
21359        }),
21360        "usage" => Some(car_inference::StreamEvent::Usage {
21361            input_tokens: v.get("input_tokens")?.as_u64()?,
21362            output_tokens: v.get("output_tokens")?.as_u64()?,
21363            cache_read_input_tokens: v
21364                .get("cache_read_input_tokens")
21365                .and_then(|x| x.as_u64())
21366                .unwrap_or(0),
21367            cache_creation_input_tokens: v
21368                .get("cache_creation_input_tokens")
21369                .and_then(|x| x.as_u64())
21370                .unwrap_or(0),
21371        }),
21372        "provider_output_item" => Some(car_inference::StreamEvent::ProviderOutputItem(
21373            v.get("item")?.clone(),
21374        )),
21375        "error" => Some(car_inference::StreamEvent::Error(
21376            v.get("message")?.as_str()?.to_string(),
21377        )),
21378        "done" => Some(car_inference::StreamEvent::Done {
21379            text: v.get("text")?.as_str()?.to_string(),
21380            tool_calls: v
21381                .get("tool_calls")
21382                .and_then(|tc| serde_json::from_value(tc.clone()).ok())
21383                .unwrap_or_default(),
21384        }),
21385        _ => None,
21386    }
21387}
21388
21389#[derive(Deserialize)]
21390struct EnrollSpeakerParams {
21391    label: String,
21392    audio: Value,
21393}
21394
21395async fn handle_enroll_speaker(req: &JsonRpcMessage) -> Result<Value, String> {
21396    let params: EnrollSpeakerParams =
21397        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21398    let audio_json = serde_json::to_string(&params.audio).map_err(|e| e.to_string())?;
21399    let json = car_ffi_common::voice::enroll_speaker(&params.label, &audio_json).await?;
21400    serde_json::from_str(&json).map_err(|e| e.to_string())
21401}
21402
21403#[derive(Deserialize)]
21404struct RemoveEnrollmentParams {
21405    label: String,
21406}
21407
21408fn handle_remove_enrollment(req: &JsonRpcMessage) -> Result<Value, String> {
21409    let params: RemoveEnrollmentParams =
21410        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21411    let json = car_ffi_common::voice::remove_enrollment(&params.label)?;
21412    serde_json::from_str(&json).map_err(|e| e.to_string())
21413}
21414
21415#[derive(Deserialize)]
21416struct WorkflowRunParams {
21417    workflow: Value,
21418    /// Optional state map seeded into workflow state before the run starts —
21419    /// the inter-workflow chaining hook (hand a prior run's `final_state` to
21420    /// the next workflow). Omitted = prior behavior.
21421    #[serde(default)]
21422    initial_state: Option<std::collections::HashMap<String, Value>>,
21423}
21424
21425async fn handle_workflow_run(
21426    req: &JsonRpcMessage,
21427    session: &Arc<crate::session::ClientSession>,
21428) -> Result<Value, String> {
21429    let params: WorkflowRunParams =
21430        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21431    let workflow_json = serde_json::to_string(&params.workflow).map_err(|e| e.to_string())?;
21432    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21433        channel: session.channel.clone(),
21434        host: session.host.clone(),
21435        client_id: session.client_id.clone(),
21436    });
21437    let json = car_ffi_common::workflow::run_workflow(&workflow_json, params.initial_state, runner)
21438        .await?;
21439    let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21440    // If the run parked at an approval gate, persist the checkpoint durably so
21441    // it survives a daemon restart and can be resumed by run_id.
21442    persist_if_paused(&result)?;
21443    Ok(result)
21444}
21445
21446#[derive(Deserialize)]
21447struct WorkflowChainParams {
21448    /// Workflow definitions run sequentially: each next workflow's initial
21449    /// state is the previous result's `final_state` merged over
21450    /// `initial_state`.
21451    workflows: Vec<Value>,
21452    /// Optional base state seeded into every workflow in the chain (the first
21453    /// workflow gets exactly this; later ones get it merged under the
21454    /// predecessor's `final_state`).
21455    #[serde(default)]
21456    initial_state: Option<std::collections::HashMap<String, Value>>,
21457}
21458
21459/// `workflow.chain` — run workflows sequentially, threading each result's
21460/// `final_state` into the next workflow's initial state. Every workflow is
21461/// statically pre-validated before any executes (structural garbage rejects
21462/// the chain up front, as an error). Stops at the first non-`completed`
21463/// result, returning the results so far plus the stopping status; a paused
21464/// intermediate persists its checkpoint durably exactly like `workflow.run`
21465/// (resume it via `workflow.resume` by `run_id`, then re-chain the remainder
21466/// if desired) and is named by `paused_at_index`. A mid-chain *runtime*
21467/// engine error (e.g. cycle limit) does not discard the chain: the response
21468/// still carries `results` so far (delivery evidence included) plus
21469/// top-level `error` and `failed_at_index`.
21470async fn handle_workflow_chain(
21471    req: &JsonRpcMessage,
21472    session: &Arc<crate::session::ClientSession>,
21473) -> Result<Value, String> {
21474    let params: WorkflowChainParams =
21475        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21476    let workflows_json = serde_json::to_string(&params.workflows).map_err(|e| e.to_string())?;
21477    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21478        channel: session.channel.clone(),
21479        host: session.host.clone(),
21480        client_id: session.client_id.clone(),
21481    });
21482    let json =
21483        car_ffi_common::workflow::chain_workflows(&workflows_json, params.initial_state, runner)
21484            .await?;
21485    let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
21486    // Only the stopping workflow can be paused (the chain halts there);
21487    // persist its checkpoint durably so workflow.resume can pick it up.
21488    if let Some(last) = result
21489        .get("results")
21490        .and_then(|r| r.as_array())
21491        .and_then(|r| r.last())
21492    {
21493        persist_if_paused(last)?;
21494    }
21495    Ok(result)
21496}
21497
21498/// Re-arm workflow runs orphaned by a crash between an approval `claim` and its
21499/// `complete`. Call **once at daemon startup**, before serving connections, so a
21500/// restart mid-approval doesn't bury paused runs. Best-effort: logs and returns
21501/// on any error rather than failing boot.
21502pub fn recover_workflow_checkpoints() {
21503    let dir = match workflow_runs_dir() {
21504        Ok(d) => d,
21505        Err(e) => {
21506            tracing::debug!(error = %e, "no workflow checkpoint dir; skipping recovery");
21507            return;
21508        }
21509    };
21510    match car_workflow::CheckpointStore::open(&dir).and_then(|s| s.recover_orphaned()) {
21511        Ok(0) => {}
21512        Ok(n) => tracing::info!(rearmed = n, "recovered orphaned workflow checkpoints"),
21513        Err(e) => tracing::warn!(error = %e, "workflow checkpoint recovery failed"),
21514    }
21515}
21516
21517/// Directory holding durable workflow checkpoints: `workflow-runs` under the
21518/// CAR state root — `CAR_HOME` when set, otherwise `~/.car/workflow-runs`.
21519fn workflow_runs_dir() -> Result<std::path::PathBuf, String> {
21520    let root = car_home::root()
21521        .ok_or_else(|| "cannot resolve home directory for workflow checkpoints".to_string())?;
21522    Ok(root.join("workflow-runs"))
21523}
21524
21525/// If `result` is a paused WorkflowResult, save its checkpoint to the store.
21526fn persist_if_paused(result: &Value) -> Result<(), String> {
21527    if result.get("status").and_then(|s| s.as_str()) != Some("paused") {
21528        return Ok(());
21529    }
21530    let Some(paused_value) = result.get("paused") else {
21531        return Err("paused result missing checkpoint".to_string());
21532    };
21533    let paused: car_workflow::PausedWorkflow =
21534        serde_json::from_value(paused_value.clone()).map_err(|e| e.to_string())?;
21535    let store =
21536        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21537    store.save(&paused).map_err(|e| e.to_string())
21538}
21539
21540#[derive(Deserialize)]
21541struct WorkflowResumeParams {
21542    run_id: String,
21543    #[serde(default)]
21544    input: Value,
21545}
21546
21547/// `workflow.list_paused` — list resumable workflow runs with their pause
21548/// metadata (EPIC H / H1). The discovery half of durable resume: after a daemon
21549/// restart a client no longer holds the paused `run_id`s, so it enumerates them
21550/// here (each `{run_id, paused_stage_id, prompt, created_at}`) and resumes by
21551/// `run_id` via `workflow.resume`. In-flight and corrupt checkpoints are
21552/// omitted — only genuinely resumable runs are returned.
21553async fn handle_workflow_list_paused() -> Result<Value, String> {
21554    let store =
21555        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21556    let summaries = store.list_summaries().map_err(|e| e.to_string())?;
21557    serde_json::to_value(&summaries).map_err(|e| format!("serialize list_paused: {e}"))
21558}
21559
21560async fn handle_workflow_resume(
21561    req: &JsonRpcMessage,
21562    session: &Arc<crate::session::ClientSession>,
21563) -> Result<Value, String> {
21564    let params: WorkflowResumeParams =
21565        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21566
21567    let store =
21568        car_workflow::CheckpointStore::open(workflow_runs_dir()?).map_err(|e| e.to_string())?;
21569    // Atomic claim: a duplicate/racing resume of the same run gets nothing,
21570    // so a side-effecting downstream stage never runs twice.
21571    let paused = store
21572        .claim(&params.run_id)
21573        .map_err(|e| e.to_string())?
21574        .ok_or_else(|| {
21575            format!(
21576                "no paused workflow run '{}' (already resumed or unknown)",
21577                params.run_id
21578            )
21579        })?;
21580    let paused_json = serde_json::to_string(&paused).map_err(|e| e.to_string())?;
21581    let input_json = if params.input.is_null() {
21582        "{}".to_string()
21583    } else {
21584        serde_json::to_string(&params.input).map_err(|e| e.to_string())?
21585    };
21586
21587    let runner: Arc<dyn car_multi::AgentRunner> = Arc::new(WsAgentRunner {
21588        channel: session.channel.clone(),
21589        host: session.host.clone(),
21590        client_id: session.client_id.clone(),
21591    });
21592
21593    let json = car_ffi_common::workflow::resume_workflow(&paused_json, &input_json, runner).await;
21594    let result = match json {
21595        Ok(j) => serde_json::from_str::<Value>(&j).map_err(|e| e.to_string())?,
21596        Err(e) => {
21597            // Resume failed (e.g. invalid input) — release the in-flight marker
21598            // so the run can be resumed again with corrected input.
21599            let _ = store.save(&paused);
21600            let _ = store.complete(&params.run_id);
21601            return Err(e);
21602        }
21603    };
21604    // Re-paused at another gate → persist the fresh checkpoint; either way drop
21605    // the in-flight marker from the claim.
21606    persist_if_paused(&result)?;
21607    store.complete(&params.run_id).map_err(|e| e.to_string())?;
21608    Ok(result)
21609}
21610
21611#[derive(Deserialize)]
21612struct BuilderBuildParams {
21613    goal: String,
21614    #[serde(default)]
21615    existing: Value,
21616    #[serde(default = "default_builder_attempts")]
21617    max_attempts: u32,
21618}
21619
21620fn default_builder_attempts() -> u32 {
21621    3
21622}
21623
21624/// `builder.build` — natural language → validated workflow. Runs on the daemon so
21625/// the catalog is authoritative: tools come from this session's registered tool
21626/// schemas and models from the inference registry, making the builder's
21627/// tool-existence cross-check meaningful.
21628async fn handle_builder_build(
21629    req: &JsonRpcMessage,
21630    state: &Arc<ServerState>,
21631    session: &Arc<crate::session::ClientSession>,
21632) -> Result<Value, String> {
21633    let params: BuilderBuildParams =
21634        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21635    if params.goal.trim().is_empty() {
21636        return Err("missing 'goal'".to_string());
21637    }
21638
21639    let engine = get_inference_engine(state).clone();
21640
21641    let tools: Vec<car_builder::ToolInfo> = session
21642        .runtime
21643        .registry
21644        .schemas()
21645        .await
21646        .into_iter()
21647        .map(|s| car_builder::ToolInfo {
21648            name: s.name,
21649            description: s.description,
21650        })
21651        .collect();
21652    let models: Vec<String> = engine
21653        .list_models_unified()
21654        .into_iter()
21655        .map(|m| m.id)
21656        .collect();
21657    let catalog = car_builder::ToolCatalog {
21658        tools,
21659        models,
21660        agents: Vec::new(),
21661    };
21662
21663    let existing = if params.existing.is_null() {
21664        None
21665    } else {
21666        serde_json::from_value::<car_workflow::Workflow>(params.existing.clone()).ok()
21667    };
21668
21669    let build_req = car_builder::BuildRequest {
21670        goal: params.goal,
21671        catalog,
21672        existing,
21673        feedback: None,
21674        max_attempts: params.max_attempts,
21675    };
21676
21677    let result = car_builder::build_workflow(
21678        |prompt: String| {
21679            let engine = engine.clone();
21680            async move {
21681                let greq = car_inference::GenerateRequest {
21682                    prompt,
21683                    model: None,
21684                    expected_row_digest: None,
21685                    expected_catalog_revision: None,
21686                    params: car_inference::GenerateParams {
21687                        temperature: 0.2,
21688                        max_tokens: 4096,
21689                        ..Default::default()
21690                    },
21691                    context: None,
21692                    context_stable_prefix: None,
21693                    tools: None,
21694                    images: None,
21695                    messages: None,
21696                    cache_control: false,
21697                    response_format: None,
21698                    intent: None,
21699                    client_ref: None,
21700                    caller: None,
21701                };
21702                engine
21703                    .generate_tracked(greq)
21704                    .await
21705                    .map(|r| r.text)
21706                    .map_err(|e| e.to_string())
21707            }
21708        },
21709        &build_req,
21710    )
21711    .await;
21712
21713    Ok(serde_json::json!({
21714        "valid": result.valid,
21715        "workflow": result.workflow,
21716        "issues": result.issues,
21717        "warnings": result.warnings,
21718        "attempts": result.attempts,
21719    }))
21720}
21721
21722#[derive(Deserialize)]
21723struct WorkflowVerifyParams {
21724    workflow: Value,
21725}
21726
21727fn handle_workflow_verify(req: &JsonRpcMessage) -> Result<Value, String> {
21728    let params: WorkflowVerifyParams =
21729        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21730    let workflow_json = serde_json::to_string(&params.workflow).map_err(|e| e.to_string())?;
21731    let json = car_ffi_common::workflow::verify_workflow(&workflow_json)?;
21732    serde_json::from_str(&json).map_err(|e| e.to_string())
21733}
21734
21735#[derive(Deserialize)]
21736struct WorkflowBuildAutomationParams {
21737    spec: Value,
21738}
21739
21740/// Lower an external-item `AutomationSpec` (poll → dedup → per-item agent →
21741/// deliver) into a runnable workflow definition. Stateless; the caller hands the
21742/// returned workflow back to `workflow.run` (typically on a schedule).
21743fn handle_workflow_build_automation(req: &JsonRpcMessage) -> Result<Value, String> {
21744    let params: WorkflowBuildAutomationParams =
21745        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21746    let spec_json = serde_json::to_string(&params.spec).map_err(|e| e.to_string())?;
21747    let json = car_ffi_common::workflow::build_automation_workflow(&spec_json)?;
21748    serde_json::from_str(&json).map_err(|e| e.to_string())
21749}
21750
21751// ---------------------------------------------------------------------------
21752// Meeting JSON-RPC methods
21753// ---------------------------------------------------------------------------
21754
21755async fn handle_meeting_start(
21756    req: &JsonRpcMessage,
21757    state: &Arc<ServerState>,
21758    session: &Arc<crate::session::ClientSession>,
21759) -> Result<Value, String> {
21760    // We need the meeting id BEFORE handing the upstream sink to
21761    // start_meeting so the WsMemgineIngestSink stamps transcripts with
21762    // the correct `meeting/<id>/<source>` speaker. Parse the request
21763    // here, mint an id if none was provided, and pass the same id
21764    // through to start_meeting via the request JSON.
21765    let mut req_value = req.params.clone();
21766    let meeting_id = req_value
21767        .get("id")
21768        .and_then(|v| v.as_str())
21769        .map(str::to_string)
21770        .unwrap_or_else(|| uuid::Uuid::new_v4().simple().to_string());
21771    if let Some(map) = req_value.as_object_mut() {
21772        map.insert("id".into(), Value::String(meeting_id.clone()));
21773    }
21774    let request_json = serde_json::to_string(&req_value).map_err(|e| e.to_string())?;
21775
21776    let ws_upstream: Arc<dyn car_voice::VoiceEventSink> =
21777        Arc::new(crate::session::WsVoiceEventSink {
21778            channel: session.channel.clone(),
21779        });
21780
21781    // Wrap the WS upstream with a memgine-ingest fanout that uses the
21782    // tokio::sync::Mutex-wrapped session memgine. We pass `None` for
21783    // the FFI-common `start_meeting` memgine arg to avoid the
21784    // sync-mutex contract there — ingest happens here instead.
21785    let upstream: Arc<dyn car_voice::VoiceEventSink> =
21786        Arc::new(crate::session::WsMemgineIngestSink {
21787            meeting_id,
21788            engine: session.memgine.clone(),
21789            upstream: ws_upstream,
21790        });
21791
21792    let cwd = std::env::current_dir().ok();
21793    let json = crate::meeting::start_meeting(
21794        &request_json,
21795        state.meetings.clone(),
21796        state.voice_sessions.clone(),
21797        upstream,
21798        None,
21799        cwd,
21800    )
21801    .await?;
21802    serde_json::from_str(&json).map_err(|e| e.to_string())
21803}
21804
21805#[derive(Deserialize)]
21806struct MeetingStopParams {
21807    meeting_id: String,
21808    #[serde(default = "default_summarize")]
21809    summarize: bool,
21810}
21811
21812fn default_summarize() -> bool {
21813    true
21814}
21815
21816async fn handle_meeting_stop(
21817    req: &JsonRpcMessage,
21818    state: &Arc<ServerState>,
21819    _session: &Arc<crate::session::ClientSession>,
21820) -> Result<Value, String> {
21821    let params: MeetingStopParams =
21822        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21823    let inference = if params.summarize {
21824        Some(state.inference.get().cloned()).flatten()
21825    } else {
21826        None
21827    };
21828    let json = crate::meeting::stop_meeting(
21829        &params.meeting_id,
21830        params.summarize,
21831        state.meetings.clone(),
21832        state.voice_sessions.clone(),
21833        inference,
21834    )
21835    .await?;
21836    serde_json::from_str(&json).map_err(|e| e.to_string())
21837}
21838
21839#[derive(Deserialize, Default)]
21840struct MeetingListParams {
21841    #[serde(default)]
21842    root: Option<std::path::PathBuf>,
21843}
21844
21845fn handle_meeting_list(req: &JsonRpcMessage) -> Result<Value, String> {
21846    let params: MeetingListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21847    let cwd = std::env::current_dir().ok();
21848    let json = crate::meeting::list_meetings(params.root, cwd)?;
21849    serde_json::from_str(&json).map_err(|e| e.to_string())
21850}
21851
21852#[derive(Deserialize)]
21853struct MeetingGetParams {
21854    meeting_id: String,
21855    #[serde(default)]
21856    root: Option<std::path::PathBuf>,
21857}
21858
21859fn handle_meeting_get(req: &JsonRpcMessage) -> Result<Value, String> {
21860    let params: MeetingGetParams =
21861        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21862    let cwd = std::env::current_dir().ok();
21863    let json = crate::meeting::get_meeting(&params.meeting_id, params.root, cwd)?;
21864    serde_json::from_str(&json).map_err(|e| e.to_string())
21865}
21866
21867// ---------------------------------------------------------------------------
21868// Agent registry — file-based cross-process discovery (#111)
21869// ---------------------------------------------------------------------------
21870
21871#[derive(Deserialize, Default)]
21872struct RegistryRegisterParams {
21873    /// Caller serializes their AgentEntry as a JSON value; we
21874    /// re-serialize it so the ffi-common helper can validate the
21875    /// shape with the same parser used by the bindings.
21876    entry: Value,
21877    #[serde(default)]
21878    registry_path: Option<std::path::PathBuf>,
21879}
21880
21881fn handle_registry_register(req: &JsonRpcMessage) -> Result<Value, String> {
21882    let params: RegistryRegisterParams =
21883        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21884    let entry_json = serde_json::to_string(&params.entry).map_err(|e| e.to_string())?;
21885    car_ffi_common::registry::register_agent(&entry_json, params.registry_path)?;
21886    Ok(Value::Null)
21887}
21888
21889#[derive(Deserialize, Default)]
21890struct RegistryNameParams {
21891    name: String,
21892    #[serde(default)]
21893    registry_path: Option<std::path::PathBuf>,
21894}
21895
21896fn handle_registry_heartbeat(req: &JsonRpcMessage) -> Result<Value, String> {
21897    let params: RegistryNameParams =
21898        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21899    let json = car_ffi_common::registry::agent_heartbeat(&params.name, params.registry_path)?;
21900    serde_json::from_str(&json).map_err(|e| e.to_string())
21901}
21902
21903fn handle_registry_unregister(req: &JsonRpcMessage) -> Result<Value, String> {
21904    let params: RegistryNameParams =
21905        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
21906    car_ffi_common::registry::unregister_agent(&params.name, params.registry_path)?;
21907    Ok(Value::Null)
21908}
21909
21910#[derive(Deserialize, Default)]
21911struct RegistryListParams {
21912    #[serde(default)]
21913    registry_path: Option<std::path::PathBuf>,
21914}
21915
21916fn handle_registry_list(req: &JsonRpcMessage) -> Result<Value, String> {
21917    let params: RegistryListParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21918    let json = car_ffi_common::registry::list_agents(params.registry_path)?;
21919    serde_json::from_str(&json).map_err(|e| e.to_string())
21920}
21921
21922#[derive(Deserialize, Default)]
21923struct RegistryReapParams {
21924    /// Heartbeats older than this many seconds are reaped. Default
21925    /// 60 — two missed 20s heartbeats trigger removal.
21926    #[serde(default = "default_reap_age")]
21927    max_age_secs: u64,
21928    #[serde(default)]
21929    registry_path: Option<std::path::PathBuf>,
21930}
21931
21932fn default_reap_age() -> u64 {
21933    60
21934}
21935
21936fn handle_registry_reap(req: &JsonRpcMessage) -> Result<Value, String> {
21937    let params: RegistryReapParams = serde_json::from_value(req.params.clone()).unwrap_or_default();
21938    let json =
21939        car_ffi_common::registry::reap_stale_agents(params.max_age_secs, params.registry_path)?;
21940    serde_json::from_str(&json).map_err(|e| e.to_string())
21941}
21942
21943// ---------------------------------------------------------------------------
21944// car-a2a server lifecycle (mirrors NAPI startA2AServer / stopA2AServer /
21945// a2AServerStatus and PyO3 start_a2a_server / stop_a2a_server /
21946// a2a_server_status — closes the binding gap noted in #126).
21947// ---------------------------------------------------------------------------
21948
21949async fn handle_a2a_start(
21950    req: &JsonRpcMessage,
21951    state: &Arc<ServerState>,
21952    session: &crate::session::ClientSession,
21953) -> Result<Value, String> {
21954    let params_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
21955    // Always hand the session's runtime through. start_a2a uses it
21956    // only when `share_session_runtime: true` is set in params;
21957    // otherwise it falls back to the legacy fresh-Runtime + agent_basics
21958    // path. Passing it unconditionally keeps the FFI layer ignorant of
21959    // the flag's plumbing.
21960    //
21961    // Also hand through a chat responder bound to THIS session so a
21962    // conversational (text-only) `message/send` to the A2A listener routes back
21963    // to this host agent's `agent.chat` handler (car-releases#65). start_a2a
21964    // wires it only when `share_session_runtime: true` — without the shared
21965    // runtime there's no host loop to route to.
21966    let responder: Arc<dyn car_a2a::ChatResponder> = Arc::new(WsChatResponder {
21967        state: Arc::downgrade(state),
21968        host_client_id: session.client_id.clone(),
21969    });
21970    let json =
21971        crate::a2a::start_a2a(&params_json, Some(session.runtime.clone()), Some(responder)).await?;
21972    serde_json::from_str(&json).map_err(|e| e.to_string())
21973}
21974
21975fn handle_a2a_stop() -> Result<Value, String> {
21976    let json = crate::a2a::stop_a2a()?;
21977    serde_json::from_str(&json).map_err(|e| e.to_string())
21978}
21979
21980fn handle_a2a_status() -> Result<Value, String> {
21981    let json = crate::a2a::a2a_status()?;
21982    serde_json::from_str(&json).map_err(|e| e.to_string())
21983}
21984
21985// a2a.peers.* — registry of remote A2A peers CAR can discover/call. Reachable
21986// from the language bindings via the generic `a2a_dispatch` proxy.
21987
21988#[derive(serde::Deserialize)]
21989struct A2aPeerAddParams {
21990    url: String,
21991    #[serde(default)]
21992    label: Option<String>,
21993    /// Opt-in to register a non-loopback peer (see the gate below).
21994    #[serde(default)]
21995    allow_untrusted: bool,
21996}
21997
21998fn handle_a2a_peers_add(req: &JsonRpcMessage) -> Result<Value, String> {
21999    let params: A2aPeerAddParams =
22000        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
22001    // `discovery.resolve` will issue an outbound GET to this URL's agent card,
22002    // so registering a non-loopback peer is a deliberate trust decision — gate
22003    // it the same way `a2a.send` gates its endpoint (SSRF guard: otherwise any
22004    // registered URL, incl. internal/metadata endpoints, becomes a daemon GET).
22005    if !params.allow_untrusted && !is_loopback_http_endpoint(&params.url) {
22006        return Err(
22007            "a2a.peers.add endpoint must be loopback unless allowUntrusted is true".to_string(),
22008        );
22009    }
22010    let reg = car_a2a::peers::PeerRegistry::user_default()?;
22011    let entry = reg.add(&params.url, params.label)?;
22012    serde_json::to_value(entry).map_err(|e| e.to_string())
22013}
22014
22015fn handle_a2a_peers_list() -> Result<Value, String> {
22016    let reg = car_a2a::peers::PeerRegistry::user_default()?;
22017    Ok(serde_json::json!({ "peers": reg.list() }))
22018}
22019
22020#[derive(serde::Deserialize)]
22021struct A2aPeerRemoveParams {
22022    slug: String,
22023}
22024
22025fn handle_a2a_peers_remove(req: &JsonRpcMessage) -> Result<Value, String> {
22026    let params: A2aPeerRemoveParams =
22027        serde_json::from_value(req.params.clone()).map_err(|e| format!("invalid params: {e}"))?;
22028    let reg = car_a2a::peers::PeerRegistry::user_default()?;
22029    Ok(serde_json::json!({ "removed": reg.remove(&params.slug)? }))
22030}
22031
22032#[derive(Deserialize)]
22033#[serde(rename_all = "camelCase")]
22034struct A2aSendParams {
22035    endpoint: String,
22036    message: car_a2a::Message,
22037    #[serde(default)]
22038    blocking: bool,
22039    #[serde(default = "default_true")]
22040    ingest_a2ui: bool,
22041    #[serde(default)]
22042    route_auth: Option<A2aRouteAuth>,
22043    #[serde(default)]
22044    allow_untrusted_endpoint: bool,
22045}
22046
22047fn default_true() -> bool {
22048    true
22049}
22050
22051/// In-core A2A dispatcher entry point. Forwards the JSON-RPC method
22052/// + params to the lazy-initialized [`car_a2a::A2aDispatcher`] held
22053/// on `ServerState`. Closes Parslee-ai/car-releases#28.
22054///
22055/// Streaming methods (`message/stream`, `tasks/resubscribe` and their
22056/// PascalCase aliases) return `MethodNotFound` from the dispatcher's
22057/// transport-neutral surface — the standalone `start_a2a_listener`
22058/// HTTP path serves SSE for those, but the in-core WS surface is
22059/// JSON-RPC only. Same trade as the dispatcher itself.
22060async fn handle_a2a_dispatch(
22061    method: &str,
22062    req: &JsonRpcMessage,
22063    state: &Arc<ServerState>,
22064) -> Result<Value, String> {
22065    let dispatcher = state.a2a_dispatcher().await;
22066    dispatcher
22067        .dispatch(method, req.params.clone())
22068        .await
22069        .map_err(|e| e.to_string())
22070}
22071
22072async fn handle_a2a_send(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
22073    let params: A2aSendParams =
22074        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
22075    let endpoint = trusted_route_endpoint(
22076        Some(params.endpoint.clone()),
22077        params.allow_untrusted_endpoint,
22078    )
22079    .ok_or_else(|| {
22080        "`a2a.send` endpoint must be loopback unless allowUntrustedEndpoint is true".to_string()
22081    })?;
22082    let client = match params.route_auth.clone() {
22083        Some(auth) => {
22084            car_a2a::A2aClient::new(endpoint.clone()).with_auth(client_auth_from_route_auth(auth))
22085        }
22086        None => car_a2a::A2aClient::new(endpoint.clone()),
22087    };
22088    let result = client
22089        .send_message(params.message, params.blocking)
22090        .await
22091        .map_err(|e| e.to_string())?;
22092    let result_value = serde_json::to_value(&result).map_err(|e| e.to_string())?;
22093    let mut applied = Vec::new();
22094    if params.ingest_a2ui {
22095        state
22096            .a2ui
22097            .validate_payload(&result_value)
22098            .map_err(|e| e.to_string())?;
22099        let routed_endpoint = Some(endpoint.clone());
22100        for envelope in car_a2ui::envelopes_from_value(&result_value).map_err(|e| e.to_string())? {
22101            let owner = car_a2ui::owner_from_value(&result_value).map(|owner| {
22102                if owner.endpoint.is_none() {
22103                    owner.with_endpoint(routed_endpoint.clone())
22104                } else {
22105                    owner
22106                }
22107            });
22108            applied.push(
22109                apply_a2ui_envelope(state, envelope, owner, params.route_auth.clone()).await?,
22110            );
22111        }
22112    }
22113    Ok(serde_json::json!({
22114        "result": result,
22115        "a2ui": {
22116            "applied": applied,
22117        }
22118    }))
22119}
22120
22121// ---------------------------------------------------------------------------
22122// macOS automation — AppleScript + Shortcuts (car-automation), Vision OCR
22123// (car-vision). Mirrors NAPI runApplescript / listShortcuts / runShortcut /
22124// visionOcr and PyO3 run_applescript / list_shortcuts / run_shortcut /
22125// vision_ocr.
22126// ---------------------------------------------------------------------------
22127
22128async fn handle_run_applescript(req: &JsonRpcMessage) -> Result<Value, String> {
22129    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22130    let json = car_ffi_common::automation::run_applescript(&args_json).await?;
22131    serde_json::from_str(&json).map_err(|e| e.to_string())
22132}
22133
22134async fn handle_run_powershell(req: &JsonRpcMessage) -> Result<Value, String> {
22135    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22136    let json = car_ffi_common::automation::run_powershell(&args_json).await?;
22137    serde_json::from_str(&json).map_err(|e| e.to_string())
22138}
22139
22140async fn handle_list_shortcuts(req: &JsonRpcMessage) -> Result<Value, String> {
22141    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22142    let json = car_ffi_common::automation::list_shortcuts(&args_json).await?;
22143    serde_json::from_str(&json).map_err(|e| e.to_string())
22144}
22145
22146async fn handle_run_shortcut(req: &JsonRpcMessage) -> Result<Value, String> {
22147    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22148    let json = car_ffi_common::automation::run_shortcut(&args_json).await?;
22149    serde_json::from_str(&json).map_err(|e| e.to_string())
22150}
22151
22152async fn handle_local_notification(req: &JsonRpcMessage) -> Result<Value, String> {
22153    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22154    let json = car_ffi_common::notifications::local(&args_json).await?;
22155    serde_json::from_str(&json).map_err(|e| e.to_string())
22156}
22157
22158async fn handle_vision_ocr(req: &JsonRpcMessage) -> Result<Value, String> {
22159    let args_json = serde_json::to_string(&req.params).map_err(|e| e.to_string())?;
22160    let json = car_ffi_common::vision::ocr(&args_json).await?;
22161    serde_json::from_str(&json).map_err(|e| e.to_string())
22162}
22163
22164// ---------------------------------------------------------------------------
22165// Lifecycle-managed agents (car_registry::supervisor) — Parslee-ai/car-releases#27
22166// ---------------------------------------------------------------------------
22167
22168/// Serialize a `ManagedAgent` for the wire, WITHOUT its per-agent token.
22169///
22170/// `AgentSpec::token` is the credential `session.auth { agent_id, token }`
22171/// checks, and `ManagedAgent` flattens the spec — so serializing one verbatim
22172/// published every supervised agent's credential to any authenticated caller.
22173/// That made the bound-identity rules in this file decorative rather than wrong:
22174/// an agent could read another's token, authenticate AS it, and then every
22175/// identity check would pass because the bound identity genuinely was the
22176/// victim. A spoofing rule cannot hold while the impersonation credential is a
22177/// public read.
22178///
22179/// Redacted in the PROJECTION rather than with `skip_serializing` on the field:
22180/// `AgentSpec` is also the on-disk `agents.json` format, so skipping it there
22181/// would silently blank every token on the next manifest write.
22182///
22183/// The projection is `wire_schema::ManagedAgentWire`, a real type rather than a
22184/// `remove("token")` on a `Value`, because `docs/wire-schema.json` is generated
22185/// from it: an added `AgentSpec` field now reaches the wire only by reaching
22186/// that type, and therefore the published schema and digest too.
22187fn agent_to_wire(agent: &car_registry::supervisor::ManagedAgent) -> Result<Value, String> {
22188    serde_json::to_value(crate::wire_schema::ManagedAgentWire::from_managed(agent))
22189        .map_err(|e| e.to_string())
22190}
22191
22192/// Ask an attached supervised agent for the exact tool names it currently
22193/// advertises to its chat model. Older agents have no handler for this reverse
22194/// query; callers then receive no `tools` field rather than a capability-based
22195/// guess. The short timeout keeps `agents.list` a bounded inventory read.
22196async fn attached_agent_chat_tools(
22197    state: &Arc<ServerState>,
22198    agent_id: &str,
22199) -> Option<Vec<String>> {
22200    let client_id = state.attached_agents.lock().await.get(agent_id)?.clone();
22201    let channel = {
22202        let sessions = state.sessions.lock().await;
22203        sessions.get(&client_id)?.channel.clone()
22204    };
22205    query_agent_chat_tools(channel).await
22206}
22207
22208async fn query_agent_chat_tools(channel: Arc<crate::session::WsChannel>) -> Option<Vec<String>> {
22209    use futures::SinkExt;
22210    use tokio::sync::oneshot;
22211    use tokio_tungstenite::tungstenite::Message;
22212
22213    let request_id = channel.next_request_id();
22214    let (tx, rx) = oneshot::channel();
22215    channel.pending.lock().await.insert(request_id.clone(), tx);
22216
22217    let request = serde_json::json!({
22218        "jsonrpc": "2.0",
22219        "method": "agent.chat.tools",
22220        "params": {},
22221        "id": request_id,
22222    });
22223    let message = Message::Text(serde_json::to_string(&request).ok()?.into());
22224    if channel.write.lock().await.send(message).await.is_err() {
22225        channel.pending.lock().await.remove(&request_id);
22226        return None;
22227    }
22228
22229    let response = match tokio::time::timeout(std::time::Duration::from_secs(1), rx).await {
22230        Ok(Ok(response)) => response,
22231        _ => {
22232            channel.pending.lock().await.remove(&request_id);
22233            return None;
22234        }
22235    };
22236    if response.error.is_some() {
22237        return None;
22238    }
22239    let mut tools: Vec<String> = response
22240        .output?
22241        .get("tools")?
22242        .as_array()?
22243        .iter()
22244        .filter_map(Value::as_str)
22245        .map(str::to_string)
22246        .collect();
22247    tools.sort();
22248    tools.dedup();
22249    Some(tools)
22250}
22251
22252#[cfg(test)]
22253mod agent_chat_tools_surface {
22254    use super::query_agent_chat_tools;
22255    use crate::session::WsChannel;
22256    use car_proto::ToolExecuteResponse;
22257    use serde_json::{json, Value};
22258    use std::sync::Arc;
22259
22260    #[tokio::test]
22261    async fn reverse_query_returns_sorted_deduplicated_model_visible_tools() {
22262        let (channel, frames) = WsChannel::test_capture();
22263        let channel = Arc::new(channel);
22264        let responder = channel.clone();
22265        let response = tokio::spawn(async move {
22266            loop {
22267                let claimed = {
22268                    let mut pending = responder.pending.lock().await;
22269                    let key = pending.keys().next().cloned();
22270                    key.and_then(|key| pending.remove(&key).map(|tx| (key, tx)))
22271                };
22272                if let Some((request_id, tx)) = claimed {
22273                    let _ = tx.send(ToolExecuteResponse {
22274                        action_id: request_id,
22275                        output: Some(json!({
22276                            "tools": ["mail_search", "calendar_events", "mail_search"]
22277                        })),
22278                        error: None,
22279                        terminal: false,
22280                    });
22281                    return;
22282                }
22283                tokio::task::yield_now().await;
22284            }
22285        });
22286
22287        assert_eq!(
22288            query_agent_chat_tools(channel).await,
22289            Some(vec![
22290                "calendar_events".to_string(),
22291                "mail_search".to_string()
22292            ])
22293        );
22294        response.await.expect("responder task finishes");
22295
22296        let frames = frames.lock().expect("captured frames lock");
22297        let request: Value = serde_json::from_str(frames.first().expect("reverse query frame"))
22298            .expect("valid JSON-RPC request");
22299        assert_eq!(request["method"], "agent.chat.tools");
22300        assert_eq!(request["params"], json!({}));
22301    }
22302}
22303
22304async fn handle_agents_list(state: &Arc<ServerState>) -> Result<Value, String> {
22305    // Observe-only mode (Parslee-ai/car-releases#44): a second
22306    // car-server on the host can't take the supervisor lock, so it
22307    // can't drive `Supervisor::list` — but it can still answer
22308    // `agents.list` by reading the on-disk manifest directly. The
22309    // `attached` decoration is local to whichever daemon the caller
22310    // is talking to, so observer-mode entries return `attached:
22311    // false` (this daemon hasn't received `session.auth` from those
22312    // children; the primary one has).
22313    let (agents, manifest_path, log_dir) = match state.observer_manifest_path() {
22314        Some(p) => {
22315            let agents = car_registry::supervisor::Supervisor::list_from_manifest(p)
22316                .map_err(|e| e.to_string())?;
22317            let log_dir = p
22318                .parent()
22319                .map(|parent| parent.join("logs"))
22320                .unwrap_or_else(|| std::path::PathBuf::from("logs"));
22321            (agents, p.clone(), log_dir)
22322        }
22323        None => {
22324            let supervisor = state.supervisor()?;
22325            let agents = supervisor.list().await;
22326            (
22327                agents,
22328                supervisor.manifest_path().to_path_buf(),
22329                supervisor.log_dir().to_path_buf(),
22330            )
22331        }
22332    };
22333    let agents_dir = manifest_path
22334        .parent()
22335        .map(|parent| parent.join("agents"))
22336        .unwrap_or_else(|| std::path::PathBuf::from("agents"));
22337    // Decorate each entry with `attached` + `session_id` so operators
22338    // see whether the supervised process has actually called
22339    // `session.auth { agent_id }` and bound a WS connection (#169) —
22340    // the lifecycle status (`Running`, etc.) only reports the
22341    // process-level view, which can't tell "alive but never
22342    // attached" from "alive and attached".
22343    let attached = state.attached_agents.lock().await.clone();
22344    //
22345    // Built as `wire_schema::ManagedAgentListRow` rather than by inserting keys
22346    // into a `Value`, so the decorated row and the `cli.car_inspect.result`
22347    // schema generated from that type cannot drift apart.
22348    let mut decorated: Vec<Value> = Vec::with_capacity(agents.len());
22349    for a in agents {
22350        let session_id = attached.get(&a.spec.id).cloned();
22351        let tools =
22352            if session_id.is_some() && crate::assistant::register::is_assistant_alias(&a.spec.id) {
22353                attached_agent_chat_tools(state, &a.spec.id).await
22354            } else {
22355                None
22356            };
22357        let row = crate::wire_schema::ManagedAgentListRow {
22358            agent: crate::wire_schema::ManagedAgentWire::from_managed(&a),
22359            attached: session_id.is_some(),
22360            tools,
22361            manifest_path: agents_dir
22362                .join(&a.spec.id)
22363                .join("manifest.toml")
22364                .to_string_lossy()
22365                .into_owned(),
22366            log_path: log_dir
22367                .join(format!("{}.stdout.log", a.spec.id))
22368                .to_string_lossy()
22369                .into_owned(),
22370            stderr_log_path: log_dir
22371                .join(format!("{}.stderr.log", a.spec.id))
22372                .to_string_lossy()
22373                .into_owned(),
22374            session_id,
22375        };
22376        decorated.push(serde_json::to_value(row).map_err(|e| e.to_string())?);
22377    }
22378    // Merge in declarative (in-daemon) agents the coder→agent loop built. They
22379    // carry `kind:"declarative"` + `enabled` instead of process status, and
22380    // never travel through the supervisor's command-validation path.
22381    decorated.extend(crate::coder::rpc::declarative_agent_rows(state).await);
22382    Ok(Value::Array(decorated))
22383}
22384
22385async fn handle_agents_upsert(
22386    req: &JsonRpcMessage,
22387    state: &Arc<ServerState>,
22388    session: &crate::session::ClientSession,
22389) -> Result<Value, String> {
22390    require_host_lifecycle_authority(session, state).await?;
22391    let mut params = req.params.clone();
22392    // Optional `interpreter` sugar (#171). When present, the
22393    // supervisor resolves the bare program name (`"node"`,
22394    // `"python"`, …) against `$PATH` and writes the absolute path
22395    // into `command` *before* validation. This keeps the strict
22396    // no-PATH-lookup rule at upsert time while letting callers
22397    // stop hand-coding `/opt/homebrew/bin/node` into every
22398    // agents.json entry. Resolution happens once; subsequent PATH
22399    // changes do not silently rewire the binding.
22400    if let Some(name) = params
22401        .get("interpreter")
22402        .and_then(|v| v.as_str())
22403        .map(str::to_string)
22404    {
22405        let resolved =
22406            car_registry::supervisor::resolve_interpreter(&name).map_err(|e| e.to_string())?;
22407        params["command"] = Value::String(resolved.to_string_lossy().into_owned());
22408    }
22409    let spec: car_registry::supervisor::AgentSpec =
22410        serde_json::from_value(params).map_err(|e| e.to_string())?;
22411    let supervisor = state.supervisor()?;
22412    let agent = supervisor.upsert(spec).await.map_err(|e| e.to_string())?;
22413    agent_to_wire(&agent)
22414}
22415
22416/// `agents.install` — install a contributed-agent manifest
22417/// (Parslee-ai/car#182 phase 3). Caller passes the parsed
22418/// `AgentManifest` JSON; the daemon runs install-time validation
22419/// (`car_min_version`, capability negotiation against the daemon's
22420/// own advertisement) and adopts the manifest. Returns
22421/// `{ report, agent? }` where `agent` is the spawnable
22422/// `ManagedAgent` for `external_process` transports and absent for
22423/// `pure_data` / health_url-only manifests.
22424///
22425/// The host capability advertisement comes from
22426/// `HostCapabilities::daemon_default(car_version)` — operators that
22427/// want a tighter advertisement go through a future config phase;
22428/// this MVP uses the runtime's natural surface.
22429async fn handle_agents_install(
22430    req: &JsonRpcMessage,
22431    state: &Arc<ServerState>,
22432    session: &crate::session::ClientSession,
22433) -> Result<Value, String> {
22434    require_host_lifecycle_authority(session, state).await?;
22435    let manifest: car_registry::manifest::AgentManifest =
22436        serde_json::from_value(req.params.clone()).map_err(|e| e.to_string())?;
22437    let host = car_registry::install::HostCapabilities::daemon_default(env!("CARGO_PKG_VERSION"));
22438    let supervisor = state.supervisor()?;
22439    let (report, managed) = supervisor
22440        .install_manifest(manifest, &host)
22441        .await
22442        .map_err(|e| e.to_string())?;
22443    Ok(serde_json::json!({
22444        "report": {
22445            "missingOptional": report
22446                .missing_optional
22447                .iter()
22448                .map(|(ns, feat)| serde_json::json!({ "namespace": ns, "feature": feat }))
22449                .collect::<Vec<_>>(),
22450        },
22451        "agent": managed,
22452    }))
22453}
22454
22455async fn handle_agents_health(state: &Arc<ServerState>) -> Result<Value, String> {
22456    // Observe-only mode (Parslee-ai/car-releases#44) — see
22457    // `handle_agents_list` for the rationale. The health view is a
22458    // pure function of each entry's `command` plus the on-disk
22459    // sandbox rules, so reading from the manifest is equivalent to
22460    // calling the live supervisor's `health()`.
22461    let entries = match state.observer_manifest_path() {
22462        Some(p) => car_registry::supervisor::Supervisor::health_from_manifest(p)
22463            .map_err(|e| e.to_string())?,
22464        None => {
22465            let supervisor = state.supervisor()?;
22466            supervisor.health().await
22467        }
22468    };
22469    serde_json::to_value(entries).map_err(|e| e.to_string())
22470}
22471
22472fn extract_agent_id(req: &JsonRpcMessage) -> Result<String, String> {
22473    req.params
22474        .get("id")
22475        .and_then(Value::as_str)
22476        .map(str::to_string)
22477        .ok_or_else(|| "missing required `id` parameter".to_string())
22478}
22479
22480async fn handle_agents_remove(
22481    req: &JsonRpcMessage,
22482    state: &Arc<ServerState>,
22483    session: &crate::session::ClientSession,
22484) -> Result<Value, String> {
22485    require_host_lifecycle_authority(session, state).await?;
22486    let id = extract_agent_id(req)?;
22487    let supervisor = state.supervisor()?;
22488    let removed = supervisor.remove(&id).await.map_err(|e| e.to_string())?;
22489    Ok(serde_json::json!({ "removed": removed }))
22490}
22491
22492async fn handle_agents_start(
22493    req: &JsonRpcMessage,
22494    state: &Arc<ServerState>,
22495    session: &crate::session::ClientSession,
22496) -> Result<Value, String> {
22497    let id = extract_agent_id(req)?;
22498    require_own_agent_or_host(session, state, "agents.start", &id).await?;
22499    let supervisor = state.supervisor()?;
22500    let agent = supervisor.start(&id).await.map_err(|e| e.to_string())?;
22501    agent_to_wire(&agent)
22502}
22503
22504async fn handle_agents_stop(
22505    req: &JsonRpcMessage,
22506    state: &Arc<ServerState>,
22507    session: &crate::session::ClientSession,
22508) -> Result<Value, String> {
22509    let id = extract_agent_id(req)?;
22510    require_own_agent_or_host(session, state, "agents.stop", &id).await?;
22511    let signal: car_registry::supervisor::StopSignal = req
22512        .params
22513        .get("signal")
22514        .map(|v| serde_json::from_value(v.clone()))
22515        .transpose()
22516        .map_err(|e| e.to_string())?
22517        .unwrap_or_default();
22518    let supervisor = state.supervisor()?;
22519    let agent = supervisor
22520        .stop(&id, signal)
22521        .await
22522        .map_err(|e| e.to_string())?;
22523    agent_to_wire(&agent)
22524}
22525
22526async fn handle_agents_restart(
22527    req: &JsonRpcMessage,
22528    state: &Arc<ServerState>,
22529    session: &crate::session::ClientSession,
22530) -> Result<Value, String> {
22531    let id = extract_agent_id(req)?;
22532    require_own_agent_or_host(session, state, "agents.restart", &id).await?;
22533    let supervisor = state.supervisor()?;
22534    let agent = supervisor.restart(&id).await.map_err(|e| e.to_string())?;
22535    agent_to_wire(&agent)
22536}
22537
22538/// Block until a managed agent reaches one of the target statuses, or the
22539/// timeout elapses. Params: `{ id, targets?: string[] = ["running"],
22540/// Ceiling for `agents.wait { timeout_secs }`. Far longer than any real
22541/// start or stop, and finite — an unbounded caller-supplied wait is a hold on
22542/// daemon resources for as long as the caller likes.
22543const MAX_AGENT_WAIT_SECS: f64 = 600.0;
22544
22545/// timeout_secs?: number = 30, poll_ms?: number = 200 }`. Returns the matching
22546/// `ManagedAgent`, or an error on timeout / unknown id.
22547async fn handle_agents_wait(
22548    req: &JsonRpcMessage,
22549    state: &Arc<ServerState>,
22550    bound_agent: Option<&str>,
22551) -> Result<Value, String> {
22552    let id = extract_agent_id(req)?;
22553    require_own_agent(bound_agent, "agents.wait", &id)?;
22554    let targets: Vec<car_registry::supervisor::AgentStatus> = match req.params.get("targets") {
22555        Some(v) => serde_json::from_value(v.clone())
22556            .map_err(|e| format!("invalid 'targets' (expected status string array): {e}"))?,
22557        None => vec![car_registry::supervisor::AgentStatus::Running],
22558    };
22559    let targets = if targets.is_empty() {
22560        vec![car_registry::supervisor::AgentStatus::Running]
22561    } else {
22562        targets
22563    };
22564    let timeout = std::time::Duration::from_secs_f64(
22565        req.params
22566            .get("timeout_secs")
22567            .and_then(Value::as_f64)
22568            .unwrap_or(30.0)
22569            // Bounded at both ends. Only the floor was enforced, so a caller
22570            // could ask the daemon to hold a supervisor poll open for an
22571            // arbitrary duration; ten minutes is far longer than any real
22572            // start/stop and still terminates.
22573            .clamp(0.0, MAX_AGENT_WAIT_SECS),
22574    );
22575    let poll = std::time::Duration::from_millis(
22576        req.params
22577            .get("poll_ms")
22578            .and_then(Value::as_u64)
22579            .unwrap_or(200)
22580            .max(10),
22581    );
22582    let supervisor = state.supervisor()?;
22583    let agent = supervisor
22584        .wait_for(&id, &targets, timeout, poll)
22585        .await
22586        .map_err(|e| e.to_string())?;
22587    agent_to_wire(&agent)
22588}
22589
22590async fn handle_agents_tail_log(
22591    req: &JsonRpcMessage,
22592    state: &Arc<ServerState>,
22593    bound_agent: Option<&str>,
22594) -> Result<Value, String> {
22595    let id = extract_agent_id(req)?;
22596    require_own_agent(bound_agent, "agents.tail_log", &id)?;
22597    // Honor `n` OR `lines` for the count — the CarHost UI historically
22598    // sent `lines: 200` while the daemon only read `n`, so the count
22599    // was silently ignored and the modal title lied (Parslee-ai/car#273).
22600    let n = req
22601        .params
22602        .get("n")
22603        .or_else(|| req.params.get("lines"))
22604        .and_then(Value::as_u64)
22605        .unwrap_or(100) as usize;
22606    let offset = req
22607        .params
22608        .get("offset")
22609        .and_then(Value::as_u64)
22610        .unwrap_or(0) as usize;
22611    let stream = car_registry::supervisor::LogStream::from_wire(
22612        req.params.get("stream").and_then(Value::as_str),
22613    );
22614    let supervisor = state.supervisor()?;
22615    let tail = supervisor
22616        .read_log(&id, stream, n, offset)
22617        .await
22618        .map_err(|e| e.to_string())?;
22619    serde_json::to_value(tail).map_err(|e| e.to_string())
22620}
22621
22622// ---------------------------------------------------------------------------
22623// External-agent detection (Phase 1 of docs/proposals/external-agent-detection.md)
22624//
22625// Discovery surface for agentic CLIs the user has already installed and
22626// authenticated (Claude Code, Codex, Gemini). Read-only — no invocation
22627// path yet; agents.invoke_external lands in Phase 2 alongside the JSON
22628// stdio adapter. The cache lives in car_ffi_common::external_agents so
22629// the in-process FFI singletons share the same snapshot.
22630// ---------------------------------------------------------------------------
22631
22632async fn handle_agents_list_external(req: &JsonRpcMessage) -> Result<Value, String> {
22633    let include_health = req
22634        .params
22635        .get("include_health")
22636        .and_then(Value::as_bool)
22637        .unwrap_or(false);
22638    let json = car_ffi_common::external_agents::list(include_health).await?;
22639    serde_json::from_str(&json).map_err(|e| e.to_string())
22640}
22641
22642async fn handle_agents_detect_external(req: &JsonRpcMessage) -> Result<Value, String> {
22643    let include_health = req
22644        .params
22645        .get("include_health")
22646        .and_then(Value::as_bool)
22647        .unwrap_or(false);
22648    let json = car_ffi_common::external_agents::detect(include_health).await?;
22649    serde_json::from_str(&json).map_err(|e| e.to_string())
22650}
22651
22652#[derive(Debug, Deserialize)]
22653struct AssistantInvokeParams {
22654    capability: String,
22655    #[serde(default)]
22656    agent_hint: Option<String>,
22657    payload_json: String,
22658}
22659
22660const ASSISTANT_INVOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
22661
22662/// Daemon-owned invocation for ready-to-use assistants. This mirrors the
22663/// UniFFI `invoke_capability` contract, but brackets the model call in the
22664/// durable run trace store so CarHost's Activity tab can explain what ran.
22665async fn handle_assistants_invoke(
22666    req: &JsonRpcMessage,
22667    state: &Arc<ServerState>,
22668    host_session: &Arc<crate::session::ClientSession>,
22669) -> Result<Value, String> {
22670    let params: AssistantInvokeParams =
22671        serde_json::from_value(req.params.clone()).map_err(|e| {
22672            format!("assistants.invoke requires {{ capability, agent_hint?, payload_json }}: {e}")
22673        })?;
22674    let capability = params.capability.trim();
22675    if capability.is_empty() {
22676        return Err("assistants.invoke requires a non-empty `capability`".to_string());
22677    }
22678
22679    let registry = car_engine::AgentCapabilityRegistry::new();
22680    car_engine::register_builtins(&registry);
22681    let agent = registry
22682        .select(capability, params.agent_hint.as_deref())
22683        .ok_or_else(|| format!("no ready-to-use assistant supports capability `{capability}`"))?;
22684    let task = car_engine::format_capability_payload(capability, &params.payload_json)
22685        .map_err(|e| e.to_string())?;
22686    let system_prompt = car_engine::agent_metadata(&agent)
22687        .map(|m| m.system_prompt.to_string())
22688        .unwrap_or_else(|| "You are a helpful assistant. Carry out the user's task.".into());
22689
22690    let run_id = uuid::Uuid::new_v4().to_string();
22691    let started_at = chrono::Utc::now();
22692    state
22693        .start_run(crate::session::RunMeta {
22694            run_id: run_id.clone(),
22695            agent_id: agent.clone(),
22696            client_id: host_session.client_id.clone(),
22697            active_client_id: host_session.client_id.clone(),
22698            resume_predecessor_client_id: None,
22699            resume_lease: None,
22700            intent: task.clone(),
22701            outcome_description: Some(format!("Run {capability} with a ready-to-use assistant")),
22702            started_at,
22703            termination: None,
22704            ended_at: None,
22705            turns: Vec::new(),
22706            start_committed: false,
22707            pending_terminal: None,
22708            cancellation_pending: None,
22709            cancellation_receipt: None,
22710            trace_corruption: None,
22711            durability_generation: 0,
22712        })
22713        .await?;
22714
22715    let req = car_inference::GenerateRequest {
22716        prompt: task.clone(),
22717        context: Some(system_prompt),
22718        intent: Some(car_inference::IntentHint {
22719            prefer_fast: true,
22720            ..Default::default()
22721        }),
22722        ..Default::default()
22723    };
22724    let engine = get_inference_engine(state);
22725    let generated: Result<String, String> = match assistant_model_setup_message(engine, &req).await
22726    {
22727        Some(message) => Err(message),
22728        None => match tokio::time::timeout(ASSISTANT_INVOKE_TIMEOUT, engine.generate(req)).await {
22729            Ok(Ok(result)) => Ok(result),
22730            Ok(Err(e)) => Err(format!("Assistant failed: {e}")),
22731            Err(_) => Err(format!(
22732                "Assistant timed out after {} seconds. Choose a faster model in Models, or try again after model setup finishes.",
22733                ASSISTANT_INVOKE_TIMEOUT.as_secs()
22734            )),
22735        },
22736    };
22737
22738    match generated {
22739        Ok(result) => {
22740            registry.note_used(capability, &agent);
22741            let _ = state
22742                .record_run_turns(
22743                    &run_id,
22744                    vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22745                        index: 0,
22746                        proposal_id: None,
22747                        action_id: None,
22748                        action_status: None,
22749                        action_duration_ms: None,
22750                        action_completed_at: None,
22751                        depends_on: None,
22752                        state_dependencies: None,
22753                        prompt: Some(task),
22754                        tool: Some(capability.to_string()),
22755                        parameters: serde_json::from_str(&params.payload_json).unwrap_or_else(
22756                            |_| serde_json::json!({ "payload_json": params.payload_json }),
22757                        ),
22758                        output: Some(serde_json::json!({ "text": result })),
22759                        cli_outcome: None,
22760                        verifier_verdict: car_proto::VerifierVerdict::NotRun,
22761                        policy_rejected: None,
22762                    })],
22763                )
22764                .await;
22765            state
22766                .complete_run(
22767                    &run_id,
22768                    car_proto::RunTermination::Outcome {
22769                        status: car_ir::OutcomeStatus::Success,
22770                        outcome: car_ir::AgentOutcome {
22771                            status: car_ir::OutcomeStatus::Success,
22772                            summary: "Assistant completed successfully.".to_string(),
22773                            evidence: Vec::new(),
22774                            metrics: Default::default(),
22775                            timestamp: chrono::Utc::now(),
22776                        },
22777                    },
22778                )
22779                .await?;
22780            Ok(serde_json::json!({
22781                "agent": agent,
22782                "result": result,
22783                "run_id": run_id,
22784            }))
22785        }
22786        Err(message) => {
22787            let _ = state
22788                .record_run_turns(
22789                    &run_id,
22790                    vec![car_proto::RunRecord::Turn(car_proto::RunTurn {
22791                        index: 0,
22792                        proposal_id: None,
22793                        action_id: None,
22794                        action_status: None,
22795                        action_duration_ms: None,
22796                        action_completed_at: None,
22797                        depends_on: None,
22798                        state_dependencies: None,
22799                        prompt: Some(task),
22800                        tool: Some(capability.to_string()),
22801                        parameters: serde_json::from_str(&params.payload_json).unwrap_or_else(
22802                            |_| serde_json::json!({ "payload_json": params.payload_json }),
22803                        ),
22804                        output: Some(serde_json::json!({ "error": message.clone() })),
22805                        cli_outcome: None,
22806                        verifier_verdict: car_proto::VerifierVerdict::NotRun,
22807                        policy_rejected: None,
22808                    })],
22809                )
22810                .await;
22811            state
22812                .complete_run(
22813                    &run_id,
22814                    car_proto::RunTermination::Outcome {
22815                        status: car_ir::OutcomeStatus::Failure,
22816                        outcome: car_ir::AgentOutcome {
22817                            status: car_ir::OutcomeStatus::Failure,
22818                            summary: message.clone(),
22819                            evidence: Vec::new(),
22820                            metrics: Default::default(),
22821                            timestamp: chrono::Utc::now(),
22822                        },
22823                    },
22824                )
22825                .await?;
22826            Err(message)
22827        }
22828    }
22829}
22830
22831async fn assistant_model_setup_message(
22832    engine: &car_inference::InferenceEngine,
22833    req: &car_inference::GenerateRequest,
22834) -> Option<String> {
22835    let decision = engine
22836        .route_adaptive_with_intent(&req.prompt, req.intent.clone())
22837        .await;
22838    match engine
22839        .unified_registry
22840        .ready_without_download(&decision.model_id)
22841    {
22842        Some(true) => None,
22843        Some(false) => Some(format!(
22844            "Assistant needs model setup before it can run. Open Models and install {}, then try again.",
22845            decision.model_name
22846        )),
22847        None => Some(format!(
22848            "Assistant selected {}, but that model is not registered. Open Models and choose an available model.",
22849            decision.model_name
22850        )),
22851    }
22852}
22853
22854/// Per-task invocation of an external CLI agent. Required params:
22855/// `id` (adapter, e.g. `"claude-code"`) and `task` (the prompt).
22856/// Optional: `cwd`, `allowed_tools`, `max_turns`, `timeout_secs`.
22857///
22858/// Phase 2 stage 3 ships with `claude-code` only. Other adapter
22859/// ids return `is_error: true` with a structured `error` so hosts
22860/// can surface the gap without a separate error code.
22861///
22862/// Phase 2 stage 4a (governance): every invocation appends a
22863/// structured audit record to `~/.car/external-agents.jsonl`. The
22864/// record captures id, task, options, result, and the full
22865/// `tool_uses` list the assistant emitted — so even though the
22866/// agent executes its built-in tools in-process (which we can't
22867/// gate via stream-json), there's a complete after-the-fact audit
22868/// trail. Full policy gating (proposing each tool_use to CAR's
22869/// validator + getting a yes/no) requires the MCP server route in
22870/// stage 4b.
22871async fn handle_agents_invoke_external(
22872    req: &JsonRpcMessage,
22873    state: &Arc<ServerState>,
22874    host_session: &Arc<crate::session::ClientSession>,
22875) -> Result<Value, String> {
22876    let id = req
22877        .params
22878        .get("id")
22879        .and_then(Value::as_str)
22880        .ok_or_else(|| "missing required `id` parameter".to_string())?
22881        .to_string();
22882    let task = req
22883        .params
22884        .get("task")
22885        .and_then(Value::as_str)
22886        .ok_or_else(|| "missing required `task` parameter".to_string())?
22887        .to_string();
22888    let stream = req
22889        .params
22890        .get("stream")
22891        .and_then(Value::as_bool)
22892        .unwrap_or(false);
22893    let session_id = req
22894        .params
22895        .get("session_id")
22896        .and_then(Value::as_str)
22897        .map(str::to_string)
22898        .unwrap_or_else(|| format!("ext-{}", uuid::Uuid::new_v4().simple()));
22899
22900    // Build the options sub-object directly from req.params so
22901    // hosts can pass `cwd` / `allowed_tools` / `max_turns` /
22902    // `timeout_secs` as siblings of `id`/`task`. Strip the
22903    // dispatch + streaming fields so they don't pollute the
22904    // options serde.
22905    let mut options_value = req.params.clone();
22906    if let Some(obj) = options_value.as_object_mut() {
22907        obj.remove("id");
22908        obj.remove("task");
22909        obj.remove("stream");
22910        obj.remove("session_id");
22911        // Auto-fill `mcp_endpoint` from the bound MCP URL when the
22912        // caller didn't supply one. This is the load-bearing
22913        // wiring of MCP-4: external agents get CAR's tools (memory,
22914        // skills, verify) routed through the daemon's policy +
22915        // shared memgine without any per-call host configuration.
22916        // Callers who want to opt out can pass `"mcp_endpoint": ""`
22917        // (empty string) — the runner skips the temp-file write
22918        // when the value isn't a non-empty URL.
22919        let has_explicit_mcp = obj.contains_key("mcp_endpoint");
22920        if !has_explicit_mcp {
22921            if let Some(url) = state.mcp_url.get() {
22922                obj.insert("mcp_endpoint".to_string(), Value::String(url.clone()));
22923            }
22924        }
22925    }
22926
22927    if !stream {
22928        // Legacy one-shot path. Unchanged shape for FFI consumers
22929        // and any caller that hasn't opted into streaming.
22930        let options_json = options_value.to_string();
22931        let json = car_ffi_common::external_agents::invoke(&id, &task, &options_json).await?;
22932        let result: Value = serde_json::from_str(&json).map_err(|e| e.to_string())?;
22933        append_external_agent_audit(&id, &task, &options_value, &result);
22934        return Ok(result);
22935    }
22936
22937    // Streaming path. Returns an ack ({accepted, session_id})
22938    // immediately and streams `agents.chat.event` notifications
22939    // to the host's WS as the runner emits StreamEvents. Reuses
22940    // the chat_sessions routing infrastructure supervised agents
22941    // use — host UIs render both kinds through the same path.
22942    let opts: car_external_agents::InvokeOptions = serde_json::from_value(options_value.clone())
22943        .map_err(|e| format!("invalid options: {e}"))?;
22944
22945    // Register the chat session BEFORE spawning so the host's
22946    // subscriber is correctly bound to this session_id by the
22947    // time the first event arrives. Reusing the supervised
22948    // agent chat infrastructure means `agents.chat.cancel`
22949    // routes to chat_sessions[session_id] and can flip the same
22950    // local cancellation flag the external runner races against.
22951    let local_cancel = Arc::new(AtomicBool::new(false));
22952    let owns_chat_session = {
22953        // If a chat session is already registered for this id (the
22954        // typical proxy shape: host → agents.chat → supervised agent
22955        // → agents.invoke_external with the same session_id), DO NOT
22956        // overwrite it. The existing entry owns the routing to the
22957        // original host; clobbering it with our caller's client_id
22958        // would send streaming events to the proxying agent instead
22959        // of back to the host that issued agents.chat. Only register
22960        // a fresh entry when the slot is empty (a direct
22961        // host-to-invoke_external call without a prior agents.chat).
22962        let mut chats = state.chat_sessions.lock().await;
22963        if let Some(chat) = chats.get_mut(&session_id) {
22964            if chat.local_cancel.is_none() {
22965                chat.local_cancel = Some(local_cancel.clone());
22966            }
22967            false
22968        } else {
22969            let created_at = std::time::SystemTime::now()
22970                .duration_since(std::time::UNIX_EPOCH)
22971                .map(|d| d.as_secs())
22972                .unwrap_or(0);
22973            chats.insert(
22974                session_id.clone(),
22975                crate::session::ChatSession {
22976                    agent_id: id.clone(),
22977                    host_client_id: host_session.client_id.clone(),
22978                    created_at,
22979                    local_cancel: Some(local_cancel.clone()),
22980                },
22981            );
22982            true
22983        }
22984    };
22985
22986    // Single drain task pulls StreamEvents off an unbounded
22987    // channel and serializes WS sends to the host. Per-event
22988    // tokio::spawn would let sends race (which token arrives
22989    // first depends on lock acquisition order). The channel
22990    // is unbounded because claude's event volume is bounded
22991    // by user turn count — typically <50 events per invocation.
22992    use tokio::sync::mpsc;
22993    let (tx, mut rx) = mpsc::unbounded_channel::<car_external_agents::StreamEvent>();
22994
22995    let drain_state = state.clone();
22996    let drain_session_id = session_id.clone();
22997    let drain_agent_id = id.clone();
22998    tokio::spawn(async move {
22999        while let Some(event) = rx.recv().await {
23000            emit_external_chat_event(&drain_state, &drain_session_id, &drain_agent_id, event).await;
23001        }
23002    });
23003
23004    let emitter_tx = tx.clone();
23005    let emitter: car_external_agents::StreamEventEmitter = Arc::new(move |event| {
23006        // Send failure (rx dropped) means the drain task has
23007        // exited — usually because the host disconnected. The
23008        // runner will keep going; let it finish so the audit
23009        // log captures the full result.
23010        let _ = emitter_tx.send(event);
23011    });
23012
23013    // Run the invocation in a separate task so this handler
23014    // can return the ack right away. The runner's child-process
23015    // future owns the spawn lifetime; if the host disconnects
23016    // mid-stream, the runner still completes (its events fall
23017    // on the floor at the drain layer) so the audit log lands.
23018    let spawn_state = state.clone();
23019    let spawn_session_id = session_id.clone();
23020    let spawn_id = id.clone();
23021    let spawn_task = task.clone();
23022    let spawn_options = options_value.clone();
23023    tokio::spawn(async move {
23024        let outcome = car_external_agents::invoke_with_emitter_and_cancel(
23025            &spawn_id,
23026            &spawn_task,
23027            opts,
23028            Some(emitter),
23029            Some(local_cancel.clone()),
23030        )
23031        .await;
23032        drop(tx); // signal drain task to exit after queue empties
23033
23034        // Synthesize a terminal agents.chat.event so the host's
23035        // bubble finalizes. The runner doesn't emit a "done" event
23036        // itself — the result is the aggregate InvokeResult. We
23037        // translate it here.
23038        let terminal_params: Value;
23039        let result_value: Value;
23040        match outcome {
23041            Ok(res) => {
23042                // Pack the metadata into `finish_reason` as a
23043                // human-readable summary so the host's existing
23044                // ChatEvent decoder surfaces it without a schema
23045                // change. Hosts that want structured data can
23046                // re-issue `agents.invoke_external` with
23047                // `stream: false` and read the InvokeResult.
23048                let mut parts: Vec<String> = Vec::new();
23049                if res.turns > 0 {
23050                    parts.push(format!(
23051                        "{} turn{}",
23052                        res.turns,
23053                        if res.turns == 1 { "" } else { "s" }
23054                    ));
23055                }
23056                if res.tool_calls > 0 {
23057                    parts.push(format!(
23058                        "{} tool{}",
23059                        res.tool_calls,
23060                        if res.tool_calls == 1 { "" } else { "s" }
23061                    ));
23062                }
23063                if res.duration_ms > 0 {
23064                    parts.push(format!("{:.1}s", res.duration_ms as f64 / 1000.0));
23065                }
23066                if res.dropped_attachments > 0 {
23067                    parts.push(format!(
23068                        "{} image{} skipped",
23069                        res.dropped_attachments,
23070                        if res.dropped_attachments == 1 {
23071                            ""
23072                        } else {
23073                            "s"
23074                        }
23075                    ));
23076                }
23077                let summary = if parts.is_empty() {
23078                    "stop".to_string()
23079                } else {
23080                    parts.join(" · ")
23081                };
23082                if res.is_error {
23083                    terminal_params = serde_json::json!({
23084                        "session_id": spawn_session_id,
23085                        "agent_id": spawn_id,
23086                        "kind": "error",
23087                        "error": res.error.clone().unwrap_or_else(|| "external agent reported error".to_string()),
23088                    });
23089                } else {
23090                    terminal_params = serde_json::json!({
23091                        "session_id": spawn_session_id,
23092                        "agent_id": spawn_id,
23093                        "kind": "done",
23094                        "finish_reason": summary,
23095                        // Structured count so the host can warn distinctly
23096                        // from the human-readable summary. Omitted when 0.
23097                        "dropped_attachments": res.dropped_attachments,
23098                    });
23099                }
23100                result_value = serde_json::to_value(&res).unwrap_or(Value::Null);
23101            }
23102            Err(e) => {
23103                let message = format!("{e}");
23104                terminal_params = serde_json::json!({
23105                    "session_id": spawn_session_id,
23106                    "agent_id": spawn_id,
23107                    "kind": "error",
23108                    "error": message.clone(),
23109                });
23110                result_value = serde_json::json!({ "is_error": true, "error": message });
23111            }
23112        }
23113        send_external_chat_frame(&spawn_state, &spawn_session_id, terminal_params).await;
23114        remove_owned_external_chat_session(&spawn_state, &spawn_session_id, owns_chat_session)
23115            .await;
23116        append_external_agent_audit(&spawn_id, &spawn_task, &spawn_options, &result_value);
23117    });
23118
23119    Ok(serde_json::json!({
23120        "accepted": true,
23121        "session_id": session_id,
23122    }))
23123}
23124
23125async fn remove_owned_external_chat_session(
23126    state: &Arc<ServerState>,
23127    session_id: &str,
23128    owns_chat_session: bool,
23129) {
23130    if owns_chat_session {
23131        state.chat_sessions.lock().await.remove(session_id);
23132    }
23133}
23134
23135/// Translate one [`StreamEvent`] from the running external CLI
23136/// into an `agents.chat.event` notification on the originating
23137/// host's WS. Same wire shape supervised agents emit, so host
23138/// UIs render both kinds with one decoder.
23139///
23140/// Mapping:
23141/// - `Assistant` events with `text` content blocks → `kind: "token"`
23142///   per text block. Each block carries the full text the
23143///   assistant emitted in that turn (claude doesn't expose
23144///   word-level deltas via stream-json — it emits per-turn or
23145///   per-content-block chunks).
23146/// - `Assistant` events with `tool_use` blocks → `kind: "tool_call"`
23147///   per block (tool name in `detail`).
23148/// - `System` / `User` / `Result` / others → dropped (Result's
23149///   metadata is folded into the terminal `done` event the
23150///   outer task emits when the invocation finishes).
23151async fn emit_external_chat_event(
23152    state: &Arc<ServerState>,
23153    session_id: &str,
23154    agent_id: &str,
23155    event: car_external_agents::StreamEvent,
23156) {
23157    use car_external_agents::StreamEvent;
23158    match event {
23159        StreamEvent::Assistant(a) => {
23160            if let Some(content) = a.message.get("content").and_then(|v| v.as_array()) {
23161                for block in content {
23162                    let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
23163                    match block_type {
23164                        "text" => {
23165                            if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
23166                                if !text.is_empty() {
23167                                    let params = serde_json::json!({
23168                                        "session_id": session_id,
23169                                        "agent_id": agent_id,
23170                                        "kind": "token",
23171                                        "delta": text,
23172                                    });
23173                                    send_external_chat_frame(state, session_id, params).await;
23174                                }
23175                            }
23176                        }
23177                        "tool_use" => {
23178                            let name = block
23179                                .get("name")
23180                                .and_then(|v| v.as_str())
23181                                .unwrap_or("(unknown tool)");
23182                            // Emit the host-protocol.md `tool` + `params`
23183                            // shape so the host renders a structured
23184                            // tool-call card (name + args). `detail` is
23185                            // kept as a legacy alias for older hosts that
23186                            // only read it.
23187                            let mut frame = serde_json::json!({
23188                                "session_id": session_id,
23189                                "agent_id": agent_id,
23190                                "kind": "tool_call",
23191                                "tool": name,
23192                                "detail": name,
23193                            });
23194                            if let Some(input) = block.get("input") {
23195                                if !input.is_null() {
23196                                    frame["params"] = input.clone();
23197                                }
23198                            }
23199                            send_external_chat_frame(state, session_id, frame).await;
23200                        }
23201                        _ => {}
23202                    }
23203                }
23204            }
23205        }
23206        _ => {
23207            // System (session id init), User (tool result echo),
23208            // Result (final aggregate — folded into terminal
23209            // `done` event by the outer task), RateLimitEvent,
23210            // Other: not surfaced to host.
23211        }
23212    }
23213}
23214
23215/// Send a single `agents.chat.event` notification to the host
23216/// session bound by `session_id`. Best-effort: a missing route
23217/// or a closed WS is silently dropped, the runner continues so
23218/// the audit log lands.
23219async fn send_external_chat_frame(state: &Arc<ServerState>, session_id: &str, params: Value) {
23220    use futures::SinkExt;
23221    use tokio_tungstenite::tungstenite::Message;
23222
23223    update_chat_goal_from_event(state, session_id, &params).await;
23224
23225    let host_client_id = state
23226        .chat_sessions
23227        .lock()
23228        .await
23229        .get(session_id)
23230        .map(|s| s.host_client_id.clone());
23231    let Some(host_client_id) = host_client_id else {
23232        return;
23233    };
23234    let host_channel = {
23235        let sessions = state.sessions.lock().await;
23236        sessions.get(&host_client_id).map(|s| s.channel.clone())
23237    };
23238    let Some(channel) = host_channel else {
23239        return;
23240    };
23241    let frame = serde_json::json!({
23242        "jsonrpc": "2.0",
23243        "method": "agents.chat.event",
23244        "params": params,
23245    });
23246    if let Ok(text) = serde_json::to_string(&frame) {
23247        let _ = channel
23248            .write
23249            .lock()
23250            .await
23251            .send(Message::Text(text.into()))
23252            .await;
23253    }
23254}
23255
23256/// Append one JSONL audit record to `external-agents.jsonl` under the CAR state
23257/// root (`~/.car/external-agents.jsonl` unless `CAR_HOME` moves the root).
23258/// Best-effort: a failure to open the journal must NOT fail the
23259/// invocation; the in-memory result already returned is the
23260/// authoritative answer. Logs at warn level when the write fails so
23261/// operators notice repeated failures. An unresolvable root is the same
23262/// best-effort miss — no root, no journal, no failed invocation.
23263fn append_external_agent_audit(id: &str, task: &str, options: &Value, result: &Value) {
23264    use std::io::Write;
23265    let car_dir = match car_home::root() {
23266        Some(root) => root,
23267        None => return,
23268    };
23269    if std::fs::create_dir_all(&car_dir).is_err() {
23270        return;
23271    }
23272    let path = car_dir.join("external-agents.jsonl");
23273    let record = serde_json::json!({
23274        "ts": chrono::Utc::now().to_rfc3339(),
23275        "adapter_id": id,
23276        "task": task,
23277        "options": options,
23278        "result": result,
23279    });
23280    let line = match serde_json::to_string(&record) {
23281        Ok(s) => s,
23282        Err(_) => return,
23283    };
23284    if let Ok(mut f) = std::fs::OpenOptions::new()
23285        .create(true)
23286        .append(true)
23287        .open(&path)
23288    {
23289        let _ = writeln!(f, "{}", line);
23290    } else {
23291        tracing::warn!(
23292            path = %path.display(),
23293            "failed to append external-agent audit record"
23294        );
23295    }
23296}
23297
23298/// Ground-truth health check. Optional `id` param picks one tool;
23299/// without it, every detected adapter is checked. `force: true`
23300/// bypasses the 30s per-tool TTL cache. Replaces the Phase 1
23301/// credential-file shape heuristic as the load-bearing signal for
23302/// "is this tool ready to invoke."
23303async fn handle_agents_health_external(req: &JsonRpcMessage) -> Result<Value, String> {
23304    let force = req
23305        .params
23306        .get("force")
23307        .and_then(Value::as_bool)
23308        .unwrap_or(false);
23309    if let Some(id) = req.params.get("id").and_then(Value::as_str) {
23310        let json = car_ffi_common::external_agents::health_one(id, force).await?;
23311        serde_json::from_str(&json).map_err(|e| e.to_string())
23312    } else {
23313        let json = car_ffi_common::external_agents::health(force).await?;
23314        serde_json::from_str(&json).map_err(|e| e.to_string())
23315    }
23316}
23317
23318// ---------------------------------------------------------------------------
23319// agents.chat — unified chat surface (docs/proposals/agent-chat-surface.md)
23320// ---------------------------------------------------------------------------
23321//
23322// Host calls `agents.chat { agent_id, prompt, session_id?, stream? }`.
23323// The server looks up the target agent's attached WS connection,
23324// reverse-calls `agent.chat { session_id, prompt, context }` on it
23325// (same pattern as `tools.execute`), and returns once the agent acks.
23326// The agent then streams `agent.chat.event` notifications back, which
23327// the dispatcher intercepts (see `try_forward_agent_chat_event`) and
23328// rewrites as `agents.chat.event` notifications on the originating
23329// host's channel.
23330
23331/// Timeout the server waits for the agent to ack `agent.chat`. The
23332/// streamed tokens come later as separate notifications and have no
23333/// bearing on this — this is just "did the agent receive the prompt
23334/// and accept it." Five seconds is generous for a local IPC ack.
23335const AGENT_CHAT_ACK_TIMEOUT_SECS: u64 = 5;
23336
23337/// `agents.chat` — host issues a chat turn to a named agent. Returns
23338/// `{ accepted: true, session_id }` once the agent acks; streamed
23339/// tokens arrive on the host's channel as `agents.chat.event`
23340/// notifications keyed by the same `session_id`.
23341/// Validate and extract the optional `attachments` array from an
23342/// `agents.chat` request's params. Returns `Ok(None)` when absent or
23343/// null; `Err` on a malformed shape (a non-array, or any entry lacking
23344/// an image-`ContentBlock` `type`). Pulled out so the validation is
23345/// unit-testable without the full session machinery.
23346fn extract_chat_attachments(params: &Value) -> Result<Option<Value>, String> {
23347    const ALLOWED_MEDIA: [&str; 4] = ["image/png", "image/jpeg", "image/gif", "image/webp"];
23348    let items = match params.get("attachments") {
23349        None | Some(Value::Null) => return Ok(None),
23350        Some(Value::Array(items)) => items,
23351        Some(_) => return Err("`agents.chat` `attachments` must be an array".to_string()),
23352    };
23353    // Validate each entry's content, not just its `type` tag — we
23354    // forward this verbatim to the agent and on to a provider, so a
23355    // malformed/hostile entry (missing data, a non-image media type, a
23356    // `file://` URL) must not pass through.
23357    for item in items {
23358        let ty = item.get("type").and_then(Value::as_str).ok_or_else(|| {
23359            "`agents.chat` `attachments` entries must have a string `type`".to_string()
23360        })?;
23361        match ty {
23362            "image_base64" => {
23363                if item.get("data").and_then(Value::as_str).is_none() {
23364                    return Err("`image_base64` attachment requires a string `data`".to_string());
23365                }
23366                let media = item.get("media_type").and_then(Value::as_str).unwrap_or("");
23367                if !ALLOWED_MEDIA.contains(&media) {
23368                    return Err(format!(
23369                        "`image_base64` attachment `media_type` must be one of {ALLOWED_MEDIA:?}"
23370                    ));
23371                }
23372            }
23373            "image_url" => {
23374                let url = item.get("url").and_then(Value::as_str).unwrap_or("");
23375                if !(url.starts_with("https://") || url.starts_with("http://")) {
23376                    return Err("`image_url` attachment `url` must be http(s)".to_string());
23377                }
23378            }
23379            other => {
23380                return Err(format!(
23381                    "`agents.chat` `attachments` entries must be image ContentBlocks \
23382                     (`image_base64` or `image_url`), got `{other}`"
23383                ));
23384            }
23385        }
23386    }
23387    Ok(Some(Value::Array(items.clone())))
23388}
23389
23390/// Validate and extract an optional deterministic goal check for an
23391/// `agents.chat` turn. When present, the served assistant runs the prompt as a
23392/// goal loop and streams `goal_evaluated` after each verifier pass.
23393fn extract_chat_goal(params: &Value) -> Result<Option<Value>, String> {
23394    let Some(goal) = params.get("goal") else {
23395        return Ok(None);
23396    };
23397    if goal.is_null() {
23398        return Ok(None);
23399    }
23400    let obj = goal
23401        .as_object()
23402        .ok_or_else(|| "`agents.chat` `goal` must be an object".to_string())?;
23403    let check = obj
23404        .get("check")
23405        .and_then(Value::as_str)
23406        .map(str::trim)
23407        .filter(|s| !s.is_empty())
23408        .ok_or_else(|| "`agents.chat` `goal.check` must be a non-empty string".to_string())?;
23409    let max_iterations = obj
23410        .get("max_iterations")
23411        .and_then(Value::as_u64)
23412        .unwrap_or(8)
23413        .clamp(1, 50);
23414    Ok(Some(serde_json::json!({
23415        "check": check,
23416        "max_iterations": max_iterations,
23417    })))
23418}
23419
23420/// Validate the optional explicit inference model for a native chat turn.
23421/// Omitted/null/blank preserves the target agent's own model or adaptive
23422/// routing default. A non-empty value uses the same `model` selector carried
23423/// by CAR's typed inference requests.
23424fn extract_chat_model(params: &Value) -> Result<Option<String>, String> {
23425    match params.get("model") {
23426        None | Some(Value::Null) => Ok(None),
23427        Some(Value::String(model)) => {
23428            Ok((!model.trim().is_empty()).then(|| model.trim().to_string()))
23429        }
23430        Some(_) => Err("`agents.chat` `model` must be a string".to_string()),
23431    }
23432}
23433
23434fn unix_secs_now() -> u64 {
23435    std::time::SystemTime::now()
23436        .duration_since(std::time::UNIX_EPOCH)
23437        .map(|d| d.as_secs())
23438        .unwrap_or(0)
23439}
23440
23441fn chat_goal_value_to_state(session_id: &str, goal: &Value) -> Result<ChatGoalState, String> {
23442    let obj = goal
23443        .as_object()
23444        .ok_or_else(|| "`goal` must be an object".to_string())?;
23445    let check = obj
23446        .get("check")
23447        .and_then(Value::as_str)
23448        .map(str::trim)
23449        .filter(|s| !s.is_empty())
23450        .ok_or_else(|| "`goal.check` must be a non-empty string".to_string())?;
23451    let max_iterations = obj
23452        .get("max_iterations")
23453        .and_then(Value::as_u64)
23454        .unwrap_or(8)
23455        .clamp(1, 50) as u32;
23456    Ok(ChatGoalState {
23457        session_id: session_id.to_string(),
23458        check: check.to_string(),
23459        max_iterations,
23460        status: "active".to_string(),
23461        last_iteration: None,
23462        last_met: None,
23463        last_grounded: None,
23464        last_reason: None,
23465        terminal_kind: None,
23466        terminal_message: None,
23467        updated_at: unix_secs_now(),
23468    })
23469}
23470
23471fn chat_goal_state_to_value(goal: &ChatGoalState) -> Result<Value, String> {
23472    serde_json::to_value(goal).map_err(|e| e.to_string())
23473}
23474
23475fn chat_goal_state_to_wire_goal(goal: &ChatGoalState) -> Value {
23476    serde_json::json!({
23477        "check": goal.check,
23478        "max_iterations": goal.max_iterations,
23479    })
23480}
23481
23482async fn ensure_chat_goal_running(
23483    state: &Arc<ServerState>,
23484    session_id: &str,
23485    goal_value: &Value,
23486) -> Result<(), String> {
23487    let mut inserted = false;
23488    {
23489        let mut goals = state.chat_goals.lock().await;
23490        if let Some(stored) = goals.get_mut(session_id) {
23491            stored.status = "running".to_string();
23492            stored.terminal_kind = None;
23493            stored.terminal_message = None;
23494            stored.updated_at = unix_secs_now();
23495        } else {
23496            let mut goal = chat_goal_value_to_state(session_id, goal_value)?;
23497            goal.status = "running".to_string();
23498            goals.insert(session_id.to_string(), goal);
23499            inserted = true;
23500        }
23501    }
23502    state.persist_chat_goals().await?;
23503    if inserted {
23504        tracing::debug!(
23505            session_id,
23506            "created durable chat goal status for inline goal"
23507        );
23508    }
23509    Ok(())
23510}
23511
23512async fn handle_goal_suggest(
23513    req: &JsonRpcMessage,
23514    state: &Arc<ServerState>,
23515) -> Result<Value, String> {
23516    let prompt = req
23517        .params
23518        .get("prompt")
23519        .or_else(|| req.params.get("objective"))
23520        .and_then(Value::as_str)
23521        .map(str::trim)
23522        .filter(|s| !s.is_empty())
23523        .ok_or_else(|| "`goal.suggest` requires `prompt` or `objective`".to_string())?;
23524    let cwd = req
23525        .params
23526        .get("working_dir")
23527        .or_else(|| req.params.get("cwd"))
23528        .and_then(Value::as_str)
23529        .map(std::path::PathBuf::from);
23530    let mut result = crate::goal_suggest::synthesize_goal_check(prompt, cwd.as_deref());
23531
23532    let should_set = req
23533        .params
23534        .get("set")
23535        .and_then(Value::as_bool)
23536        .unwrap_or(false);
23537    if should_set {
23538        let session_id = req
23539            .params
23540            .get("session_id")
23541            .and_then(Value::as_str)
23542            .map(str::trim)
23543            .filter(|s| !s.is_empty())
23544            .ok_or_else(|| "`goal.suggest` with `set: true` requires `session_id`".to_string())?;
23545        let goal_value = result.get("goal").cloned().unwrap_or(Value::Null);
23546        if goal_value.is_null() {
23547            return Err("`goal.suggest` could not infer a goal to set".to_string());
23548        }
23549        let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23550        state
23551            .chat_goals
23552            .lock()
23553            .await
23554            .insert(session_id.to_string(), goal.clone());
23555        state.persist_chat_goals().await?;
23556        if let Some(obj) = result.as_object_mut() {
23557            obj.insert("set".to_string(), Value::Bool(true));
23558            obj.insert(
23559                "session_id".to_string(),
23560                Value::String(session_id.to_string()),
23561            );
23562            obj.insert("stored_goal".to_string(), chat_goal_state_to_value(&goal)?);
23563        }
23564    }
23565
23566    Ok(result)
23567}
23568
23569async fn handle_goal_set(req: &JsonRpcMessage, state: &Arc<ServerState>) -> Result<Value, String> {
23570    let session_id = req
23571        .params
23572        .get("session_id")
23573        .and_then(Value::as_str)
23574        .map(str::trim)
23575        .filter(|s| !s.is_empty())
23576        .ok_or_else(|| "`goal.set` requires `session_id`".to_string())?;
23577    let goal_value = if let Some(goal) = req.params.get("goal") {
23578        goal.clone()
23579    } else {
23580        serde_json::json!({
23581            "check": req.params.get("check").cloned().unwrap_or(Value::Null),
23582            "max_iterations": req.params.get("max_iterations").cloned().unwrap_or(Value::Null),
23583        })
23584    };
23585    let goal = chat_goal_value_to_state(session_id, &goal_value)?;
23586    state
23587        .chat_goals
23588        .lock()
23589        .await
23590        .insert(session_id.to_string(), goal.clone());
23591    state.persist_chat_goals().await?;
23592    Ok(serde_json::json!({
23593        "set": true,
23594        "goal": chat_goal_state_to_value(&goal)?,
23595    }))
23596}
23597
23598async fn handle_goal_status(
23599    req: &JsonRpcMessage,
23600    state: &Arc<ServerState>,
23601) -> Result<Value, String> {
23602    let goals = state.chat_goals.lock().await;
23603    if let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) {
23604        let goal = goals.get(session_id).cloned();
23605        return Ok(serde_json::json!({
23606            "session_id": session_id,
23607            "goal": goal
23608                .as_ref()
23609                .map(chat_goal_state_to_value)
23610                .transpose()?
23611                .unwrap_or(Value::Null),
23612        }));
23613    }
23614    let mut values = Vec::with_capacity(goals.len());
23615    for goal in goals.values() {
23616        values.push(chat_goal_state_to_value(goal)?);
23617    }
23618    Ok(serde_json::json!({ "goals": values }))
23619}
23620
23621async fn handle_goal_clear(
23622    req: &JsonRpcMessage,
23623    state: &Arc<ServerState>,
23624) -> Result<Value, String> {
23625    let Some(session_id) = req.params.get("session_id").and_then(Value::as_str) else {
23626        let mut goals = state.chat_goals.lock().await;
23627        let removed = goals.len();
23628        goals.clear();
23629        drop(goals);
23630        state.persist_chat_goals().await?;
23631        return Ok(serde_json::json!({ "cleared": removed }));
23632    };
23633    let removed = state.chat_goals.lock().await.remove(session_id).is_some();
23634    state.persist_chat_goals().await?;
23635    Ok(serde_json::json!({
23636        "cleared": removed,
23637        "session_id": session_id,
23638    }))
23639}
23640
23641/// Build the `params` object for the reverse `agent.chat` call. The
23642/// `attachments` key is inserted only when present, so agents that
23643/// don't read it see no change to the request shape. Pulled out so the
23644/// attachment-forwarding is unit-testable.
23645fn build_agent_chat_params(
23646    session_id: &str,
23647    prompt: &str,
23648    stream: bool,
23649    host_client_id: &str,
23650    voice_input: bool,
23651    model: Option<String>,
23652    attachments: Option<Value>,
23653    goal: Option<Value>,
23654) -> Value {
23655    let mut params = serde_json::json!({
23656        "session_id": session_id,
23657        "prompt": prompt,
23658        "stream": stream,
23659        "context": {
23660            "host_client_id": host_client_id,
23661            "voice_input": voice_input,
23662        },
23663    });
23664    if let Some(model) = model {
23665        if let Some(obj) = params.as_object_mut() {
23666            obj.insert("model".to_string(), Value::String(model));
23667        }
23668    }
23669    if let Some(att) = attachments {
23670        if let Some(obj) = params.as_object_mut() {
23671            obj.insert("attachments".to_string(), att);
23672        }
23673    }
23674    if let Some(goal) = goal {
23675        if let Some(obj) = params.as_object_mut() {
23676            obj.insert("goal".to_string(), goal);
23677        }
23678    }
23679    params
23680}
23681
23682/// Resolve a caller-supplied `agent_id` against the flagship assistant's
23683/// canonical/legacy id pair (car#1107). If `agent_id` names the assistant
23684/// under either spelling (`parslee-core` or `car-assistant`) and the daemon
23685/// only has the *other* spelling attached, return the attached one; otherwise
23686/// return `agent_id` unchanged. Keeps macOS (hardcoded `car-assistant`) and
23687/// mobile (hardcoded `parslee-core`) both working against a daemon that
23688/// attaches the flagship under one spelling at a time.
23689async fn resolve_assistant_agent_alias(agent_id: String, state: &Arc<ServerState>) -> String {
23690    if !crate::assistant::register::is_assistant_alias(&agent_id) {
23691        return agent_id;
23692    }
23693    let attached = state.attached_agents.lock().await;
23694    if attached.contains_key(&agent_id) {
23695        return agent_id;
23696    }
23697    for alias in [
23698        crate::assistant::register::ASSISTANT_AGENT_ID,
23699        crate::assistant::register::LEGACY_ASSISTANT_AGENT_ID,
23700    ] {
23701        if alias != agent_id && attached.contains_key(alias) {
23702            return alias.to_string();
23703        }
23704    }
23705    agent_id
23706}
23707
23708async fn handle_agents_chat(
23709    req: &JsonRpcMessage,
23710    state: &Arc<ServerState>,
23711    host_session: &Arc<crate::session::ClientSession>,
23712) -> Result<Value, String> {
23713    use futures::SinkExt;
23714    use tokio::sync::oneshot;
23715    use tokio_tungstenite::tungstenite::Message;
23716
23717    let agent_id = req
23718        .params
23719        .get("agent_id")
23720        .and_then(Value::as_str)
23721        .ok_or_else(|| "`agents.chat` requires `agent_id`".to_string())?
23722        .to_string();
23723    // Resolve `car-assistant` / `parslee-core` to whichever spelling the
23724    // flagship assistant actually attached under (car#1107). This is the
23725    // single call site: `agents.chat.approve` / `.cancel` read the resolved
23726    // id back out of `chat_sessions` (see `ChatSession::agent_id` below),
23727    // so they need no resolution of their own.
23728    let agent_id = resolve_assistant_agent_alias(agent_id, state).await;
23729    let prompt = req
23730        .params
23731        .get("prompt")
23732        .and_then(Value::as_str)
23733        .ok_or_else(|| "`agents.chat` requires `prompt`".to_string())?
23734        .to_string();
23735    let session_id = req
23736        .params
23737        .get("session_id")
23738        .and_then(Value::as_str)
23739        .map(str::to_string)
23740        .unwrap_or_else(|| format!("chat-{}", uuid::Uuid::new_v4().simple()));
23741    let stream = req
23742        .params
23743        .get("stream")
23744        .and_then(Value::as_bool)
23745        .unwrap_or(true);
23746    let voice_input = req
23747        .params
23748        .get("voice_input")
23749        .and_then(Value::as_bool)
23750        .unwrap_or(false);
23751    let model = extract_chat_model(&req.params)?;
23752
23753    // Optional image attachments (ContentBlock image variants). The
23754    // daemon is a pass-through here — it validates the shape lightly and
23755    // forwards them verbatim to the agent's `agent.chat` handler, which
23756    // hands them to inference as `imagesJson`.
23757    let attachments = extract_chat_attachments(&req.params)?;
23758    let inline_goal = extract_chat_goal(&req.params)?;
23759    let stored_goal = if inline_goal.is_none() {
23760        state
23761            .chat_goals
23762            .lock()
23763            .await
23764            .get(&session_id)
23765            .map(chat_goal_state_to_wire_goal)
23766    } else {
23767        None
23768    };
23769    let goal = inline_goal.or(stored_goal);
23770    if goal.is_some() && attachments.is_some() {
23771        return Err(
23772            "`agents.chat` `goal` currently cannot be combined with `attachments`".to_string(),
23773        );
23774    }
23775    let has_attached_agent = state.attached_agents.lock().await.contains_key(&agent_id);
23776    let declarative_spec = if has_attached_agent {
23777        None
23778    } else {
23779        state.declagents().ok().and_then(|reg| reg.get(&agent_id))
23780    };
23781
23782    // Same admission `agents.message` applies. Without it this method was the
23783    // way around that one: an agent denied at the read-only tier could reach
23784    // another agent by calling `agents.chat` instead, and two agents could
23785    // answer each other with nothing to terminate the loop. The host is exempt
23786    // inside the helper — it is the operator's own client.
23787    //
23788    // Placed AFTER the agent is known to exist, and that ordering is
23789    // load-bearing twice over. The guard records the (sender, body) pair on
23790    // admit, so admitting first meant an unroutable `agent_id` burned the
23791    // dedupe window and the caller's honest retry came back "already
23792    // delivered" — false, and it points away from the only thing that would
23793    // work. It also meant every caller-supplied string minted a permanent
23794    // `DeliveryGuard` in a map only a disconnecting bound agent ever prunes.
23795    // `handle_agents_message` never had either problem because it resolves the
23796    // directory before the guard; this now matches it.
23797    if !has_attached_agent && declarative_spec.is_none() {
23798        return Err(format!(
23799            "`{agent_id}` is not attached to this daemon and is not a declarative agent"
23800        ));
23801    }
23802    let chat_principal = session_principal_for_peers(host_session).await;
23803    {
23804        let sender_agent = host_session.agent_id.lock().await.clone();
23805        let is_host = host_session
23806            .is_host
23807            .load(std::sync::atomic::Ordering::Acquire);
23808        crate::peers::admit_turn(
23809            &state.clone(),
23810            &chat_principal,
23811            sender_agent,
23812            is_host,
23813            &agent_id,
23814            &prompt,
23815        )
23816        .await?;
23817    }
23818
23819    if !has_attached_agent {
23820        if let Some(spec) = declarative_spec {
23821            return handle_declarative_agents_chat(
23822                state,
23823                host_session,
23824                spec,
23825                prompt,
23826                session_id,
23827                stream,
23828                model,
23829                attachments,
23830                goal,
23831            )
23832            .await;
23833        }
23834    }
23835    // Resolve the agent's attached WS channel via `attached_agents` →
23836    // `sessions` → `channel`. Both lookups must hit; a missing entry on
23837    // either side means the agent is registered in `agents.json` but
23838    // hasn't `session.auth`'d (or has disconnected), so refuse with a
23839    // structured error rather than silently parking the chat.
23840    // A turn admitted above but not delivered here must give back its dedupe
23841    // record: the agent was attached when we checked and is gone now, so the
23842    // caller's immediate retry is a legitimate retry, not the loop the dedupe
23843    // window exists to break. The rate budget is deliberately not refunded —
23844    // the attempt did cost the channel something.
23845    let resolved = async {
23846        let agent_client_id = state
23847            .attached_agents
23848            .lock()
23849            .await
23850            .get(&agent_id)
23851            .cloned()
23852            .ok_or_else(|| {
23853                format!(
23854                    "agent `{}` is not attached to this daemon — supervisor may have it stopped, or it hasn't called session.auth yet",
23855                    agent_id
23856                )
23857            })?;
23858        let sessions = state.sessions.lock().await;
23859        sessions
23860            .get(&agent_client_id)
23861            .map(|s| s.channel.clone())
23862            .ok_or_else(|| {
23863                format!(
23864                    "agent `{}` client_id `{}` not found in session registry (raced with disconnect)",
23865                    agent_id, agent_client_id
23866                )
23867            })
23868    }
23869    .await;
23870    let agent_channel = match resolved {
23871        Ok(ch) => ch,
23872        Err(e) => {
23873            crate::peers::forget_synchronous_turn(state, &chat_principal, &agent_id, &prompt).await;
23874            return Err(e);
23875        }
23876    };
23877
23878    // Record who drove this turn. See `crate::peers::append_agent_chat_audit`
23879    // for why this lands here in the same change as `agents.message`.
23880    crate::peers::append_agent_chat_audit(
23881        state,
23882        &session_principal(host_session).await,
23883        &agent_id,
23884        &session_id,
23885    );
23886
23887    if let Some(goal_value) = &goal {
23888        ensure_chat_goal_running(state, &session_id, goal_value).await?;
23889    }
23890
23891    // Register the chat session BEFORE sending the reverse call so any
23892    // `agent.chat.event` notifications the agent sends as part of
23893    // accepting the chat (e.g. an immediate `token` delta) route
23894    // correctly. Indexed by session_id so the notification interceptor
23895    // can locate the originating host without scanning.
23896    {
23897        let created_at = std::time::SystemTime::now()
23898            .duration_since(std::time::UNIX_EPOCH)
23899            .map(|d| d.as_secs())
23900            .unwrap_or(0);
23901        state.chat_sessions.lock().await.insert(
23902            session_id.clone(),
23903            crate::session::ChatSession {
23904                agent_id: agent_id.clone(),
23905                host_client_id: host_session.client_id.clone(),
23906                created_at,
23907                local_cancel: None,
23908            },
23909        );
23910    }
23911
23912    // Reverse-callback: register a oneshot for the ack, send the
23913    // `agent.chat` JSON-RPC request on the agent's channel, await up
23914    // to AGENT_CHAT_ACK_TIMEOUT_SECS. Uses the same `pending` map the
23915    // tool-callback path uses (`WsToolExecutor`) — the dispatcher's
23916    // response demuxer at the top of `run_dispatch` already routes
23917    // `result` / `error` frames keyed by request id back through it.
23918    let request_id = agent_channel.next_request_id();
23919    let (tx, rx) = oneshot::channel();
23920    agent_channel
23921        .pending
23922        .lock()
23923        .await
23924        .insert(request_id.clone(), tx);
23925
23926    let chat_params = build_agent_chat_params(
23927        &session_id,
23928        &prompt,
23929        stream,
23930        &host_session.client_id,
23931        voice_input,
23932        model,
23933        attachments,
23934        goal,
23935    );
23936    let rpc_request = serde_json::json!({
23937        "jsonrpc": "2.0",
23938        "method": "agent.chat",
23939        "params": chat_params,
23940        "id": request_id,
23941    });
23942    let msg = Message::Text(
23943        serde_json::to_string(&rpc_request)
23944            .map_err(|e| e.to_string())?
23945            .into(),
23946    );
23947    if let Err(e) = agent_channel.write.lock().await.send(msg).await {
23948        // Send failed — drop the pending waiter and the chat session
23949        // entry so a retry can take a fresh session_id without
23950        // colliding.
23951        agent_channel.pending.lock().await.remove(&request_id);
23952        state.chat_sessions.lock().await.remove(&session_id);
23953        update_chat_goal_from_event(
23954            state,
23955            &session_id,
23956            &serde_json::json!({
23957                "kind": "error",
23958                "error": format!("failed to deliver agent.chat to `{}`: {}", agent_id, e),
23959            }),
23960        )
23961        .await;
23962        return Err(format!(
23963            "failed to deliver agent.chat to `{}`: {}",
23964            agent_id, e
23965        ));
23966    }
23967
23968    // Await the agent's ack. The dispatcher's response demuxer routes
23969    // the result/error back via the oneshot. Timeout means the agent
23970    // is alive but unresponsive — clean up routing state and surface a
23971    // structured error so the host UI doesn't hang.
23972    let ack = match tokio::time::timeout(
23973        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
23974        rx,
23975    )
23976    .await
23977    {
23978        Ok(Ok(resp)) => resp,
23979        Ok(Err(_)) => {
23980            // Channel closed — agent disconnected mid-call.
23981            state.chat_sessions.lock().await.remove(&session_id);
23982            update_chat_goal_from_event(
23983                state,
23984                &session_id,
23985                &serde_json::json!({
23986                    "kind": "error",
23987                    "error": format!("agent `{}` disconnected before acking agents.chat", agent_id),
23988                }),
23989            )
23990            .await;
23991            return Err(format!(
23992                "agent `{}` disconnected before acking agents.chat",
23993                agent_id
23994            ));
23995        }
23996        Err(_) => {
23997            // Timeout — agent didn't respond in time. Don't keep the
23998            // chat session around: any later events from the agent
23999            // would route to a host that already returned an error.
24000            agent_channel.pending.lock().await.remove(&request_id);
24001            state.chat_sessions.lock().await.remove(&session_id);
24002            update_chat_goal_from_event(
24003                state,
24004                &session_id,
24005                &serde_json::json!({
24006                    "kind": "error",
24007                    "error": format!(
24008                        "agent `{}` did not ack agents.chat within {}s",
24009                        agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24010                    ),
24011                }),
24012            )
24013            .await;
24014            return Err(format!(
24015                "agent `{}` did not ack agents.chat within {}s",
24016                agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24017            ));
24018        }
24019    };
24020
24021    if let Some(err) = ack.error {
24022        // Agent explicitly rejected — drop the session and propagate.
24023        state.chat_sessions.lock().await.remove(&session_id);
24024        update_chat_goal_from_event(
24025            state,
24026            &session_id,
24027            &serde_json::json!({
24028                "kind": "error",
24029                "error": format!("agent `{}` rejected chat: {}", agent_id, err),
24030            }),
24031        )
24032        .await;
24033        return Err(format!("agent `{}` rejected chat: {}", agent_id, err));
24034    }
24035
24036    Ok(serde_json::json!({
24037        "accepted": true,
24038        "session_id": session_id,
24039    }))
24040}
24041
24042async fn handle_declarative_agents_chat(
24043    state: &Arc<ServerState>,
24044    host_session: &Arc<crate::session::ClientSession>,
24045    spec: car_registry::declarative::DeclarativeAgentSpec,
24046    prompt: String,
24047    session_id: String,
24048    _stream: bool,
24049    model: Option<String>,
24050    attachments: Option<Value>,
24051    goal: Option<Value>,
24052) -> Result<Value, String> {
24053    if attachments.is_some() {
24054        return Err("declarative agents do not support `agents.chat` attachments".to_string());
24055    }
24056    if goal.is_some() {
24057        return Err(
24058            "`agents.chat` inline/standing goals are only supported by attached chat agents; \
24059             declarative agents use their manifest `goal`"
24060                .to_string(),
24061        );
24062    }
24063    if !spec.enabled {
24064        return Err(format!("agent '{}' is disabled", spec.id));
24065    }
24066
24067    let local_cancel = Arc::new(AtomicBool::new(false));
24068    {
24069        let created_at = std::time::SystemTime::now()
24070            .duration_since(std::time::UNIX_EPOCH)
24071            .map(|d| d.as_secs())
24072            .unwrap_or(0);
24073        state.chat_sessions.lock().await.insert(
24074            session_id.clone(),
24075            crate::session::ChatSession {
24076                agent_id: spec.id.clone(),
24077                host_client_id: host_session.client_id.clone(),
24078                created_at,
24079                local_cancel: Some(local_cancel.clone()),
24080            },
24081        );
24082    }
24083
24084    let state_for_task = state.clone();
24085    let sid = session_id.clone();
24086    tokio::spawn(async move {
24087        let result = crate::coder::rpc::run_declarative_with_cancel_and_model(
24088            &spec,
24089            &prompt,
24090            &state_for_task,
24091            Some(local_cancel.clone()),
24092            model,
24093        )
24094        .await;
24095        match result {
24096            Ok(run) => {
24097                if local_cancel.load(Ordering::SeqCst) {
24098                    send_external_chat_frame(
24099                        &state_for_task,
24100                        &sid,
24101                        serde_json::json!({
24102                            "session_id": sid.clone(),
24103                            "agent_id": spec.id.clone(),
24104                            "kind": "error",
24105                            "error": "cancelled",
24106                            "result": crate::coder::rpc::run_result_json(&run),
24107                        }),
24108                    )
24109                    .await;
24110                    state_for_task.chat_sessions.lock().await.remove(&sid);
24111                    return;
24112                }
24113                crate::coder::rpc::record_routing_outcome(&state_for_task, &spec.id, &run);
24114                if let Some(goal) = &run.goal {
24115                    send_external_chat_frame(
24116                        &state_for_task,
24117                        &sid,
24118                        serde_json::json!({
24119                            "session_id": sid.clone(),
24120                            "agent_id": spec.id.clone(),
24121                            "kind": "goal_evaluated",
24122                            "iteration": goal.iterations,
24123                            "met": goal.met,
24124                            "grounded": goal.grounded,
24125                            "reason": goal.last_reason,
24126                        }),
24127                    )
24128                    .await;
24129                }
24130                let result_json = crate::coder::rpc::run_result_json(&run);
24131                if let Some(err) = run.error.clone() {
24132                    send_external_chat_frame(
24133                        &state_for_task,
24134                        &sid,
24135                        serde_json::json!({
24136                            "session_id": sid.clone(),
24137                            "agent_id": spec.id.clone(),
24138                            "kind": "error",
24139                            "error": err,
24140                            "result": result_json,
24141                        }),
24142                    )
24143                    .await;
24144                } else {
24145                    let text = run.output.clone();
24146                    if !text.is_empty() {
24147                        send_external_chat_frame(
24148                            &state_for_task,
24149                            &sid,
24150                            serde_json::json!({
24151                                "session_id": sid.clone(),
24152                                "agent_id": spec.id.clone(),
24153                                "kind": "token",
24154                                "delta": text,
24155                            }),
24156                        )
24157                        .await;
24158                    }
24159                    send_external_chat_frame(
24160                        &state_for_task,
24161                        &sid,
24162                        serde_json::json!({
24163                            "session_id": sid.clone(),
24164                            "agent_id": spec.id.clone(),
24165                            "kind": "done",
24166                            "text": text,
24167                            "finish_reason": "declarative agent completed",
24168                            "result": result_json,
24169                        }),
24170                    )
24171                    .await;
24172                }
24173            }
24174            Err(e) => {
24175                send_external_chat_frame(
24176                    &state_for_task,
24177                    &sid,
24178                    serde_json::json!({
24179                        "session_id": sid.clone(),
24180                        "agent_id": spec.id.clone(),
24181                        "kind": "error",
24182                        "error": e,
24183                    }),
24184                )
24185                .await;
24186            }
24187        }
24188        state_for_task.chat_sessions.lock().await.remove(&sid);
24189    });
24190
24191    Ok(serde_json::json!({
24192        "accepted": true,
24193        "session_id": session_id,
24194        "in_daemon": true,
24195    }))
24196}
24197
24198/// Overall deadline for an A2A conversational turn: how long [`chat_collect`]
24199/// waits for the host agent to finish streaming after it acks. Generous (the
24200/// host may run several tool round-trips) but bounded so an A2A task can't hang
24201/// forever on a wedged agent.
24202const A2A_CHAT_TIMEOUT_SECS: u64 = 180;
24203
24204/// Reverse-call `agent.chat` on `host_channel` and aggregate the streamed
24205/// `agent.chat.event` deltas into a single reply string. The A2A conversational
24206/// bridge uses this — it has no host UI to stream to, so it registers an
24207/// in-process collector (keyed by a fresh `session_id`) that
24208/// [`try_forward_agent_chat_event`] feeds, then accumulates `token` deltas until
24209/// the terminal `done`/`error`. Always removes its collector on the way out.
24210/// Principal recorded for a conversational A2A message routed to the host
24211/// agent's loop. One bucket, not one per remote caller: the `a2a.start`
24212/// listener is `NoAuth`, so there is no verified caller to name — and inventing
24213/// a per-request identity from unauthenticated input would put a forgeable
24214/// string in the audit journal, which is worse than an honest single bucket.
24215pub(crate) const A2A_CONVERSATIONAL_PRINCIPAL: &str = "a2a:conversational";
24216
24217pub(crate) async fn chat_collect(
24218    state: &Arc<ServerState>,
24219    host_channel: &Arc<crate::session::WsChannel>,
24220    host_client_id: &str,
24221    prompt: &str,
24222) -> Result<String, String> {
24223    use futures::SinkExt;
24224    use tokio::sync::{mpsc, oneshot};
24225    use tokio_tungstenite::tungstenite::Message;
24226
24227    // This sends the same `agent.chat` frame as `handle_agents_chat` and had
24228    // none of its admission — no guard, no policy, no audit — while being
24229    // reachable from the `a2a.start` listener, which serves NO authentication.
24230    // Gating the *bind* (loopback unless `allow_non_loopback_bind`) bounds who
24231    // can reach it; this bounds what they can do once they have. There is no
24232    // agent principal to grade — the caller is a remote party the operator
24233    // deliberately exposed with `share_session_runtime` — so the channel guard
24234    // is the whole admission here, which is what stops a remote peer looping or
24235    // flooding the host agent's turns.
24236    crate::peers::admit_turn(
24237        state,
24238        A2A_CONVERSATIONAL_PRINCIPAL,
24239        None,
24240        false,
24241        host_client_id,
24242        prompt,
24243    )
24244    .await?;
24245
24246    let session_id = format!("a2a-chat-{}", uuid::Uuid::new_v4().simple());
24247    let (chunk_tx, mut chunk_rx) = mpsc::unbounded_channel::<crate::session::ChatStreamChunk>();
24248    state.chat_collectors.lock().await.insert(
24249        session_id.clone(),
24250        crate::session::ChatCollector {
24251            tx: chunk_tx,
24252            host_client_id: host_client_id.to_string(),
24253        },
24254    );
24255
24256    // Ack round-trip reuses the tools.execute pending-response demuxer.
24257    let request_id = host_channel.next_request_id();
24258    let (ack_tx, ack_rx) = oneshot::channel();
24259    host_channel
24260        .pending
24261        .lock()
24262        .await
24263        .insert(request_id.clone(), ack_tx);
24264
24265    let req = serde_json::json!({
24266        "jsonrpc": "2.0",
24267        "method": "agent.chat",
24268        "params": {
24269            "session_id": session_id,
24270            "prompt": prompt,
24271            "stream": true,
24272            "context": { "source": "a2a" },
24273        },
24274        "id": request_id,
24275    });
24276    if let Err(e) = host_channel
24277        .write
24278        .lock()
24279        .await
24280        .send(Message::Text(
24281            serde_json::to_string(&req).unwrap_or_default().into(),
24282        ))
24283        .await
24284    {
24285        host_channel.pending.lock().await.remove(&request_id);
24286        state.chat_collectors.lock().await.remove(&session_id);
24287        return Err(format!("failed to deliver agent.chat to host: {e}"));
24288    }
24289
24290    // Wait for the host to ack (same 5s budget as agents.chat).
24291    let ack = tokio::time::timeout(
24292        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
24293        ack_rx,
24294    )
24295    .await;
24296    match ack {
24297        Ok(Ok(resp)) => {
24298            if let Some(err) = resp.error {
24299                state.chat_collectors.lock().await.remove(&session_id);
24300                return Err(format!("host agent rejected chat: {err}"));
24301            }
24302        }
24303        Ok(Err(_)) => {
24304            state.chat_collectors.lock().await.remove(&session_id);
24305            return Err("host agent disconnected before acking agent.chat".to_string());
24306        }
24307        Err(_) => {
24308            host_channel.pending.lock().await.remove(&request_id);
24309            state.chat_collectors.lock().await.remove(&session_id);
24310            return Err(format!(
24311                "host agent did not ack agent.chat within {AGENT_CHAT_ACK_TIMEOUT_SECS}s"
24312            ));
24313        }
24314    }
24315
24316    // Aggregate streamed deltas until the terminal event or the overall timeout.
24317    let collect = collect_chat_stream(&mut chunk_rx);
24318    let result = tokio::time::timeout(
24319        std::time::Duration::from_secs(A2A_CHAT_TIMEOUT_SECS),
24320        collect,
24321    )
24322    .await;
24323    state.chat_collectors.lock().await.remove(&session_id);
24324    match result {
24325        Ok(r) => r,
24326        Err(_) => Err(format!(
24327            "host agent did not complete the reply within {A2A_CHAT_TIMEOUT_SECS}s"
24328        )),
24329    }
24330}
24331
24332/// [`car_a2a::ChatResponder`] backed by the WS session that started the A2A
24333/// listener: a conversational A2A `message/send` reverse-calls `agent.chat` on
24334/// that session and returns the host agent's aggregated reply. Holds a `Weak`
24335/// to `ServerState` so a dropped daemon doesn't keep it alive, and resolves the
24336/// host channel fresh each call so a reconnect/disconnect surfaces as an error.
24337pub(crate) struct WsChatResponder {
24338    pub state: std::sync::Weak<ServerState>,
24339    pub host_client_id: String,
24340}
24341
24342#[async_trait::async_trait]
24343impl car_a2a::ChatResponder for WsChatResponder {
24344    async fn respond(&self, prompt: &str) -> Result<String, String> {
24345        let state = self
24346            .state
24347            .upgrade()
24348            .ok_or_else(|| "daemon is shutting down".to_string())?;
24349        let host_channel = {
24350            let sessions = state.sessions.lock().await;
24351            sessions
24352                .get(&self.host_client_id)
24353                .map(|s| s.channel.clone())
24354        }
24355        .ok_or_else(|| "host session for the A2A listener has disconnected".to_string())?;
24356        chat_collect(&state, &host_channel, &self.host_client_id, prompt).await
24357    }
24358}
24359
24360/// `agents.chat.cancel` — host aborts an in-flight chat. Forwards
24361/// `agent.chat.cancel` to the bound agent so the agent can short-
24362/// circuit its inference stream + free upstream resources
24363/// (`inference.stream.cancel`). The chat session is dropped from
24364/// routing state immediately whether or not the agent acks the cancel
24365/// — further `agent.chat.event` notifications for this session_id
24366/// fall on the floor by design.
24367async fn handle_agents_chat_cancel(
24368    req: &JsonRpcMessage,
24369    state: &Arc<ServerState>,
24370    caller_client_id: &str,
24371    caller_is_host: bool,
24372) -> Result<Value, String> {
24373    use futures::SinkExt;
24374    use tokio_tungstenite::tungstenite::Message;
24375
24376    let session_id = req
24377        .params
24378        .get("session_id")
24379        .and_then(Value::as_str)
24380        .ok_or_else(|| "`agents.chat.cancel` requires `session_id`".to_string())?
24381        .to_string();
24382
24383    // Same ownership check `agents.chat.approve` applies one arm over. Without
24384    // it any authenticated session could abort any live turn by guessing or
24385    // observing a `session_id` — the caller was authenticated, and that was
24386    // taken to settle what it may do. Look up before removing, so a caller that
24387    // fails the check does not destroy the routing entry on its way out.
24388    {
24389        let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24390        if let Some(chat) = chat {
24391            if !caller_is_host && caller_client_id != chat.host_client_id {
24392                return Err(
24393                    "`agents.chat.cancel` requires the originating host session or \
24394                     host-management role"
24395                        .to_string(),
24396                );
24397            }
24398        }
24399    }
24400
24401    let chat = state.chat_sessions.lock().await.remove(&session_id);
24402    let chat = match chat {
24403        Some(c) => c,
24404        None => {
24405            // Already cancelled or never existed — idempotent.
24406            return Ok(serde_json::json!({ "cancelled": false, "reason": "unknown session_id" }));
24407        }
24408    };
24409    if let Some(flag) = &chat.local_cancel {
24410        flag.store(true, Ordering::SeqCst);
24411    }
24412    update_chat_goal_from_event(
24413        state,
24414        &session_id,
24415        &serde_json::json!({
24416            "kind": "error",
24417            "error": "cancelled",
24418        }),
24419    )
24420    .await;
24421
24422    // Best-effort fire-and-forget to the agent. We've already removed
24423    // the routing entry, so no need to await any agent response.
24424    let agent_client_id = state
24425        .attached_agents
24426        .lock()
24427        .await
24428        .get(&chat.agent_id)
24429        .cloned();
24430    if let Some(client_id) = agent_client_id {
24431        let channel_opt = {
24432            let sessions = state.sessions.lock().await;
24433            sessions.get(&client_id).map(|s| s.channel.clone())
24434        };
24435        if let Some(channel) = channel_opt {
24436            let notification = serde_json::json!({
24437                "jsonrpc": "2.0",
24438                "method": "agent.chat.cancel",
24439                "params": { "session_id": session_id },
24440            });
24441            if let Ok(text) = serde_json::to_string(&notification) {
24442                let _ = channel
24443                    .write
24444                    .lock()
24445                    .await
24446                    .send(Message::Text(text.into()))
24447                    .await;
24448            }
24449        }
24450    }
24451
24452    Ok(serde_json::json!({ "cancelled": true, "session_id": session_id }))
24453}
24454
24455/// Resolve a chat-surface human-in-the-loop approval (`agents.chat.approve`).
24456///
24457/// The mirror of [`handle_agents_chat_cancel`], but for the `approval_pending`
24458/// round-trip: an agent parked a gated action awaiting a decision (emitting an
24459/// `approval_pending` event with an `approval_id`); a host renders Approve/Deny
24460/// and calls this. We route the decision to the agent's own
24461/// `agent.chat.approve` handler (which resolves the parked oneshot so the turn
24462/// resumes), keyed by the live `chat_sessions` map — the SAME routing
24463/// `agents.chat`/`agents.chat.cancel` use. Unlike cancel, we do NOT drop the
24464/// chat session: the turn continues after the approval.
24465///
24466/// Params: `session_id` (required, routes to the agent), `approval_id`
24467/// (required, identifies the parked action), `decision` (bool, or one of
24468/// `"approve"`/`"approved"`/`"yes"` vs anything else). Forwarded verbatim.
24469///
24470/// This is a reverse *request* (not fire-and-forget like cancel): the agent's
24471/// handler returns `{ resolved: bool }`, which we await (bounded) and relay so
24472/// the host gets a definitive answer rather than guessing from the stream.
24473async fn handle_agents_chat_approve(
24474    req: &JsonRpcMessage,
24475    state: &Arc<ServerState>,
24476    caller_client_id: &str,
24477    caller_is_host: bool,
24478) -> Result<Value, String> {
24479    use futures::SinkExt;
24480    use tokio::sync::oneshot;
24481    use tokio_tungstenite::tungstenite::Message;
24482
24483    let session_id = req
24484        .params
24485        .get("session_id")
24486        .and_then(Value::as_str)
24487        .ok_or_else(|| "`agents.chat.approve` requires `session_id`".to_string())?
24488        .to_string();
24489    let approval_id = req
24490        .params
24491        .get("approval_id")
24492        .and_then(Value::as_str)
24493        .ok_or_else(|| "`agents.chat.approve` requires `approval_id`".to_string())?
24494        .to_string();
24495    // `decision` is forwarded verbatim — the agent-side handler accepts a bool
24496    // or a string (`"approve"`/`"approved"`/`"yes"` → approved). Default to a
24497    // negative bool if omitted so an unspecified decision can never approve.
24498    let decision = req
24499        .params
24500        .get("decision")
24501        .cloned()
24502        .unwrap_or(Value::Bool(false));
24503
24504    // Look up (do NOT remove) the chat session so the turn keeps routing after
24505    // the approval resolves.
24506    let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24507    let chat = chat.ok_or_else(|| {
24508        format!("`agents.chat.approve`: unknown or already-finished session_id `{session_id}`")
24509    })?;
24510    if !caller_is_host && caller_client_id != chat.host_client_id {
24511        return Err(
24512            "`agents.chat.approve` requires the originating host session or host-management role"
24513                .to_string(),
24514        );
24515    }
24516
24517    // Resolve the agent's attached WS channel, same as agents.chat.
24518    let agent_client_id = state
24519        .attached_agents
24520        .lock()
24521        .await
24522        .get(&chat.agent_id)
24523        .cloned()
24524        .ok_or_else(|| {
24525            format!(
24526                "agent `{}` is not attached to this daemon (raced with disconnect)",
24527                chat.agent_id
24528            )
24529        })?;
24530    let agent_channel = {
24531        let sessions = state.sessions.lock().await;
24532        sessions
24533            .get(&agent_client_id)
24534            .map(|s| s.channel.clone())
24535            .ok_or_else(|| {
24536                format!(
24537                    "agent `{}` client_id `{}` not found in session registry",
24538                    chat.agent_id, agent_client_id
24539                )
24540            })?
24541    };
24542
24543    // Reverse-request `agent.chat.approve` and await the agent's `{resolved}`
24544    // reply via the same `pending` demuxer the ack path uses.
24545    let request_id = agent_channel.next_request_id();
24546    let (tx, rx) = oneshot::channel();
24547    agent_channel
24548        .pending
24549        .lock()
24550        .await
24551        .insert(request_id.clone(), tx);
24552
24553    let rpc_request = serde_json::json!({
24554        "jsonrpc": "2.0",
24555        "method": "agent.chat.approve",
24556        "params": { "approval_id": approval_id, "decision": decision },
24557        "id": request_id,
24558    });
24559    let msg = Message::Text(
24560        serde_json::to_string(&rpc_request)
24561            .map_err(|e| e.to_string())?
24562            .into(),
24563    );
24564    if let Err(e) = agent_channel.write.lock().await.send(msg).await {
24565        agent_channel.pending.lock().await.remove(&request_id);
24566        return Err(format!(
24567            "failed to deliver agent.chat.approve to `{}`: {}",
24568            chat.agent_id, e
24569        ));
24570    }
24571
24572    let ack = match tokio::time::timeout(
24573        std::time::Duration::from_secs(AGENT_CHAT_ACK_TIMEOUT_SECS),
24574        rx,
24575    )
24576    .await
24577    {
24578        Ok(Ok(resp)) => resp,
24579        Ok(Err(_)) => {
24580            return Err(format!(
24581                "agent `{}` disconnected before acking agents.chat.approve",
24582                chat.agent_id
24583            ));
24584        }
24585        Err(_) => {
24586            agent_channel.pending.lock().await.remove(&request_id);
24587            return Err(format!(
24588                "agent `{}` did not ack agents.chat.approve within {}s",
24589                chat.agent_id, AGENT_CHAT_ACK_TIMEOUT_SECS
24590            ));
24591        }
24592    };
24593    if let Some(err) = ack.error {
24594        return Err(format!(
24595            "agent `{}` rejected agents.chat.approve: {}",
24596            chat.agent_id, err
24597        ));
24598    }
24599
24600    // Relay the agent's `{resolved: bool}` result; default false if the agent
24601    // returned an unexpected shape.
24602    let resolved = ack
24603        .output
24604        .as_ref()
24605        .and_then(|r| r.get("resolved"))
24606        .and_then(Value::as_bool)
24607        .unwrap_or(false);
24608    Ok(serde_json::json!({ "resolved": resolved, "session_id": session_id }))
24609}
24610
24611async fn update_chat_goal_from_event(state: &Arc<ServerState>, session_id: &str, params: &Value) {
24612    let Some(kind) = params.get("kind").and_then(Value::as_str) else {
24613        return;
24614    };
24615    let changed = {
24616        let mut goals = state.chat_goals.lock().await;
24617        let Some(goal) = goals.get_mut(session_id) else {
24618            return;
24619        };
24620        match kind {
24621            "goal_evaluated" => {
24622                goal.last_iteration = params
24623                    .get("iteration")
24624                    .and_then(Value::as_u64)
24625                    .and_then(|n| u32::try_from(n).ok());
24626                goal.last_met = params.get("met").and_then(Value::as_bool);
24627                goal.last_grounded = params.get("grounded").and_then(Value::as_bool);
24628                goal.last_reason = params
24629                    .get("reason")
24630                    .and_then(Value::as_str)
24631                    .map(str::to_string);
24632                goal.status = if goal.last_met == Some(true) && goal.last_grounded == Some(true) {
24633                    "met".to_string()
24634                } else {
24635                    "running".to_string()
24636                };
24637                goal.updated_at = unix_secs_now();
24638                true
24639            }
24640            // `auth_required` is terminal on the wire exactly as `done` and
24641            // `error` are (`chat::goal_turn_terminal_event`), and nothing
24642            // follows it — so without it here a goal blocked on the account
24643            // stays `"running"` on disk forever, and a `goal.status` poller is
24644            // told the run is still going.
24645            "done" | "error" | "auth_required" => {
24646                goal.terminal_kind = Some(kind.to_string());
24647                // A JSON null is ABSENT, not a value. An `.or_else` chain over
24648                // `params.get` cannot express that: a caller that projects its
24649                // params with always-present keys hands us `Some(&Value::Null)`
24650                // for the first name, the chain stops there, and the message is
24651                // lost. `find_map` over the three names, each read as a string,
24652                // is the reading that survives either caller shape.
24653                goal.terminal_message = ["finish_reason", "error", "message"]
24654                    .iter()
24655                    .find_map(|key| params.get(*key).and_then(Value::as_str))
24656                    .map(str::to_string);
24657                // car#1113 review: a fail-open `done` (the goal check never
24658                // got the chance to run — see `chat::goal_turn_terminal_event`
24659                // / `GoalHalt::EvaluationTimeout`) must not be recorded as
24660                // `"met"`. `finish_reason` alone isn't enough to prevent that
24661                // — it lands in `terminal_message`, a secondary prose field,
24662                // while `status` is the one `docs/websocket-protocol.md`
24663                // documents as what a `goal.status` poller actually reads.
24664                // `goal_unevaluated` is the machine-readable marker
24665                // `goal_turn_terminal_event` sets for exactly this case.
24666                let unevaluated = params
24667                    .get("goal_unevaluated")
24668                    .and_then(Value::as_bool)
24669                    .unwrap_or(false);
24670                // The same status an `error` terminal yields: the condition
24671                // was not met and the run is over. Mirrored deliberately
24672                // rather than given a status of its own — `docs/websocket-
24673                // protocol.md` enumerates what a poller may read, and
24674                // `terminal_kind` is where the distinction lives.
24675                goal.status = if kind == "error" || kind == "auth_required" {
24676                    "error".to_string()
24677                } else if unevaluated {
24678                    "unevaluated".to_string()
24679                } else {
24680                    "met".to_string()
24681                };
24682                goal.updated_at = unix_secs_now();
24683                true
24684            }
24685            _ => false,
24686        }
24687    };
24688    if changed {
24689        if let Err(e) = state.persist_chat_goals().await {
24690            tracing::warn!(
24691                session_id,
24692                error = %e,
24693                "failed to persist chat goal status update"
24694            );
24695        }
24696    }
24697}
24698
24699/// Aggregate an `agent.chat` stream into one A2A answer.
24700///
24701/// Split out of the A2A bridge so the terminal contract is testable without a
24702/// host, a socket and a 180-second clock. **Every terminal kind must have an
24703/// arm here**: a kind this loop does not recognize is silently skipped, and
24704/// because nothing follows a terminal frame on the wire the loop then blocks
24705/// until the caller's timeout fires and reports a timeout instead of what
24706/// actually happened.
24707async fn collect_chat_stream(
24708    chunk_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::session::ChatStreamChunk>,
24709) -> Result<String, String> {
24710    let mut text = String::new();
24711    while let Some(chunk) = chunk_rx.recv().await {
24712        match chunk.kind.as_str() {
24713            "token" => {
24714                if let Some(d) = chunk.delta {
24715                    text.push_str(&d);
24716                }
24717            }
24718            "done" => return Ok(text),
24719            "error" => {
24720                return Err(chunk
24721                    .error
24722                    .unwrap_or_else(|| "host agent error".to_string()));
24723            }
24724            // The turn was refused because of the Parslee account. The caller
24725            // gets the remedy immediately; waiting out the timeout would turn
24726            // an answerable question into an unanswerable one.
24727            "auth_required" => {
24728                return Err(chunk
24729                    .message
24730                    .unwrap_or_else(|| "agent requires a Parslee sign-in".to_string()));
24731            }
24732            _ => {}
24733        }
24734    }
24735    Err("agent.chat stream ended before completion".to_string())
24736}
24737
24738/// Forward an `agent.chat.event` notification from an agent's
24739/// connection to the originating host's connection, rewritten as an
24740/// `agents.chat.event` notification. Returns `true` if the inbound
24741/// frame was a chat-event we routed (so the dispatcher can `continue`
24742/// past the normal method dispatch and skip the wasted "unknown
24743/// method" response), `false` otherwise.
24744///
24745/// Terminal events (`kind: "done"` / `"error"` / `"auth_required"`) also drop
24746/// the routing entry from `state.chat_sessions` so a later stray notification
24747/// can be rejected as orphaned without leaking memory.
24748pub(crate) async fn try_forward_agent_chat_event(
24749    parsed: &JsonRpcMessage,
24750    state: &Arc<ServerState>,
24751) -> bool {
24752    use futures::SinkExt;
24753    use tokio_tungstenite::tungstenite::Message;
24754
24755    // Notification predicate: method is `agent.chat.event`, id is
24756    // missing/null (per JSON-RPC, notifications have no id), and
24757    // params carry a session_id.
24758    let Some(method) = parsed.method.as_deref() else {
24759        return false;
24760    };
24761    if method != "agent.chat.event" {
24762        return false;
24763    }
24764    if !parsed.id.is_null() {
24765        // Has an id → it's a request, not a notification. Let the
24766        // normal dispatcher handle it (and reply with method-not-found).
24767        return false;
24768    }
24769    let Some(session_id) = parsed.params.get("session_id").and_then(Value::as_str) else {
24770        return false;
24771    };
24772    let session_id = session_id.to_string();
24773
24774    // In-process collector path (the A2A conversational bridge has no host UI
24775    // to stream to). If a collector is registered for this session, feed it the
24776    // normalized chunk and consume the event — the collecting task owns the
24777    // collector's lifetime, so we don't touch chat_sessions or remove it here.
24778    let collector = state
24779        .chat_collectors
24780        .lock()
24781        .await
24782        .get(&session_id)
24783        .map(|c| c.tx.clone());
24784    if let Some(tx) = collector {
24785        let kind = parsed
24786            .params
24787            .get("kind")
24788            .and_then(Value::as_str)
24789            .map(str::to_string)
24790            .unwrap_or_else(|| {
24791                if parsed.params.get("error").is_some() {
24792                    "error".to_string()
24793                } else if parsed.params.get("finish_reason").is_some() {
24794                    "done".to_string()
24795                } else {
24796                    "token".to_string()
24797                }
24798            });
24799        let delta = parsed
24800            .params
24801            .get("delta")
24802            .and_then(Value::as_str)
24803            .map(str::to_string);
24804        let error = parsed
24805            .params
24806            .get("error")
24807            .and_then(Value::as_str)
24808            .map(str::to_string);
24809        let message = parsed
24810            .params
24811            .get("message")
24812            .and_then(Value::as_str)
24813            .map(str::to_string);
24814        let _ = tx.send(crate::session::ChatStreamChunk {
24815            kind,
24816            delta,
24817            error,
24818            message,
24819        });
24820        return true;
24821    }
24822
24823    // Look up the routing entry. If gone (cancelled, agent dropped,
24824    // disconnect cleanup), drop the event silently — late frames from
24825    // a respawned agent for a stale session are not the host's
24826    // problem.
24827    let chat = state.chat_sessions.lock().await.get(&session_id).cloned();
24828    let Some(chat) = chat else {
24829        return true; // recognized the method, but routing has dropped — consumed.
24830    };
24831
24832    // Pull the kind early so terminal-event cleanup runs even if the
24833    // host's send fails. Agents may omit `kind` and signal terminal
24834    // state via `finish_reason` / `error` instead (car#222) — derive
24835    // it from the frame shape so both the cleanup below AND the host
24836    // see a correct, host-protocol-compliant kind. The old code
24837    // defaulted a `finish_reason`-only "done" frame to "token", so
24838    // terminal cleanup never ran and the host (which requires `kind`)
24839    // dropped every frame silently.
24840    let kind = parsed
24841        .params
24842        .get("kind")
24843        .and_then(Value::as_str)
24844        .map(str::to_string)
24845        .unwrap_or_else(|| {
24846            if parsed.params.get("error").is_some() {
24847                "error".to_string()
24848            } else if parsed.params.get("finish_reason").is_some() {
24849                "done".to_string()
24850            } else {
24851                "token".to_string()
24852            }
24853        });
24854    // The agent's own params, with only the normalized `kind` written over the
24855    // top — NOT a hand-listed projection of them.
24856    //
24857    // The projection this replaces was a copy of the field names
24858    // `update_chat_goal_from_event` happened to read, which is a list that
24859    // rots: `goal_unevaluated` (the marker that keeps a fail-open `done` from
24860    // being recorded as "met", car#1113) was never on it, so it never survived
24861    // this path, and every field it did list was forced present as `null`,
24862    // which is what broke `terminal_message`. Forwarding the frame verbatim
24863    // cannot drop a field the reader learns to want, and `send_external_chat_
24864    // frame` — the other caller — already does exactly this.
24865    let mut goal_params = parsed.params.clone();
24866    if let Some(obj) = goal_params.as_object_mut() {
24867        obj.insert("kind".to_string(), Value::String(kind.clone()));
24868    }
24869    update_chat_goal_from_event(state, &session_id, &goal_params).await;
24870
24871    // Forward to the host. Rewrites the method name to the host-facing
24872    // form and attaches `agent_id` so the host doesn't have to remember
24873    // which agent owns each session.
24874    let host_channel = {
24875        let sessions = state.sessions.lock().await;
24876        sessions
24877            .get(&chat.host_client_id)
24878            .map(|s| s.channel.clone())
24879    };
24880    if let Some(channel) = host_channel {
24881        let mut params = parsed.params.clone();
24882        if let Some(obj) = params.as_object_mut() {
24883            obj.insert("agent_id".to_string(), Value::String(chat.agent_id.clone()));
24884            // host-protocol.md requires a top-level `kind` on every
24885            // agents.chat.event. Agents that omit it (signalling via
24886            // finish_reason/error) were dropped wholesale by the host
24887            // decoder — normalize here so the contract holds. car#222.
24888            obj.entry("kind")
24889                .or_insert_with(|| Value::String(kind.clone()));
24890        }
24891        let forward = serde_json::json!({
24892            "jsonrpc": "2.0",
24893            "method": "agents.chat.event",
24894            "params": params,
24895        });
24896        if let Ok(text) = serde_json::to_string(&forward) {
24897            let send_result = channel
24898                .write
24899                .lock()
24900                .await
24901                .send(Message::Text(text.into()))
24902                .await;
24903            if let Err(e) = send_result {
24904                tracing::warn!(
24905                    session_id = %session_id,
24906                    agent_id = %chat.agent_id,
24907                    host_client_id = %chat.host_client_id,
24908                    kind = %kind,
24909                    error = %e,
24910                    "agent.chat.event forward to host failed at the WS send step"
24911                );
24912            }
24913        }
24914    } else {
24915        // Host disconnected mid-stream — chat_sessions still holds
24916        // the routing entry but the originating client_id no longer
24917        // resolves to a session. Pre-#233 this was silent and the
24918        // operator had no way to tell whether the event was dropped
24919        // here or never emitted by the agent. Log + drop the
24920        // routing entry so subsequent stray events are no-ops.
24921        tracing::warn!(
24922            session_id = %session_id,
24923            agent_id = %chat.agent_id,
24924            host_client_id = %chat.host_client_id,
24925            kind = %kind,
24926            "agent.chat.event from supervised agent had no host channel \
24927             (host disconnected since `agents.chat`); dropping routing entry"
24928        );
24929        state.chat_sessions.lock().await.remove(&session_id);
24930        return true;
24931    }
24932
24933    // Terminal-kind cleanup. The host_channel branch above already
24934    // forwarded the terminal event; we just remove the routing entry
24935    // here so subsequent stray frames are no-ops.
24936    if matches!(kind.as_str(), "done" | "error" | "auth_required") {
24937        state.chat_sessions.lock().await.remove(&session_id);
24938    }
24939
24940    true
24941}
24942
24943#[cfg(test)]
24944mod credential_event_lag_regression {
24945    use super::{credential_fanout_test_gate, credential_handoff_test_gate, run_dispatch};
24946    use futures::{Sink, SinkExt, StreamExt};
24947    use std::pin::Pin;
24948    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
24949    use std::sync::Arc;
24950    use std::task::{Context, Poll};
24951    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
24952
24953    const HOST_TOKEN: &str = "lag-test-host-token-cccccccccccccccccccc";
24954
24955    struct StallAfterTwoWrites {
24956        outbound: futures::channel::mpsc::UnboundedSender<Message>,
24957        sends: Arc<AtomicUsize>,
24958        stalled: Arc<AtomicBool>,
24959    }
24960
24961    impl Sink<Message> for StallAfterTwoWrites {
24962        type Error = WsError;
24963
24964        fn poll_ready(
24965            self: Pin<&mut Self>,
24966            _context: &mut Context<'_>,
24967        ) -> Poll<Result<(), Self::Error>> {
24968            if self.sends.load(Ordering::SeqCst) >= 2 {
24969                self.stalled.store(true, Ordering::SeqCst);
24970                Poll::Pending
24971            } else {
24972                Poll::Ready(Ok(()))
24973            }
24974        }
24975
24976        fn start_send(self: Pin<&mut Self>, message: Message) -> Result<(), Self::Error> {
24977            self.outbound
24978                .unbounded_send(message)
24979                .map_err(|_| WsError::ConnectionClosed)?;
24980            self.sends.fetch_add(1, Ordering::SeqCst);
24981            Ok(())
24982        }
24983
24984        fn poll_flush(
24985            self: Pin<&mut Self>,
24986            _context: &mut Context<'_>,
24987        ) -> Poll<Result<(), Self::Error>> {
24988            Poll::Ready(Ok(()))
24989        }
24990
24991        fn poll_close(
24992            self: Pin<&mut Self>,
24993            _context: &mut Context<'_>,
24994        ) -> Poll<Result<(), Self::Error>> {
24995            Poll::Ready(Ok(()))
24996        }
24997    }
24998
24999    fn request(id: &str, method: &str, params: serde_json::Value) -> Message {
25000        Message::Text(
25001            serde_json::json!({
25002                "jsonrpc": "2.0",
25003                "id": id,
25004                "method": method,
25005                "params": params,
25006            })
25007            .to_string()
25008            .into(),
25009        )
25010    }
25011
25012    #[tokio::test]
25013    async fn credential_event_queue_overflow_terminates_host_dispatch() {
25014        const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_LAG_CHILD";
25015        if std::env::var_os(SENTINEL).is_none() {
25016            let status = std::process::Command::new(std::env::current_exe().unwrap())
25017                .arg("--exact")
25018                .arg(
25019                    "handler::credential_event_lag_regression::credential_event_queue_overflow_terminates_host_dispatch",
25020                )
25021                .arg("--nocapture")
25022                .env(SENTINEL, "1")
25023                .env("CAR_NO_INFERENCE_WORKER", "1")
25024                .status()
25025                .expect("spawn isolated credential-event lag contract");
25026            assert!(
25027                status.success(),
25028                "isolated credential-event lag contract failed"
25029            );
25030            return;
25031        }
25032
25033        let temp = tempfile::TempDir::new().unwrap();
25034        let secrets = tempfile::TempDir::new().unwrap();
25035        std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
25036        std::env::set_var(car_home::ENV_VAR, temp.path());
25037        car_auth::commit_login(
25038            "https://api.example.test",
25039            &car_auth::TokenSet {
25040                access_token: "lag-test-access".into(),
25041                refresh_token: "lag-test-refresh".into(),
25042                expires_in: 3_600,
25043                token_type: "Bearer".into(),
25044            },
25045            r#"{"Account":{"Id":"lag-account","Email":"lag@example.test"}}"#,
25046            None,
25047        )
25048        .await
25049        .unwrap();
25050
25051        let state = Arc::new(crate::session::ServerState::standalone(
25052            temp.path().join("journal"),
25053        ));
25054        state
25055            .install_host_token(HOST_TOKEN.to_string())
25056            .expect("install host token");
25057
25058        let (inbound_tx, inbound_rx) =
25059            futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
25060        let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
25061        let sends = Arc::new(AtomicUsize::new(0));
25062        let credential_write_stalled = Arc::new(AtomicBool::new(false));
25063        let sink = StallAfterTwoWrites {
25064            outbound: outbound_tx,
25065            sends: Arc::clone(&sends),
25066            stalled: Arc::clone(&credential_write_stalled),
25067        };
25068        let write: crate::session::WsSink = Box::pin(sink);
25069        let dispatch_state = Arc::clone(&state);
25070        let mut dispatch = tokio::spawn(async move {
25071            run_dispatch(
25072                inbound_rx,
25073                write,
25074                "credential-lag-test".to_string(),
25075                dispatch_state,
25076            )
25077            .await
25078            .map_err(|error| error.to_string())
25079        });
25080
25081        inbound_tx
25082            .unbounded_send(Ok(request(
25083                "auth",
25084                "session.auth",
25085                serde_json::json!({ "host_token": HOST_TOKEN }),
25086            )))
25087            .unwrap();
25088        let auth_response = outbound_rx.next().await.expect("host auth response");
25089        assert!(auth_response
25090            .into_text()
25091            .unwrap()
25092            .contains("\"role\":\"host\""));
25093
25094        inbound_tx
25095            .unbounded_send(Ok(request(
25096                "handshake",
25097                "server.handshake",
25098                serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
25099            )))
25100            .unwrap();
25101        let handshake_response = outbound_rx.next().await.expect("handshake response");
25102        assert!(handshake_response.into_text().unwrap().contains(&format!(
25103            "\"protocol_version\":{}",
25104            car_proto::PROTOCOL_VERSION
25105        )));
25106
25107        car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25108            .await
25109            .expect("credential generation should resolve");
25110        for _ in 0..100 {
25111            if credential_write_stalled.load(Ordering::SeqCst) {
25112                break;
25113            }
25114            tokio::task::yield_now().await;
25115        }
25116        assert!(
25117            credential_write_stalled.load(Ordering::SeqCst),
25118            "the real host fanout must be stalled on its credential-event write"
25119        );
25120        for _ in 0..3 {
25121            car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25122                .await
25123                .expect("credential generation should resolve while host fanout is stalled");
25124        }
25125
25126        let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
25127            .await
25128            .expect("credential-event overflow must cancel the owning host connection")
25129            .expect("dispatcher task should join");
25130        assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
25131        assert!(
25132            state.sessions.lock().await.is_empty(),
25133            "a lagged host cannot remain connected but silently unsubscribed"
25134        );
25135    }
25136
25137    #[tokio::test]
25138    async fn preauth_activity_cannot_disconnect_a_later_eligible_host() {
25139        const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_PREAUTH_CHILD";
25140        if std::env::var_os(SENTINEL).is_none() {
25141            let status = std::process::Command::new(std::env::current_exe().unwrap())
25142                .arg("--exact")
25143                .arg(
25144                    "handler::credential_event_lag_regression::preauth_activity_cannot_disconnect_a_later_eligible_host",
25145                )
25146                .arg("--nocapture")
25147                .env(SENTINEL, "1")
25148                .env("CAR_NO_INFERENCE_WORKER", "1")
25149                .env("CAR_TEST_PAUSE_CREDENTIAL_FANOUT", "1")
25150                .status()
25151                .expect("spawn isolated pre-auth credential-event contract");
25152            assert!(status.success(), "isolated pre-auth contract failed");
25153            return;
25154        }
25155
25156        let temp = tempfile::TempDir::new().unwrap();
25157        let secrets = tempfile::TempDir::new().unwrap();
25158        std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
25159        std::env::set_var(car_home::ENV_VAR, temp.path());
25160        car_auth::commit_login(
25161            "https://api.example.test",
25162            &car_auth::TokenSet {
25163                access_token: "preauth-test-access".into(),
25164                refresh_token: "preauth-test-refresh".into(),
25165                expires_in: 3_600,
25166                token_type: "Bearer".into(),
25167            },
25168            r#"{"Account":{"Id":"preauth-account","Email":"preauth@example.test"}}"#,
25169            None,
25170        )
25171        .await
25172        .unwrap();
25173
25174        let state = Arc::new(crate::session::ServerState::standalone(
25175            temp.path().join("journal"),
25176        ));
25177        state
25178            .install_host_token(HOST_TOKEN.to_string())
25179            .expect("install host token");
25180
25181        let (inbound_tx, inbound_rx) =
25182            futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
25183        let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
25184        let write: crate::session::WsSink = Box::pin(
25185            outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
25186        );
25187        let dispatch_state = Arc::clone(&state);
25188        let mut dispatch = tokio::spawn(async move {
25189            run_dispatch(
25190                inbound_rx,
25191                write,
25192                "credential-preauth-test".to_string(),
25193                dispatch_state,
25194            )
25195            .await
25196            .map_err(|error| error.to_string())
25197        });
25198
25199        credential_fanout_test_gate().ready.notified().await;
25200        for _ in 0..3 {
25201            car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25202                .await
25203                .expect("pre-auth credential generation should resolve");
25204        }
25205
25206        inbound_tx
25207            .unbounded_send(Ok(request(
25208                "auth",
25209                "session.auth",
25210                serde_json::json!({ "host_token": HOST_TOKEN }),
25211            )))
25212            .unwrap();
25213        let auth_response = outbound_rx.next().await.expect("host auth response");
25214        assert!(auth_response
25215            .into_text()
25216            .unwrap()
25217            .contains("\"role\":\"host\""));
25218
25219        inbound_tx
25220            .unbounded_send(Ok(request(
25221                "handshake",
25222                "server.handshake",
25223                serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
25224            )))
25225            .unwrap();
25226        let mut negotiated = false;
25227        for _ in 0..100 {
25228            negotiated = state.sessions.lock().await.values().any(|session| {
25229                session.negotiated_protocol_version.load(Ordering::Acquire)
25230                    == car_proto::PROTOCOL_VERSION
25231            });
25232            if negotiated {
25233                break;
25234            }
25235            tokio::task::yield_now().await;
25236        }
25237        assert!(
25238            negotiated,
25239            "host protocol negotiation must complete while fanout is paused"
25240        );
25241
25242        credential_fanout_test_gate().release.notify_one();
25243        let mut saw_handshake = false;
25244        let mut saw_reconciled = false;
25245        for _ in 0..2 {
25246            let frame =
25247                tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
25248                    .await
25249                    .expect("eligible host should receive handshake and reconciled snapshot")
25250                    .expect("eligible host connection must remain open")
25251                    .into_text()
25252                    .unwrap();
25253            saw_handshake |= frame.contains(&format!(
25254                "\"protocol_version\":{}",
25255                car_proto::PROTOCOL_VERSION
25256            ));
25257            saw_reconciled |= frame.contains("\"method\":\"auth.credential.event\"")
25258                && frame.contains("\"state\":\"configured\"");
25259        }
25260        assert!(saw_handshake, "host must receive handshake success");
25261        assert!(
25262            saw_reconciled,
25263            "host must receive the retained credential snapshot"
25264        );
25265
25266        inbound_tx
25267            .unbounded_send(Ok(request(
25268                "still-connected",
25269                "auth.authority_hint",
25270                serde_json::json!({}),
25271            )))
25272            .unwrap();
25273        let response =
25274            tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
25275                .await
25276                .expect("host should remain responsive after pre-auth activity")
25277                .expect("host connection should remain open")
25278                .into_text()
25279                .unwrap();
25280        assert!(response.contains("\"id\":\"still-connected\""));
25281        assert!(!dispatch.is_finished());
25282
25283        inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
25284        let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
25285            .await
25286            .expect("dispatcher should stop after close")
25287            .expect("dispatcher task should join");
25288        assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
25289    }
25290
25291    #[tokio::test]
25292    async fn handoff_cannot_send_terminal_before_queued_pending() {
25293        const SENTINEL: &str = "CAR_CREDENTIAL_EVENT_HANDOFF_CHILD";
25294        if std::env::var_os(SENTINEL).is_none() {
25295            let status = std::process::Command::new(std::env::current_exe().unwrap())
25296                .arg("--exact")
25297                .arg(
25298                    "handler::credential_event_lag_regression::handoff_cannot_send_terminal_before_queued_pending",
25299                )
25300                .arg("--nocapture")
25301                .env(SENTINEL, "1")
25302                .env("CAR_NO_INFERENCE_WORKER", "1")
25303                .env("CAR_TEST_PAUSE_CREDENTIAL_HANDOFF", "1")
25304                .status()
25305                .expect("spawn isolated credential handoff contract");
25306            assert!(status.success(), "isolated credential handoff failed");
25307            return;
25308        }
25309
25310        let temp = tempfile::TempDir::new().unwrap();
25311        let secrets = tempfile::TempDir::new().unwrap();
25312        std::env::set_var("CAR_SECRETS_FILE_DIR", secrets.path());
25313        std::env::set_var(car_home::ENV_VAR, temp.path());
25314        car_auth::commit_login(
25315            "https://api.example.test",
25316            &car_auth::TokenSet {
25317                access_token: "handoff-test-access".into(),
25318                refresh_token: "handoff-test-refresh".into(),
25319                expires_in: 3_600,
25320                token_type: "Bearer".into(),
25321            },
25322            r#"{"Account":{"Id":"handoff-account","Email":"handoff@example.test"}}"#,
25323            None,
25324        )
25325        .await
25326        .unwrap();
25327
25328        let state = Arc::new(crate::session::ServerState::standalone(
25329            temp.path().join("journal"),
25330        ));
25331        state
25332            .install_host_token(HOST_TOKEN.to_string())
25333            .expect("install host token");
25334
25335        let (inbound_tx, inbound_rx) =
25336            futures::channel::mpsc::unbounded::<Result<Message, WsError>>();
25337        let (outbound_tx, mut outbound_rx) = futures::channel::mpsc::unbounded::<Message>();
25338        let write: crate::session::WsSink = Box::pin(
25339            outbound_tx.sink_map_err(|_| tokio_tungstenite::tungstenite::Error::ConnectionClosed),
25340        );
25341        let dispatch_state = Arc::clone(&state);
25342        let mut dispatch = tokio::spawn(async move {
25343            run_dispatch(
25344                inbound_rx,
25345                write,
25346                "credential-handoff-test".to_string(),
25347                dispatch_state,
25348            )
25349            .await
25350            .map_err(|error| error.to_string())
25351        });
25352
25353        inbound_tx
25354            .unbounded_send(Ok(request(
25355                "auth",
25356                "session.auth",
25357                serde_json::json!({ "host_token": HOST_TOKEN }),
25358            )))
25359            .unwrap();
25360        let auth_response = outbound_rx.next().await.expect("host auth response");
25361        assert!(auth_response
25362            .into_text()
25363            .unwrap()
25364            .contains("\"role\":\"host\""));
25365
25366        inbound_tx
25367            .unbounded_send(Ok(request(
25368                "handshake",
25369                "server.handshake",
25370                serde_json::json!({ "protocol_version": car_proto::PROTOCOL_VERSION }),
25371            )))
25372            .unwrap();
25373        credential_handoff_test_gate().ready.notified().await;
25374
25375        car_auth::resolve_credential(car_auth::CredentialReadMode::Retry)
25376            .await
25377            .expect("interleaved credential generation should resolve");
25378        credential_handoff_test_gate().release.notify_one();
25379
25380        let mut saw_handshake = false;
25381        let mut states = Vec::new();
25382        while !saw_handshake || states.len() < 2 {
25383            let frame =
25384                tokio::time::timeout(std::time::Duration::from_millis(500), outbound_rx.next())
25385                    .await
25386                    .expect("handoff must deliver handshake and exact credential lifecycle")
25387                    .expect("eligible host connection must remain open")
25388                    .into_text()
25389                    .unwrap();
25390            let value: serde_json::Value =
25391                serde_json::from_str(&frame).expect("credential handoff frame JSON");
25392            saw_handshake |= value["id"] == "handshake";
25393            if value["method"] == "auth.credential.event" {
25394                states.push(value["params"]["state"].as_str().unwrap().to_string());
25395            }
25396        }
25397        assert_eq!(states, ["pending", "configured"]);
25398
25399        inbound_tx.unbounded_send(Ok(Message::Close(None))).unwrap();
25400        let result = tokio::time::timeout(std::time::Duration::from_millis(500), &mut dispatch)
25401            .await
25402            .expect("dispatcher should stop after close")
25403            .expect("dispatcher task should join");
25404        assert!(result.is_ok(), "dispatcher cleanup failed: {result:?}");
25405    }
25406}
25407
25408#[cfg(test)]
25409mod fd_leak_regression {
25410    //! car#209 regression: an abrupt transport error must still run
25411    //! the connection cleanup. Before the fix, `let msg = msg?;`
25412    //! propagated the read error out of `run_dispatch`, skipping
25413    //! `remove_session`, so `state.sessions` (holding the
25414    //! `Arc<ClientSession>` -> `Arc<WsChannel>` -> socket FD) leaked
25415    //! forever on every peer reset / crash-loop disconnect.
25416    use super::run_dispatch;
25417    use futures::SinkExt;
25418    use std::sync::Arc;
25419    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25420
25421    #[tokio::test]
25422    async fn abrupt_read_error_still_runs_session_cleanup() {
25423        let tmp = tempfile::TempDir::new().unwrap();
25424        let state = Arc::new(crate::session::ServerState::standalone(
25425            tmp.path().to_path_buf(),
25426        ));
25427
25428        // Read stream that immediately yields a transport error (peer
25429        // reset), then ends -- the exact shape of an ungraceful
25430        // client disconnect.
25431        let read = futures::stream::iter(vec![Err::<Message, WsError>(WsError::ConnectionClosed)]);
25432        let write: crate::session::WsSink =
25433            Box::pin(futures::sink::drain().sink_map_err(|_| WsError::ConnectionClosed));
25434
25435        let result = run_dispatch(read, write, "test-peer".to_string(), state.clone()).await;
25436        assert!(
25437            result.is_ok(),
25438            "run_dispatch must return Ok after cleanup, got {result:?}"
25439        );
25440
25441        // The session (and its channel/FD) must be gone -- cleanup
25442        // ran despite the abrupt error.
25443        assert!(
25444            state.sessions.lock().await.is_empty(),
25445            "state.sessions must be empty after an abrupt disconnect (car#209)"
25446        );
25447    }
25448}
25449
25450#[cfg(test)]
25451mod a2ui_action_delivery {
25452    //! Parslee-ai/car-releases#58: a ClientAction must reach A2UI
25453    //! subscribers on the `a2ui.event` channel — the same one that
25454    //! already carries surface updates — so an agent that created a
25455    //! surface receives the button click it would otherwise never see.
25456    use super::{handle_a2ui_action, JsonRpcMessage};
25457    use crate::session::{ServerState, WsChannel, WsSink};
25458    use futures::{SinkExt, StreamExt};
25459    use std::collections::HashMap;
25460    use std::sync::atomic::AtomicU64;
25461    use std::sync::Arc;
25462    use tokio::sync::Mutex;
25463    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25464
25465    #[tokio::test]
25466    async fn client_action_broadcasts_to_a2ui_subscribers() {
25467        let tmp = tempfile::TempDir::new().unwrap();
25468        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25469
25470        // Capturing subscriber channel: a futures mpsc whose sink half is
25471        // erased into the WsSink the server writes to, receiver kept here.
25472        let (tx, mut rx) = futures::channel::mpsc::unbounded::<Message>();
25473        let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
25474        let channel = Arc::new(WsChannel {
25475            write: Mutex::new(sink),
25476            pending: Mutex::new(HashMap::new()),
25477            active_actions: Mutex::new(HashMap::new()),
25478            next_id: AtomicU64::new(0),
25479        });
25480        state
25481            .a2ui_subscribers
25482            .lock()
25483            .await
25484            .insert("test-sub".to_string(), channel);
25485
25486        // Send the action under the `action` key (not `name`) to also
25487        // exercise the serde alias — the web renderer forwards
25488        // `@a2ui/web_core`'s object verbatim.
25489        let req: JsonRpcMessage = serde_json::from_value(serde_json::json!({
25490            "jsonrpc": "2.0",
25491            "method": "a2ui.action",
25492            "id": 1,
25493            "params": {
25494                "action": "trader:pause",
25495                "surfaceId": "surf-1",
25496                "sourceComponentId": "b1",
25497                "timestamp": "2026-06-03T00:00:00Z"
25498            }
25499        }))
25500        .unwrap();
25501
25502        let out = handle_a2ui_action(&req, &state).await;
25503        assert!(out.is_ok(), "handle_a2ui_action failed: {out:?}");
25504
25505        let msg = rx.next().await.expect("subscriber received no frame");
25506        let text = match msg {
25507            Message::Text(t) => t.to_string(),
25508            other => panic!("expected text frame, got {other:?}"),
25509        };
25510        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25511        assert_eq!(v["method"], "a2ui.event");
25512        assert_eq!(v["params"]["kind"], "a2ui.action");
25513        // Alias resolved `action` -> `name`.
25514        assert_eq!(
25515            v["params"]["result"]["action"]["name"], "trader:pause",
25516            "ClientAction.name should accept the `action` alias"
25517        );
25518        assert_eq!(v["params"]["result"]["surfaceId"], "surf-1");
25519    }
25520}
25521
25522#[cfg(test)]
25523mod a2a_chat_collector {
25524    //! car-releases#65 part 2: when an A2A conversational turn registers an
25525    //! in-process collector, `try_forward_agent_chat_event` feeds it the
25526    //! normalized stream chunks instead of forwarding to a host UI channel.
25527    use super::{try_forward_agent_chat_event, JsonRpcMessage};
25528    use crate::session::{ChatCollector, ServerState};
25529    use std::sync::Arc;
25530    use tokio::sync::mpsc;
25531
25532    fn event(session_id: &str, extra: serde_json::Value) -> JsonRpcMessage {
25533        let mut params = serde_json::Map::new();
25534        params.insert("session_id".into(), session_id.into());
25535        if let Some(obj) = extra.as_object() {
25536            for (k, v) in obj {
25537                params.insert(k.clone(), v.clone());
25538            }
25539        }
25540        serde_json::from_value(serde_json::json!({
25541            "jsonrpc": "2.0",
25542            "method": "agent.chat.event",
25543            "params": params,
25544        }))
25545        .unwrap()
25546    }
25547
25548    #[tokio::test]
25549    async fn collector_receives_normalized_chunks() {
25550        let tmp = tempfile::TempDir::new().unwrap();
25551        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25552        let (tx, mut rx) = mpsc::unbounded_channel();
25553        state.chat_collectors.lock().await.insert(
25554            "a2a-chat-xyz".into(),
25555            ChatCollector {
25556                tx,
25557                host_client_id: "host-1".into(),
25558            },
25559        );
25560
25561        // Explicit token delta.
25562        assert!(
25563            try_forward_agent_chat_event(
25564                &event(
25565                    "a2a-chat-xyz",
25566                    serde_json::json!({ "kind": "token", "delta": "Hello " })
25567                ),
25568                &state,
25569            )
25570            .await
25571        );
25572        // kind omitted, has delta → derived "token".
25573        try_forward_agent_chat_event(
25574            &event("a2a-chat-xyz", serde_json::json!({ "delta": "world" })),
25575            &state,
25576        )
25577        .await;
25578        // kind omitted, finish_reason present → derived terminal "done".
25579        try_forward_agent_chat_event(
25580            &event(
25581                "a2a-chat-xyz",
25582                serde_json::json!({ "finish_reason": "1 turn" }),
25583            ),
25584            &state,
25585        )
25586        .await;
25587
25588        let c1 = rx.recv().await.unwrap();
25589        assert_eq!(c1.kind, "token");
25590        assert_eq!(c1.delta.as_deref(), Some("Hello "));
25591        let c2 = rx.recv().await.unwrap();
25592        assert_eq!(c2.delta.as_deref(), Some("world"));
25593        let c3 = rx.recv().await.unwrap();
25594        assert_eq!(c3.kind, "done");
25595
25596        // The collector is left in place for the collecting task to drain/remove.
25597        assert!(state
25598            .chat_collectors
25599            .lock()
25600            .await
25601            .contains_key("a2a-chat-xyz"));
25602    }
25603
25604    /// The terminal contract, from the bridge's side: an `auth_required` frame
25605    /// ENDS the stream, so the loop must answer immediately with the remedy.
25606    ///
25607    /// Nothing follows a terminal frame on the wire. Before this, the kind fell
25608    /// through the match, the loop kept awaiting a chunk that was never coming,
25609    /// and the caller got a 180-second timeout instead of the sentence it had
25610    /// already been handed. `drop(tx)` here is not what makes the test pass —
25611    /// the assertion is the message, which only the new arm can produce.
25612    #[tokio::test]
25613    async fn an_auth_required_chunk_ends_the_collection_with_its_message() {
25614        let (tx, mut rx) = mpsc::unbounded_channel();
25615        tx.send(crate::session::ChatStreamChunk {
25616            kind: "token".into(),
25617            delta: Some("thinking…".into()),
25618            error: None,
25619            message: None,
25620        })
25621        .unwrap();
25622        tx.send(crate::session::ChatStreamChunk {
25623            kind: "auth_required".into(),
25624            delta: None,
25625            error: None,
25626            message: Some("Parslee Core runs on your Parslee account.".into()),
25627        })
25628        .unwrap();
25629        // The sender stays alive: if the loop did not terminate on the frame it
25630        // would block here rather than fall out of the `while let`.
25631        let result = tokio::time::timeout(
25632            std::time::Duration::from_secs(2),
25633            super::collect_chat_stream(&mut rx),
25634        )
25635        .await
25636        .expect("the collector must not wait for its caller's timeout");
25637        assert_eq!(
25638            result,
25639            Err("Parslee Core runs on your Parslee account.".to_string())
25640        );
25641    }
25642
25643    /// A terminal frame drops the per-turn routing entry so later stray frames
25644    /// are no-ops. `auth_required` is terminal, so it must too — otherwise the
25645    /// entry leaks for the life of the daemon and a respawned agent's frames
25646    /// for a dead session keep routing to the host.
25647    #[tokio::test]
25648    async fn an_auth_required_frame_drops_the_routing_entry() {
25649        let tmp = tempfile::TempDir::new().unwrap();
25650        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25651        // A live host session, so the forward path runs and the test reaches
25652        // the terminal-cleanup line rather than the host-disconnected one
25653        // (which drops the entry for ANY kind and would prove nothing).
25654        state
25655            .create_session("host-1", Arc::new(crate::session::WsChannel::test_stub()))
25656            .await
25657            .unwrap();
25658        state.chat_sessions.lock().await.insert(
25659            "auth-route".into(),
25660            crate::session::ChatSession {
25661                agent_id: "parslee-core".into(),
25662                host_client_id: "host-1".into(),
25663                created_at: 0,
25664                local_cancel: None,
25665            },
25666        );
25667
25668        try_forward_agent_chat_event(
25669            &event(
25670                "auth-route",
25671                serde_json::json!({ "kind": "token", "delta": "hi" }),
25672            ),
25673            &state,
25674        )
25675        .await;
25676        assert!(
25677            state.chat_sessions.lock().await.contains_key("auth-route"),
25678            "a non-terminal frame must leave the route in place"
25679        );
25680
25681        try_forward_agent_chat_event(
25682            &event(
25683                "auth-route",
25684                serde_json::json!({
25685                    "kind": "auth_required",
25686                    "reason": "signed_out",
25687                    "message": "Parslee Core runs on your Parslee account."
25688                }),
25689            ),
25690            &state,
25691        )
25692        .await;
25693        assert!(
25694            !state.chat_sessions.lock().await.contains_key("auth-route"),
25695            "a terminal auth_required must drop the routing entry"
25696        );
25697    }
25698
25699    #[tokio::test]
25700    async fn error_event_carries_error_text() {
25701        let tmp = tempfile::TempDir::new().unwrap();
25702        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25703        let (tx, mut rx) = mpsc::unbounded_channel();
25704        state.chat_collectors.lock().await.insert(
25705            "a2a-chat-err".into(),
25706            ChatCollector {
25707                tx,
25708                host_client_id: "h".into(),
25709            },
25710        );
25711        try_forward_agent_chat_event(
25712            &event(
25713                "a2a-chat-err",
25714                serde_json::json!({ "error": "model exploded" }),
25715            ),
25716            &state,
25717        )
25718        .await;
25719        let c = rx.recv().await.unwrap();
25720        assert_eq!(c.kind, "error"); // derived from `error` presence
25721        assert_eq!(c.error.as_deref(), Some("model exploded"));
25722    }
25723}
25724
25725#[cfg(test)]
25726mod schedule_suggest_rpc {
25727    use super::{handle_schedule_suggest, JsonRpcMessage};
25728
25729    fn request(params: serde_json::Value) -> JsonRpcMessage {
25730        JsonRpcMessage {
25731            jsonrpc: "2.0".into(),
25732            method: Some("schedule.suggest".into()),
25733            params,
25734            id: serde_json::json!(1),
25735            result: None,
25736            error: None,
25737        }
25738    }
25739
25740    #[test]
25741    fn rpc_returns_structured_cadence_and_preserves_timezone() {
25742        let result = handle_schedule_suggest(&request(serde_json::json!({
25743            "phrase": "weekly on Monday at 8:30",
25744            "timezone": "America/New_York"
25745        })))
25746        .unwrap();
25747        assert_eq!(result["cadence"]["trigger"], "cron");
25748        assert_eq!(result["cadence"]["schedule"], "30 8 * * 1");
25749        assert_eq!(result["cadence"]["timezone"], "America/New_York");
25750        assert!(result.get("reason").is_none());
25751    }
25752
25753    #[test]
25754    fn rpc_returns_reason_instead_of_guessing_and_rejects_missing_phrase() {
25755        let result = handle_schedule_suggest(&request(serde_json::json!({
25756            "phrase": "sometime after lunch"
25757        })))
25758        .unwrap();
25759        assert!(result["cadence"].is_null());
25760        assert!(result["reason"].as_str().is_some_and(|s| !s.is_empty()));
25761
25762        assert_eq!(
25763            handle_schedule_suggest(&request(serde_json::json!({}))).unwrap_err(),
25764            "schedule.suggest requires 'phrase'"
25765        );
25766    }
25767}
25768
25769#[cfg(test)]
25770mod scheduler_stateful_dedup {
25771    //! The WS `scheduler.run` path deserializes a fresh `Task` from client
25772    //! params on every call and used to run it through a throwaway `Executor`
25773    //! without persisting the mutated task. Because the executor's
25774    //! deterministic occurrence guard lives in `task.executions`, that history
25775    //! was empty on every call and a replayed Interval/Once occurrence ran
25776    //! twice. `run_scheduler_task_once` now seeds the task from a `TaskStore`
25777    //! and saves it back, so the guard survives the JSON-RPC boundary.
25778    use super::run_scheduler_task_once;
25779    use car_multi::{AgentOutput, AgentRunner, AgentSpec, Mailbox, MultiError};
25780    use car_scheduler::{Task, TaskStore, TaskTrigger};
25781    use std::sync::atomic::{AtomicU32, Ordering};
25782    use std::sync::Arc;
25783
25784    /// Runner that counts how many times the runtime actually invoked it.
25785    struct CountingRunner {
25786        runs: Arc<AtomicU32>,
25787    }
25788
25789    #[async_trait::async_trait]
25790    impl AgentRunner for CountingRunner {
25791        async fn run(
25792            &self,
25793            spec: &AgentSpec,
25794            _task: &str,
25795            _runtime: &car_engine::Runtime,
25796            _mailbox: &Mailbox,
25797        ) -> Result<AgentOutput, MultiError> {
25798            self.runs.fetch_add(1, Ordering::SeqCst);
25799            Ok(AgentOutput {
25800                name: spec.name.clone(),
25801                answer: "done".to_string(),
25802                turns: 1,
25803                tool_calls: 0,
25804                duration_ms: 1.0,
25805                error: None,
25806                outcome: None,
25807                tokens: None,
25808                tools_used: Vec::new(),
25809            })
25810        }
25811    }
25812
25813    #[tokio::test]
25814    async fn second_run_dedups_across_the_jsonrpc_boundary() {
25815        let dir = tempfile::TempDir::new().unwrap();
25816        let store = TaskStore::new(dir.path());
25817        let runs = Arc::new(AtomicU32::new(0));
25818
25819        // The same Task JSON a client would post twice: an Interval trigger on a
25820        // long schedule (both calls land in slot 0) with an empty execution
25821        // history each time. Serializing/deserializing models the JSON-RPC hop.
25822        let task =
25823            Task::new("dedup_task", "do the thing").with_trigger(TaskTrigger::Interval, "1h");
25824        let task_json = serde_json::to_value(&task).unwrap();
25825
25826        // First call: runs and persists the occurrence.
25827        let mut t1: Task = serde_json::from_value(task_json.clone()).unwrap();
25828        assert!(t1.executions.is_empty());
25829        let runner1: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25830        run_scheduler_task_once(&mut t1, runner1, &store).await;
25831
25832        // Second call: a fresh deserialized Task (empty executions again), but
25833        // the store seeds the prior occurrence so the guard dedups it.
25834        let mut t2: Task = serde_json::from_value(task_json).unwrap();
25835        assert!(t2.executions.is_empty());
25836        let runner2: Arc<dyn AgentRunner> = Arc::new(CountingRunner { runs: runs.clone() });
25837        run_scheduler_task_once(&mut t2, runner2, &store).await;
25838
25839        assert_eq!(
25840            runs.load(Ordering::SeqCst),
25841            1,
25842            "the runner must be invoked exactly once across two stateless calls"
25843        );
25844
25845        // The persisted task carries the single occurrence.
25846        let persisted = store.load(&task.id).expect("task should be persisted");
25847        assert_eq!(persisted.run_count, 1);
25848        assert_eq!(persisted.executions.len(), 1);
25849    }
25850}
25851
25852#[cfg(test)]
25853mod a2ui_apply_delivery {
25854    //! Parslee-ai/car-releases#29: applying an A2UI envelope must reach
25855    //! every WS subscriber on the `a2ui.event` channel. A `createSurface`
25856    //! apply broadcasts `a2ui.surface_updated`; a `deleteSurface` apply
25857    //! broadcasts `a2ui.surface_deleted`. Reuses the fake
25858    //! WsChannel-over-mpsc harness from `a2ui_action_delivery`.
25859    use super::apply_a2ui_envelope;
25860    use crate::session::{ServerState, WsChannel, WsSink};
25861    use futures::{SinkExt, StreamExt};
25862    use std::collections::HashMap;
25863    use std::sync::atomic::AtomicU64;
25864    use std::sync::Arc;
25865    use tokio::sync::Mutex;
25866    use tokio_tungstenite::tungstenite::{Error as WsError, Message};
25867
25868    fn fake_subscriber() -> (
25869        Arc<WsChannel>,
25870        futures::channel::mpsc::UnboundedReceiver<Message>,
25871    ) {
25872        let (tx, rx) = futures::channel::mpsc::unbounded::<Message>();
25873        let sink: WsSink = Box::pin(tx.sink_map_err(|_| WsError::ConnectionClosed));
25874        let channel = Arc::new(WsChannel {
25875            write: Mutex::new(sink),
25876            pending: Mutex::new(HashMap::new()),
25877            active_actions: Mutex::new(HashMap::new()),
25878            next_id: AtomicU64::new(0),
25879        });
25880        (channel, rx)
25881    }
25882
25883    fn envelope(value: serde_json::Value) -> car_a2ui::A2uiEnvelope {
25884        serde_json::from_value(value).expect("valid envelope")
25885    }
25886
25887    #[tokio::test]
25888    async fn apply_create_broadcasts_surface_updated() {
25889        let tmp = tempfile::TempDir::new().unwrap();
25890        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25891        let (channel, mut rx) = fake_subscriber();
25892        state
25893            .a2ui_subscribers
25894            .lock()
25895            .await
25896            .insert("test-sub".to_string(), channel);
25897
25898        apply_a2ui_envelope(
25899            &state,
25900            envelope(serde_json::json!({
25901                "version": "v0.9",
25902                "createSurface": { "surfaceId": "surf-a" }
25903            })),
25904            None,
25905            None,
25906        )
25907        .await
25908        .expect("apply must succeed");
25909
25910        let msg = rx.next().await.expect("subscriber received no frame");
25911        let text = match msg {
25912            Message::Text(t) => t.to_string(),
25913            other => panic!("expected text frame, got {other:?}"),
25914        };
25915        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25916        assert_eq!(v["method"], "a2ui.event");
25917        assert_eq!(v["params"]["kind"], "a2ui.surface_updated");
25918        assert_eq!(v["params"]["result"]["surfaceId"], "surf-a");
25919    }
25920
25921    #[tokio::test]
25922    async fn apply_delete_broadcasts_surface_deleted() {
25923        let tmp = tempfile::TempDir::new().unwrap();
25924        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
25925        let (channel, mut rx) = fake_subscriber();
25926        state
25927            .a2ui_subscribers
25928            .lock()
25929            .await
25930            .insert("test-sub".to_string(), channel);
25931
25932        // Create the surface first so the delete targets something real.
25933        apply_a2ui_envelope(
25934            &state,
25935            envelope(serde_json::json!({
25936                "version": "v0.9",
25937                "createSurface": { "surfaceId": "surf-d" }
25938            })),
25939            None,
25940            None,
25941        )
25942        .await
25943        .expect("create must succeed");
25944        let _ = rx.next().await.expect("surface_updated frame");
25945
25946        apply_a2ui_envelope(
25947            &state,
25948            envelope(serde_json::json!({
25949                "version": "v0.9",
25950                "deleteSurface": { "surfaceId": "surf-d" }
25951            })),
25952            None,
25953            None,
25954        )
25955        .await
25956        .expect("delete must succeed");
25957
25958        let msg = rx
25959            .next()
25960            .await
25961            .expect("subscriber received no delete frame");
25962        let text = match msg {
25963            Message::Text(t) => t.to_string(),
25964            other => panic!("expected text frame, got {other:?}"),
25965        };
25966        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
25967        assert_eq!(v["method"], "a2ui.event");
25968        assert_eq!(v["params"]["kind"], "a2ui.surface_deleted");
25969        assert_eq!(v["params"]["result"]["surfaceId"], "surf-d");
25970    }
25971}
25972
25973#[cfg(test)]
25974mod agents_chat_attachments {
25975    //! Image attachments ride the `agents.chat` → `agent.chat`
25976    //! reverse-call as an optional `attachments` array of image
25977    //! ContentBlocks. The daemon validates the shape and forwards it
25978    //! verbatim; absent attachments must not alter the request shape.
25979    use super::{
25980        build_agent_chat_params, ensure_chat_goal_running, extract_chat_attachments,
25981        extract_chat_goal, extract_chat_model, handle_agents_chat_cancel, handle_goal_clear,
25982        handle_goal_set, handle_goal_status, handle_goal_suggest, send_external_chat_frame,
25983        try_forward_agent_chat_event, update_chat_goal_from_event, JsonRpcMessage,
25984    };
25985    use crate::session::{ChatSession, ServerState, WsChannel};
25986    use serde_json::{json, Value};
25987    use std::sync::atomic::AtomicBool;
25988    use std::sync::Arc;
25989
25990    fn req(params: Value) -> JsonRpcMessage {
25991        JsonRpcMessage {
25992            jsonrpc: "2.0".into(),
25993            method: None,
25994            params,
25995            id: json!(1),
25996            result: None,
25997            error: None,
25998        }
25999    }
26000
26001    #[test]
26002    fn extract_returns_none_when_absent_or_null() {
26003        assert_eq!(extract_chat_attachments(&json!({"prompt": "hi"})), Ok(None));
26004        assert_eq!(
26005            extract_chat_attachments(&json!({"attachments": null})),
26006            Ok(None)
26007        );
26008    }
26009
26010    #[test]
26011    fn extract_accepts_image_content_blocks() {
26012        let params = json!({
26013            "attachments": [
26014                {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
26015                {"type": "image_url", "url": "https://example.com/a.jpg", "detail": "auto"}
26016            ]
26017        });
26018        let got = extract_chat_attachments(&params).unwrap().unwrap();
26019        assert_eq!(got.as_array().map(|a| a.len()), Some(2));
26020    }
26021
26022    #[test]
26023    fn extract_rejects_non_array() {
26024        assert!(extract_chat_attachments(&json!({"attachments": "nope"})).is_err());
26025    }
26026
26027    #[test]
26028    fn extract_rejects_non_image_entry() {
26029        let params = json!({
26030            "attachments": [
26031                {"type": "image_base64", "data": "AAAA", "media_type": "image/png"},
26032                {"type": "text", "text": "not an image"}
26033            ]
26034        });
26035        assert!(extract_chat_attachments(&params).is_err());
26036    }
26037
26038    #[test]
26039    fn extract_rejects_base64_missing_data() {
26040        let params = json!({"attachments": [{"type": "image_base64", "media_type": "image/png"}]});
26041        assert!(extract_chat_attachments(&params).is_err());
26042    }
26043
26044    #[test]
26045    fn extract_rejects_disallowed_media_type() {
26046        let params = json!({
26047            "attachments": [{"type": "image_base64", "data": "AAAA", "media_type": "image/svg+xml"}]
26048        });
26049        assert!(extract_chat_attachments(&params).is_err());
26050    }
26051
26052    #[test]
26053    fn extract_rejects_non_http_image_url() {
26054        let params = json!({"attachments": [{"type": "image_url", "url": "file:///etc/passwd"}]});
26055        assert!(extract_chat_attachments(&params).is_err());
26056    }
26057
26058    #[test]
26059    fn build_params_omits_attachments_when_none() {
26060        let params =
26061            build_agent_chat_params("sess-1", "hello", true, "host-1", false, None, None, None);
26062        assert_eq!(params["session_id"], "sess-1");
26063        assert_eq!(params["prompt"], "hello");
26064        assert_eq!(params["context"]["host_client_id"], "host-1");
26065        assert!(
26066            params.get("attachments").is_none(),
26067            "no `attachments` key when there are none — agents that ignore it see the legacy shape"
26068        );
26069    }
26070
26071    #[test]
26072    fn build_params_carries_attachments_when_present() {
26073        let blocks = json!([
26074            {"type": "image_base64", "data": "AAAA", "media_type": "image/png"}
26075        ]);
26076        let params = build_agent_chat_params(
26077            "sess-2",
26078            "describe",
26079            true,
26080            "host-2",
26081            false,
26082            None,
26083            Some(blocks),
26084            None,
26085        );
26086        let att = params["attachments"]
26087            .as_array()
26088            .expect("attachments present");
26089        assert_eq!(att.len(), 1);
26090        assert_eq!(att[0]["type"], "image_base64");
26091        assert_eq!(att[0]["media_type"], "image/png");
26092    }
26093
26094    #[test]
26095    fn extract_goal_returns_none_when_absent_or_null() {
26096        assert_eq!(extract_chat_goal(&json!({"prompt": "hi"})), Ok(None));
26097        assert_eq!(extract_chat_goal(&json!({"goal": null})), Ok(None));
26098    }
26099
26100    #[test]
26101    fn extract_goal_normalizes_check_and_default_iterations() {
26102        let got = extract_chat_goal(&json!({"goal": {"check": "  cargo test -q  "}}))
26103            .unwrap()
26104            .unwrap();
26105        assert_eq!(got["check"], "cargo test -q");
26106        assert_eq!(got["max_iterations"], 8);
26107    }
26108
26109    #[test]
26110    fn extract_goal_clamps_iteration_budget() {
26111        let low = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 0}}))
26112            .unwrap()
26113            .unwrap();
26114        let high = extract_chat_goal(&json!({"goal": {"check": "true", "max_iterations": 500}}))
26115            .unwrap()
26116            .unwrap();
26117        assert_eq!(low["max_iterations"], 1);
26118        assert_eq!(high["max_iterations"], 50);
26119    }
26120
26121    #[test]
26122    fn extract_goal_rejects_bad_shape() {
26123        assert!(extract_chat_goal(&json!({"goal": "nope"})).is_err());
26124        assert!(extract_chat_goal(&json!({"goal": {"check": ""}})).is_err());
26125    }
26126
26127    #[test]
26128    fn selected_chat_model_is_forwarded_and_unset_preserves_agent_default() {
26129        assert_eq!(
26130            extract_chat_model(&json!({
26131                "model": "  openrouter/deepseek/deepseek-v3.2  "
26132            }))
26133            .unwrap()
26134            .as_deref(),
26135            Some("openrouter/deepseek/deepseek-v3.2")
26136        );
26137        assert_eq!(extract_chat_model(&json!({})), Ok(None));
26138        assert_eq!(extract_chat_model(&json!({"model": "  "})), Ok(None));
26139        assert!(extract_chat_model(&json!({"model": 42})).is_err());
26140
26141        let selected = build_agent_chat_params(
26142            "sess-model",
26143            "hello",
26144            true,
26145            "host-model",
26146            false,
26147            Some("openrouter/deepseek/deepseek-v3.2".into()),
26148            None,
26149            None,
26150        );
26151        assert_eq!(
26152            selected["model"], "openrouter/deepseek/deepseek-v3.2",
26153            "the actual daemon-to-agent chat request must carry the selected CAR model"
26154        );
26155
26156        let adaptive = build_agent_chat_params(
26157            "sess-adaptive",
26158            "hello",
26159            true,
26160            "host-adaptive",
26161            false,
26162            None,
26163            None,
26164            None,
26165        );
26166        assert!(
26167            adaptive.get("model").is_none(),
26168            "omission preserves the supervised agent or adaptive-router default"
26169        );
26170    }
26171
26172    #[test]
26173    fn build_params_carries_goal_when_present() {
26174        let goal = json!({"check": "cargo test -q", "max_iterations": 4});
26175        let params = build_agent_chat_params(
26176            "sess-3",
26177            "fix tests",
26178            true,
26179            "host-3",
26180            false,
26181            None,
26182            None,
26183            Some(goal),
26184        );
26185        assert_eq!(params["goal"]["check"], "cargo test -q");
26186        assert_eq!(params["goal"]["max_iterations"], 4);
26187        assert!(params.get("attachments").is_none());
26188    }
26189
26190    #[tokio::test]
26191    async fn goal_set_status_and_clear_roundtrip() {
26192        let tmp = tempfile::TempDir::new().unwrap();
26193        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26194
26195        let set = handle_goal_set(
26196            &req(json!({
26197                "session_id": "chat-1",
26198                "goal": {"check": "  test -f done  ", "max_iterations": 500}
26199            })),
26200            &state,
26201        )
26202        .await
26203        .unwrap();
26204        assert_eq!(set["set"], true);
26205        assert_eq!(set["goal"]["check"], "test -f done");
26206        assert_eq!(set["goal"]["max_iterations"], 50);
26207        assert_eq!(set["goal"]["status"], "active");
26208
26209        let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
26210            .await
26211            .unwrap();
26212        assert_eq!(status["goal"]["session_id"], "chat-1");
26213        assert_eq!(status["goal"]["check"], "test -f done");
26214
26215        let clear = handle_goal_clear(&req(json!({"session_id": "chat-1"})), &state)
26216            .await
26217            .unwrap();
26218        assert_eq!(clear["cleared"], true);
26219        let status = handle_goal_status(&req(json!({"session_id": "chat-1"})), &state)
26220            .await
26221            .unwrap();
26222        assert!(status["goal"].is_null());
26223    }
26224
26225    #[tokio::test]
26226    async fn goal_status_tracks_evaluator_and_terminal_events() {
26227        let tmp = tempfile::TempDir::new().unwrap();
26228        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26229        handle_goal_set(
26230            &req(json!({"session_id": "chat-2", "check": "test -f done", "max_iterations": 4})),
26231            &state,
26232        )
26233        .await
26234        .unwrap();
26235
26236        update_chat_goal_from_event(
26237            &state,
26238            "chat-2",
26239            &json!({
26240                "kind": "goal_evaluated",
26241                "iteration": 1,
26242                "met": false,
26243                "grounded": false,
26244                "reason": "file missing"
26245            }),
26246        )
26247        .await;
26248        let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
26249            .await
26250            .unwrap();
26251        assert_eq!(status["goal"]["status"], "running");
26252        assert_eq!(status["goal"]["last_iteration"], 1);
26253        assert_eq!(status["goal"]["last_reason"], "file missing");
26254
26255        update_chat_goal_from_event(
26256            &state,
26257            "chat-2",
26258            &json!({
26259                "kind": "goal_evaluated",
26260                "iteration": 2,
26261                "met": true,
26262                "grounded": true,
26263                "reason": "file exists"
26264            }),
26265        )
26266        .await;
26267        update_chat_goal_from_event(
26268            &state,
26269            "chat-2",
26270            &json!({"kind": "done", "finish_reason": "goal reached"}),
26271        )
26272        .await;
26273        let status = handle_goal_status(&req(json!({"session_id": "chat-2"})), &state)
26274            .await
26275            .unwrap();
26276        assert_eq!(status["goal"]["status"], "met");
26277        assert_eq!(status["goal"]["last_met"], true);
26278        assert_eq!(status["goal"]["terminal_kind"], "done");
26279        assert_eq!(status["goal"]["terminal_message"], "goal reached");
26280    }
26281
26282    /// An `agent.chat.event` notification exactly as a supervised agent sends
26283    /// one — the shape `try_forward_agent_chat_event` reads.
26284    fn chat_event(session_id: &str, extra: Value) -> JsonRpcMessage {
26285        let mut params = serde_json::Map::new();
26286        params.insert("session_id".into(), session_id.into());
26287        if let Some(obj) = extra.as_object() {
26288            for (k, v) in obj {
26289                params.insert(k.clone(), v.clone());
26290            }
26291        }
26292        JsonRpcMessage {
26293            jsonrpc: "2.0".into(),
26294            method: Some("agent.chat.event".into()),
26295            params: Value::Object(params),
26296            id: Value::Null,
26297            result: None,
26298            error: None,
26299        }
26300    }
26301
26302    /// Register a live host session and a routed chat session with a goal set,
26303    /// so a frame driven through `try_forward_agent_chat_event` reaches the
26304    /// goal record the way a real turn does.
26305    async fn routed_goal_session(session_id: &str) -> (tempfile::TempDir, Arc<ServerState>) {
26306        let tmp = tempfile::TempDir::new().unwrap();
26307        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26308        state
26309            .create_session("host-1", Arc::new(WsChannel::test_stub()))
26310            .await
26311            .unwrap();
26312        state.chat_sessions.lock().await.insert(
26313            session_id.to_string(),
26314            ChatSession {
26315                agent_id: "parslee-core".into(),
26316                host_client_id: "host-1".into(),
26317                created_at: 0,
26318                local_cancel: None,
26319            },
26320        );
26321        handle_goal_set(
26322            &req(json!({"session_id": session_id, "check": "test -f done", "max_iterations": 4})),
26323            &state,
26324        )
26325        .await
26326        .unwrap();
26327        (tmp, state)
26328    }
26329
26330    /// THROUGH THE PRODUCTION PATH, which is the only way this defect was ever
26331    /// going to show.
26332    ///
26333    /// The direct-call test below passes a hand-built params object and always
26334    /// did. The real caller handed the reader a projection of the frame with
26335    /// every key forced present as `null`, so `get("finish_reason")` answered
26336    /// `Some(&Value::Null)`, the `.or_else` chain stopped at the first name,
26337    /// and the message was dropped — for `auth_required` AND, for as long as
26338    /// the projection existed, for `error` frames too.
26339    #[tokio::test]
26340    async fn an_auth_required_frame_persists_its_message_through_the_wire_path() {
26341        const MESSAGE: &str = "Parslee Core runs on your Parslee account. Sign in to continue. \
26342                               New to Parslee? Create your account at parslee.ai first, then \
26343                               come back and sign in.";
26344        let (_tmp, state) = routed_goal_session("wire-auth").await;
26345
26346        try_forward_agent_chat_event(
26347            &chat_event(
26348                "wire-auth",
26349                json!({
26350                    "kind": "auth_required",
26351                    "reason": "signed_out",
26352                    "message": MESSAGE
26353                }),
26354            ),
26355            &state,
26356        )
26357        .await;
26358
26359        let status = handle_goal_status(&req(json!({"session_id": "wire-auth"})), &state)
26360            .await
26361            .unwrap();
26362        assert_eq!(status["goal"]["terminal_kind"], "auth_required");
26363        assert_eq!(
26364            status["goal"]["terminal_message"], MESSAGE,
26365            "the remedy must survive the trip from the agent to the goal record"
26366        );
26367        assert_eq!(status["goal"]["status"], "error");
26368    }
26369
26370    /// The same defect, on the frame that has been shipping the longest: an
26371    /// `error` terminal lost its text through this path too.
26372    #[tokio::test]
26373    async fn an_error_frame_persists_its_text_through_the_wire_path() {
26374        let (_tmp, state) = routed_goal_session("wire-error").await;
26375
26376        try_forward_agent_chat_event(
26377            &chat_event(
26378                "wire-error",
26379                json!({ "kind": "error", "error": "goal not reached: turn budget exhausted" }),
26380            ),
26381            &state,
26382        )
26383        .await;
26384
26385        let status = handle_goal_status(&req(json!({"session_id": "wire-error"})), &state)
26386            .await
26387            .unwrap();
26388        assert_eq!(status["goal"]["terminal_kind"], "error");
26389        assert_eq!(
26390            status["goal"]["terminal_message"],
26391            "goal not reached: turn budget exhausted"
26392        );
26393        assert_eq!(status["goal"]["status"], "error");
26394    }
26395
26396    /// The fail-open marker travelled the same broken road: it was not on the
26397    /// projection's field list at all, so a `done` that never got to run its
26398    /// check was persisted as `"met"` through the wire path.
26399    #[tokio::test]
26400    async fn a_fail_open_done_is_not_recorded_as_met_through_the_wire_path() {
26401        let (_tmp, state) = routed_goal_session("wire-unevaluated").await;
26402
26403        try_forward_agent_chat_event(
26404            &chat_event(
26405                "wire-unevaluated",
26406                json!({
26407                    "kind": "done",
26408                    "finish_reason": "goal check unevaluated (timed out)",
26409                    "goal_unevaluated": true
26410                }),
26411            ),
26412            &state,
26413        )
26414        .await;
26415
26416        let status = handle_goal_status(&req(json!({"session_id": "wire-unevaluated"})), &state)
26417            .await
26418            .unwrap();
26419        assert_eq!(
26420            status["goal"]["status"], "unevaluated",
26421            "a check that never ran is not a pass"
26422        );
26423        assert_eq!(
26424            status["goal"]["terminal_message"],
26425            "goal check unevaluated (timed out)"
26426        );
26427    }
26428
26429    /// A goal chat that ends on the account must not be left recorded as
26430    /// running. `auth_required` is terminal on the wire (`chat::
26431    /// goal_turn_terminal_event`) and nothing follows it, so the persisted
26432    /// record is the last word a `goal.status` poller will ever get: before
26433    /// this it stayed `"running"` on disk forever.
26434    ///
26435    /// The status mirrors the `error` terminal deliberately — the condition
26436    /// was not met and the run is over. The distinction lives in
26437    /// `terminal_kind`, which is where a caller that cares can read it.
26438    #[tokio::test]
26439    async fn a_goal_that_ends_on_auth_required_is_recorded_as_terminal() {
26440        let tmp = tempfile::TempDir::new().unwrap();
26441        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26442        handle_goal_set(
26443            &req(json!({"session_id": "chat-auth", "check": "test -f done", "max_iterations": 4})),
26444            &state,
26445        )
26446        .await
26447        .unwrap();
26448
26449        // Explicit nulls for the earlier names — the shape any caller that
26450        // projects its params produces. An `.or_else` chain reads those as
26451        // present and stops; this function must read them as absent.
26452        update_chat_goal_from_event(
26453            &state,
26454            "chat-auth",
26455            &json!({
26456                "kind": "auth_required",
26457                "finish_reason": Value::Null,
26458                "error": Value::Null,
26459                "message": "Parslee Core runs on your Parslee account."
26460            }),
26461        )
26462        .await;
26463
26464        let status = handle_goal_status(&req(json!({"session_id": "chat-auth"})), &state)
26465            .await
26466            .unwrap();
26467        assert_eq!(
26468            status["goal"]["terminal_kind"], "auth_required",
26469            "the record must name WHY the run ended"
26470        );
26471        assert_eq!(
26472            status["goal"]["terminal_message"],
26473            "Parslee Core runs on your Parslee account."
26474        );
26475        assert_eq!(
26476            status["goal"]["status"], "error",
26477            "the same status an `error` terminal yields — not `running`, and \
26478             never `met`"
26479        );
26480    }
26481
26482    /// car#1113 review: a fail-open `done` — the goal check never got the
26483    /// chance to run (`GoalHalt::EvaluationTimeout`, `chat::
26484    /// goal_turn_terminal_event`) — must NOT be recorded as `status: "met"`.
26485    /// Before this fix, `kind == "done"` mapped to `"met"` unconditionally,
26486    /// so this exact event persisted a "verified" status for a goal that was
26487    /// never actually checked, surviving a daemon restart via
26488    /// `chat-goals.json`.
26489    #[tokio::test]
26490    async fn a_fail_open_done_is_recorded_as_unevaluated_not_met() {
26491        let tmp = tempfile::TempDir::new().unwrap();
26492        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26493        handle_goal_set(
26494            &req(json!({"session_id": "chat-timeout", "check": "test -f done"})),
26495            &state,
26496        )
26497        .await
26498        .unwrap();
26499
26500        // Shape mirrors `chat::goal_turn_terminal_event`'s `EvaluationTimeout`
26501        // arm exactly: `kind: "done"` (fail-open) plus the machine-readable
26502        // `goal_unevaluated` marker alongside the human-readable
26503        // `finish_reason`.
26504        update_chat_goal_from_event(
26505            &state,
26506            "chat-timeout",
26507            &json!({
26508                "kind": "done",
26509                "finish_reason": "goal check unevaluated (timed out) — reply delivered unverified",
26510                "goal_unevaluated": true,
26511            }),
26512        )
26513        .await;
26514
26515        let status = handle_goal_status(&req(json!({"session_id": "chat-timeout"})), &state)
26516            .await
26517            .unwrap();
26518        assert_eq!(
26519            status["goal"]["status"], "unevaluated",
26520            "a goal check that never ran must not be recorded as met: {status}"
26521        );
26522        assert_eq!(status["goal"]["terminal_kind"], "done");
26523        assert_eq!(
26524            status["goal"]["terminal_message"],
26525            "goal check unevaluated (timed out) — reply delivered unverified"
26526        );
26527
26528        // A genuine achieved completion (no `goal_unevaluated` marker) is
26529        // unaffected by the new branch — still "met".
26530        handle_goal_set(
26531            &req(json!({"session_id": "chat-achieved", "check": "test -f done"})),
26532            &state,
26533        )
26534        .await
26535        .unwrap();
26536        update_chat_goal_from_event(
26537            &state,
26538            "chat-achieved",
26539            &json!({"kind": "done", "finish_reason": "goal reached"}),
26540        )
26541        .await;
26542        let status = handle_goal_status(&req(json!({"session_id": "chat-achieved"})), &state)
26543            .await
26544            .unwrap();
26545        assert_eq!(status["goal"]["status"], "met");
26546    }
26547
26548    #[tokio::test]
26549    async fn inline_goal_creates_durable_running_status() {
26550        let tmp = tempfile::TempDir::new().unwrap();
26551        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26552
26553        ensure_chat_goal_running(
26554            &state,
26555            "chat-inline",
26556            &json!({"check": "test -f done", "max_iterations": 99}),
26557        )
26558        .await
26559        .unwrap();
26560
26561        let status = handle_goal_status(&req(json!({"session_id": "chat-inline"})), &state)
26562            .await
26563            .unwrap();
26564        assert_eq!(status["goal"]["session_id"], "chat-inline");
26565        assert_eq!(status["goal"]["check"], "test -f done");
26566        assert_eq!(status["goal"]["max_iterations"], 50);
26567        assert_eq!(status["goal"]["status"], "running");
26568    }
26569
26570    #[tokio::test]
26571    async fn rerun_goal_clears_prior_terminal_status() {
26572        let tmp = tempfile::TempDir::new().unwrap();
26573        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26574        handle_goal_set(
26575            &req(json!({"session_id": "chat-rerun", "check": "test -f done"})),
26576            &state,
26577        )
26578        .await
26579        .unwrap();
26580        update_chat_goal_from_event(
26581            &state,
26582            "chat-rerun",
26583            &json!({"kind": "error", "error": "cancelled"}),
26584        )
26585        .await;
26586
26587        ensure_chat_goal_running(
26588            &state,
26589            "chat-rerun",
26590            &json!({"check": "test -f done", "max_iterations": 4}),
26591        )
26592        .await
26593        .unwrap();
26594
26595        let status = handle_goal_status(&req(json!({"session_id": "chat-rerun"})), &state)
26596            .await
26597            .unwrap();
26598        assert_eq!(status["goal"]["status"], "running");
26599        assert!(status["goal"]["terminal_kind"].is_null());
26600        assert!(status["goal"]["terminal_message"].is_null());
26601    }
26602
26603    #[tokio::test]
26604    async fn daemon_owned_chat_frames_update_goal_status_before_host_delivery() {
26605        let tmp = tempfile::TempDir::new().unwrap();
26606        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26607        handle_goal_set(
26608            &req(json!({"session_id": "chat-local", "check": "test -f done"})),
26609            &state,
26610        )
26611        .await
26612        .unwrap();
26613
26614        // No chat_sessions route is registered here. `send_external_chat_frame`
26615        // should still persist the goal status before best-effort host delivery
26616        // decides there is no host channel to send to.
26617        send_external_chat_frame(
26618            &state,
26619            "chat-local",
26620            json!({
26621                "session_id": "chat-local",
26622                "agent_id": "local",
26623                "kind": "goal_evaluated",
26624                "iteration": 1,
26625                "met": true,
26626                "grounded": true,
26627                "reason": "file exists"
26628            }),
26629        )
26630        .await;
26631
26632        let status = handle_goal_status(&req(json!({"session_id": "chat-local"})), &state)
26633            .await
26634            .unwrap();
26635        assert_eq!(status["goal"]["status"], "met");
26636        assert_eq!(status["goal"]["last_iteration"], 1);
26637        assert_eq!(status["goal"]["last_reason"], "file exists");
26638    }
26639
26640    #[tokio::test]
26641    async fn cancelling_goal_chat_marks_standing_goal_terminal() {
26642        let tmp = tempfile::TempDir::new().unwrap();
26643        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
26644        handle_goal_set(
26645            &req(json!({"session_id": "chat-cancel", "check": "test -f done"})),
26646            &state,
26647        )
26648        .await
26649        .unwrap();
26650        update_chat_goal_from_event(
26651            &state,
26652            "chat-cancel",
26653            &json!({"kind": "goal_evaluated", "iteration": 1, "met": false, "grounded": false, "reason": "not yet"}),
26654        )
26655        .await;
26656        let cancel_flag = Arc::new(AtomicBool::new(false));
26657        state.chat_sessions.lock().await.insert(
26658            "chat-cancel".to_string(),
26659            ChatSession {
26660                agent_id: "local".to_string(),
26661                host_client_id: "host".to_string(),
26662                created_at: 0,
26663                local_cancel: Some(cancel_flag.clone()),
26664            },
26665        );
26666
26667        // A different authenticated session may not abort someone else's turn.
26668        // `agents.chat.approve` has always required the originating host; this
26669        // is the same check on the arm next to it, which had none — being
26670        // authenticated was taken to settle what the caller may do.
26671        let stolen = handle_agents_chat_cancel(
26672            &req(json!({"session_id": "chat-cancel"})),
26673            &state,
26674            "someone-else",
26675            false,
26676        )
26677        .await;
26678        assert!(
26679            stolen.is_err(),
26680            "a foreign session must not cancel this turn: {stolen:?}"
26681        );
26682        assert!(
26683            state.chat_sessions.lock().await.contains_key("chat-cancel"),
26684            "a refused cancel must not destroy the routing entry on its way out"
26685        );
26686        assert!(
26687            !cancel_flag.load(std::sync::atomic::Ordering::SeqCst),
26688            "and must not have signalled cancellation"
26689        );
26690
26691        let cancel = handle_agents_chat_cancel(
26692            &req(json!({"session_id": "chat-cancel"})),
26693            &state,
26694            "host",
26695            true,
26696        )
26697        .await
26698        .unwrap();
26699        assert_eq!(cancel["cancelled"], true);
26700        let status = handle_goal_status(&req(json!({"session_id": "chat-cancel"})), &state)
26701            .await
26702            .unwrap();
26703        assert_eq!(status["goal"]["status"], "error");
26704        assert_eq!(status["goal"]["terminal_kind"], "error");
26705        assert_eq!(status["goal"]["terminal_message"], "cancelled");
26706        assert!(cancel_flag.load(std::sync::atomic::Ordering::SeqCst));
26707    }
26708
26709    #[tokio::test]
26710    async fn goal_status_persists_across_server_state_restart() {
26711        let tmp = tempfile::TempDir::new().unwrap();
26712        let journal_dir = tmp.path().join(".car").join("journals");
26713        let state = Arc::new(ServerState::standalone(journal_dir.clone()));
26714
26715        handle_goal_set(
26716            &req(json!({"session_id": "chat-durable", "check": "test -f done"})),
26717            &state,
26718        )
26719        .await
26720        .unwrap();
26721        update_chat_goal_from_event(
26722            &state,
26723            "chat-durable",
26724            &json!({
26725                "kind": "goal_evaluated",
26726                "iteration": 1,
26727                "met": false,
26728                "grounded": false,
26729                "reason": "not yet"
26730            }),
26731        )
26732        .await;
26733
26734        let restarted = Arc::new(ServerState::standalone(journal_dir.clone()));
26735        let status = handle_goal_status(&req(json!({"session_id": "chat-durable"})), &restarted)
26736            .await
26737            .unwrap();
26738        assert_eq!(status["goal"]["check"], "test -f done");
26739        assert_eq!(status["goal"]["status"], "active");
26740        assert_eq!(status["goal"]["last_iteration"], 1);
26741        assert_eq!(status["goal"]["last_reason"], "not yet");
26742
26743        update_chat_goal_from_event(
26744            &restarted,
26745            "chat-durable",
26746            &json!({
26747                "kind": "goal_evaluated",
26748                "iteration": 2,
26749                "met": true,
26750                "grounded": true,
26751                "reason": "done exists"
26752            }),
26753        )
26754        .await;
26755        update_chat_goal_from_event(
26756            &restarted,
26757            "chat-durable",
26758            &json!({"kind": "done", "finish_reason": "goal reached"}),
26759        )
26760        .await;
26761
26762        let restarted_again = Arc::new(ServerState::standalone(journal_dir));
26763        let status = handle_goal_status(
26764            &req(json!({"session_id": "chat-durable"})),
26765            &restarted_again,
26766        )
26767        .await
26768        .unwrap();
26769        assert_eq!(status["goal"]["status"], "met");
26770        assert_eq!(status["goal"]["last_iteration"], 2);
26771        assert_eq!(status["goal"]["terminal_kind"], "done");
26772    }
26773
26774    #[tokio::test]
26775    async fn restart_downgrades_stale_running_goal_to_active() {
26776        let tmp = tempfile::TempDir::new().unwrap();
26777        let journal_dir = tmp.path().join(".car").join("journals");
26778        let state = Arc::new(ServerState::standalone(journal_dir.clone()));
26779
26780        handle_goal_set(
26781            &req(json!({"session_id": "chat-running", "check": "true"})),
26782            &state,
26783        )
26784        .await
26785        .unwrap();
26786        {
26787            let mut goals = state.chat_goals.lock().await;
26788            goals.get_mut("chat-running").unwrap().status = "running".to_string();
26789        }
26790        state.persist_chat_goals().await.unwrap();
26791
26792        let restarted = Arc::new(ServerState::standalone(journal_dir));
26793        let status = handle_goal_status(&req(json!({"session_id": "chat-running"})), &restarted)
26794            .await
26795            .unwrap();
26796        assert_eq!(status["goal"]["status"], "active");
26797    }
26798
26799    #[tokio::test]
26800    async fn goal_suggest_detects_cargo_project() {
26801        let tmp = tempfile::TempDir::new().unwrap();
26802        std::fs::write(
26803            tmp.path().join("Cargo.toml"),
26804            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
26805        )
26806        .unwrap();
26807        let state = Arc::new(ServerState::standalone(
26808            tmp.path().join(".car").join("journals"),
26809        ));
26810
26811        let got = handle_goal_suggest(
26812            &req(json!({
26813                "prompt": "make the tests pass",
26814                "working_dir": tmp.path().to_string_lossy()
26815            })),
26816            &state,
26817        )
26818        .await
26819        .unwrap();
26820        assert_eq!(got["suggested"], true);
26821        assert_eq!(got["confidence"], "high");
26822        assert!(got["goal"]["check"]
26823            .as_str()
26824            .unwrap()
26825            .ends_with(" && cargo test -q"));
26826    }
26827
26828    #[tokio::test]
26829    async fn goal_suggest_prefers_project_check_over_existing_path_mentions() {
26830        let tmp = tempfile::TempDir::new().unwrap();
26831        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26832        std::fs::write(
26833            tmp.path().join("Cargo.toml"),
26834            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
26835        )
26836        .unwrap();
26837        std::fs::write(tmp.path().join("src/lib.rs"), "").unwrap();
26838        let state = Arc::new(ServerState::standalone(
26839            tmp.path().join(".car").join("journals"),
26840        ));
26841
26842        let got = handle_goal_suggest(
26843            &req(json!({
26844                "prompt": "fix src/lib.rs",
26845                "working_dir": tmp.path().to_string_lossy()
26846            })),
26847            &state,
26848        )
26849        .await
26850        .unwrap();
26851        assert!(got["goal"]["check"]
26852            .as_str()
26853            .unwrap()
26854            .ends_with(" && cargo test -q"));
26855    }
26856
26857    #[tokio::test]
26858    async fn goal_suggest_combines_created_file_with_project_check() {
26859        let tmp = tempfile::TempDir::new().unwrap();
26860        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
26861        std::fs::write(
26862            tmp.path().join("Cargo.toml"),
26863            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n",
26864        )
26865        .unwrap();
26866        let state = Arc::new(ServerState::standalone(
26867            tmp.path().join(".car").join("journals"),
26868        ));
26869
26870        let got = handle_goal_suggest(
26871            &req(json!({
26872                "prompt": "create src/lib.rs",
26873                "working_dir": tmp.path().to_string_lossy()
26874            })),
26875            &state,
26876        )
26877        .await
26878        .unwrap();
26879        assert_eq!(got["suggested"], true);
26880        assert_eq!(got["confidence"], "high");
26881        let check = got["goal"]["check"].as_str().unwrap();
26882        assert!(check.contains("test -f 'src/lib.rs'"), "{check}");
26883        assert!(check.ends_with(" && cargo test -q"), "{check}");
26884        let signals = got["signals"].as_array().unwrap();
26885        assert!(signals.contains(&json!("prompt_mentions_path:src/lib.rs")));
26886        assert!(signals.contains(&json!("Cargo.toml")));
26887    }
26888
26889    #[tokio::test]
26890    async fn goal_suggest_chooses_package_script_from_prompt() {
26891        let tmp = tempfile::TempDir::new().unwrap();
26892        std::fs::write(
26893            tmp.path().join("package.json"),
26894            r#"{"scripts":{"test":"vitest run","typecheck":"tsc --noEmit","check":"biome check","build":"vite build","lint":"eslint ."}}"#,
26895        )
26896        .unwrap();
26897        std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
26898        let state = Arc::new(ServerState::standalone(
26899            tmp.path().join(".car").join("journals"),
26900        ));
26901
26902        let got = handle_goal_suggest(
26903            &req(json!({
26904                "prompt": "fix the TypeScript errors",
26905                "working_dir": tmp.path().to_string_lossy()
26906            })),
26907            &state,
26908        )
26909        .await
26910        .unwrap();
26911        assert_eq!(got["suggested"], true);
26912        assert!(got["goal"]["check"]
26913            .as_str()
26914            .unwrap()
26915            .ends_with(" && pnpm typecheck"));
26916    }
26917
26918    #[tokio::test]
26919    async fn goal_suggest_chooses_check_script_from_prompt() {
26920        let tmp = tempfile::TempDir::new().unwrap();
26921        std::fs::write(
26922            tmp.path().join("package.json"),
26923            r#"{"scripts":{"test":"vitest run","check":"biome check","build":"vite build"}}"#,
26924        )
26925        .unwrap();
26926        std::fs::write(tmp.path().join("pnpm-lock.yaml"), "").unwrap();
26927        let state = Arc::new(ServerState::standalone(
26928            tmp.path().join(".car").join("journals"),
26929        ));
26930
26931        let got = handle_goal_suggest(
26932            &req(json!({
26933                "prompt": "fix the CI check failures",
26934                "working_dir": tmp.path().to_string_lossy()
26935            })),
26936            &state,
26937        )
26938        .await
26939        .unwrap();
26940        assert_eq!(got["suggested"], true);
26941        assert!(got["goal"]["check"]
26942            .as_str()
26943            .unwrap()
26944            .ends_with(" && pnpm check"));
26945        let signals = got["signals"].as_array().unwrap();
26946        assert!(signals.contains(&json!("package_script:check")));
26947    }
26948
26949    #[tokio::test]
26950    async fn goal_suggest_chooses_build_script_from_nested_directory_prompt() {
26951        let tmp = tempfile::TempDir::new().unwrap();
26952        std::fs::create_dir_all(tmp.path().join("apps/web")).unwrap();
26953        std::fs::write(
26954            tmp.path().join("apps/web/package.json"),
26955            r#"{"scripts":{"test":"vitest run","build":"vite build","lint":"eslint ."}}"#,
26956        )
26957        .unwrap();
26958        let state = Arc::new(ServerState::standalone(
26959            tmp.path().join(".car").join("journals"),
26960        ));
26961
26962        let got = handle_goal_suggest(
26963            &req(json!({
26964                "prompt": "fix the production build in apps/web",
26965                "working_dir": tmp.path().to_string_lossy()
26966            })),
26967            &state,
26968        )
26969        .await
26970        .unwrap();
26971        assert_eq!(got["suggested"], true);
26972        let check = got["goal"]["check"].as_str().unwrap();
26973        assert!(check.contains("apps/web"), "{check}");
26974        assert!(check.ends_with(" && npm run build"), "{check}");
26975        let signals = got["signals"].as_array().unwrap();
26976        assert!(signals.contains(&json!("package_script:build")));
26977    }
26978
26979    #[tokio::test]
26980    async fn goal_suggest_chooses_lint_script_from_prompt() {
26981        let tmp = tempfile::TempDir::new().unwrap();
26982        std::fs::write(
26983            tmp.path().join("package.json"),
26984            r#"{"scripts":{"test":"vitest run","lint":"eslint .","build":"vite build"}}"#,
26985        )
26986        .unwrap();
26987        let state = Arc::new(ServerState::standalone(
26988            tmp.path().join(".car").join("journals"),
26989        ));
26990
26991        let got = handle_goal_suggest(
26992            &req(json!({
26993                "prompt": "fix lint errors",
26994                "working_dir": tmp.path().to_string_lossy()
26995            })),
26996            &state,
26997        )
26998        .await
26999        .unwrap();
27000        assert_eq!(got["suggested"], true);
27001        assert!(got["goal"]["check"]
27002            .as_str()
27003            .unwrap()
27004            .ends_with(" && npm run lint"));
27005        let signals = got["signals"].as_array().unwrap();
27006        assert!(signals.contains(&json!("package_script:lint")));
27007    }
27008
27009    #[tokio::test]
27010    async fn goal_suggest_uses_nearest_nested_project_for_created_file() {
27011        let tmp = tempfile::TempDir::new().unwrap();
27012        std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
27013        std::fs::write(
27014            tmp.path().join("apps/web/package.json"),
27015            r#"{"scripts":{"test":"vitest run"}}"#,
27016        )
27017        .unwrap();
27018        let state = Arc::new(ServerState::standalone(
27019            tmp.path().join(".car").join("journals"),
27020        ));
27021
27022        let got = handle_goal_suggest(
27023            &req(json!({
27024                "prompt": "create apps/web/src/App.ts",
27025                "working_dir": tmp.path().to_string_lossy()
27026            })),
27027            &state,
27028        )
27029        .await
27030        .unwrap();
27031        assert_eq!(got["suggested"], true);
27032        assert_eq!(got["confidence"], "high");
27033        let check = got["goal"]["check"].as_str().unwrap();
27034        assert!(check.contains("apps/web"), "{check}");
27035        assert!(check.contains("test -f 'src/App.ts'"), "{check}");
27036        assert!(check.ends_with(" && npm test"), "{check}");
27037        let signals = got["signals"].as_array().unwrap();
27038        assert!(signals.contains(&json!("project_dir:apps/web")));
27039        assert!(signals.contains(&json!("package.json")));
27040    }
27041
27042    #[tokio::test]
27043    async fn goal_suggest_uses_nearest_nested_project_for_existing_file_prompt() {
27044        let tmp = tempfile::TempDir::new().unwrap();
27045        std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
27046        std::fs::write(
27047            tmp.path().join("apps/web/package.json"),
27048            r#"{"scripts":{"typecheck":"tsc --noEmit","test":"vitest run"}}"#,
27049        )
27050        .unwrap();
27051        std::fs::write(tmp.path().join("apps/web/src/App.ts"), "export {}\n").unwrap();
27052        let state = Arc::new(ServerState::standalone(
27053            tmp.path().join(".car").join("journals"),
27054        ));
27055
27056        let got = handle_goal_suggest(
27057            &req(json!({
27058                "prompt": "fix TypeScript errors in apps/web/src/App.ts",
27059                "working_dir": tmp.path().to_string_lossy()
27060            })),
27061            &state,
27062        )
27063        .await
27064        .unwrap();
27065        assert_eq!(got["suggested"], true);
27066        assert_eq!(got["confidence"], "high");
27067        let check = got["goal"]["check"].as_str().unwrap();
27068        assert!(check.contains("apps/web"), "{check}");
27069        assert!(!check.contains("test -f"), "{check}");
27070        assert!(check.ends_with(" && npm run typecheck"), "{check}");
27071        let signals = got["signals"].as_array().unwrap();
27072        assert!(signals.contains(&json!("prompt_mentions_path:apps/web/src/App.ts")));
27073        assert!(signals.contains(&json!("project_dir:apps/web")));
27074        assert!(signals.contains(&json!("package_script:typecheck")));
27075    }
27076
27077    #[tokio::test]
27078    async fn goal_suggest_uses_nearest_nested_project_for_directory_prompt() {
27079        let tmp = tempfile::TempDir::new().unwrap();
27080        std::fs::create_dir_all(tmp.path().join("apps/web/src")).unwrap();
27081        std::fs::write(
27082            tmp.path().join("apps/web/package.json"),
27083            r#"{"scripts":{"test":"vitest run"}}"#,
27084        )
27085        .unwrap();
27086        let state = Arc::new(ServerState::standalone(
27087            tmp.path().join(".car").join("journals"),
27088        ));
27089
27090        let got = handle_goal_suggest(
27091            &req(json!({
27092                "prompt": "fix apps/web",
27093                "working_dir": tmp.path().to_string_lossy()
27094            })),
27095            &state,
27096        )
27097        .await
27098        .unwrap();
27099        assert_eq!(got["suggested"], true);
27100        assert_eq!(got["confidence"], "high");
27101        let check = got["goal"]["check"].as_str().unwrap();
27102        assert!(check.contains("apps/web"), "{check}");
27103        assert!(!check.contains("test -f"), "{check}");
27104        assert!(check.ends_with(" && npm test"), "{check}");
27105        let signals = got["signals"].as_array().unwrap();
27106        assert!(signals.contains(&json!("prompt_mentions_path:apps/web")));
27107        assert!(signals.contains(&json!("project_dir:apps/web")));
27108    }
27109
27110    #[tokio::test]
27111    async fn goal_suggest_can_store_the_suggested_goal() {
27112        let tmp = tempfile::TempDir::new().unwrap();
27113        std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
27114        let journal_dir = tmp.path().join(".car").join("journals");
27115        let state = Arc::new(ServerState::standalone(journal_dir.clone()));
27116
27117        let got = handle_goal_suggest(
27118            &req(json!({
27119                "prompt": "make it work",
27120                "working_dir": tmp.path().to_string_lossy(),
27121                "session_id": "chat-suggest",
27122                "set": true
27123            })),
27124            &state,
27125        )
27126        .await
27127        .unwrap();
27128        assert_eq!(got["set"], true);
27129        assert_eq!(got["stored_goal"]["session_id"], "chat-suggest");
27130
27131        let restarted = Arc::new(ServerState::standalone(journal_dir));
27132        let status = handle_goal_status(&req(json!({"session_id": "chat-suggest"})), &restarted)
27133            .await
27134            .unwrap();
27135        assert!(status["goal"]["check"]
27136            .as_str()
27137            .unwrap()
27138            .ends_with(" && go test ./..."));
27139    }
27140
27141    #[tokio::test]
27142    async fn goal_suggest_detects_swift_package() {
27143        let tmp = tempfile::TempDir::new().unwrap();
27144        std::fs::write(
27145            tmp.path().join("Package.swift"),
27146            "// swift-tools-version: 6.0\nimport PackageDescription\n",
27147        )
27148        .unwrap();
27149        let state = Arc::new(ServerState::standalone(
27150            tmp.path().join(".car").join("journals"),
27151        ));
27152
27153        let got = handle_goal_suggest(
27154            &req(json!({
27155                "prompt": "make the package tests pass",
27156                "working_dir": tmp.path().to_string_lossy()
27157            })),
27158            &state,
27159        )
27160        .await
27161        .unwrap();
27162        assert_eq!(got["suggested"], true);
27163        assert_eq!(got["confidence"], "high");
27164        assert!(got["goal"]["check"]
27165            .as_str()
27166            .unwrap()
27167            .ends_with(" && swift test"));
27168        let signals = got["signals"].as_array().unwrap();
27169        assert!(signals.contains(&json!("Package.swift")));
27170    }
27171
27172    #[tokio::test]
27173    async fn goal_suggest_combines_created_swift_file_with_package_check() {
27174        let tmp = tempfile::TempDir::new().unwrap();
27175        std::fs::create_dir_all(tmp.path().join("Sources/App")).unwrap();
27176        std::fs::write(
27177            tmp.path().join("Package.swift"),
27178            "// swift-tools-version: 6.0\nimport PackageDescription\n",
27179        )
27180        .unwrap();
27181        let state = Arc::new(ServerState::standalone(
27182            tmp.path().join(".car").join("journals"),
27183        ));
27184
27185        let got = handle_goal_suggest(
27186            &req(json!({
27187                "prompt": "create Sources/App/Main.swift",
27188                "working_dir": tmp.path().to_string_lossy()
27189            })),
27190            &state,
27191        )
27192        .await
27193        .unwrap();
27194        assert_eq!(got["suggested"], true);
27195        let check = got["goal"]["check"].as_str().unwrap();
27196        assert!(
27197            check.contains("test -f 'Sources/App/Main.swift'"),
27198            "{check}"
27199        );
27200        assert!(check.ends_with(" && swift test"), "{check}");
27201    }
27202
27203    #[tokio::test]
27204    async fn goal_suggest_detects_cmake_project() {
27205        let tmp = tempfile::TempDir::new().unwrap();
27206        std::fs::write(
27207            tmp.path().join("CMakeLists.txt"),
27208            "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
27209        )
27210        .unwrap();
27211        let state = Arc::new(ServerState::standalone(
27212            tmp.path().join(".car").join("journals"),
27213        ));
27214
27215        let got = handle_goal_suggest(
27216            &req(json!({
27217                "prompt": "fix the C++ build",
27218                "working_dir": tmp.path().to_string_lossy()
27219            })),
27220            &state,
27221        )
27222        .await
27223        .unwrap();
27224        assert_eq!(got["suggested"], true);
27225        assert_eq!(got["confidence"], "high");
27226        assert!(got["goal"]["check"]
27227            .as_str()
27228            .unwrap()
27229            .ends_with(" && cmake -S . -B build && cmake --build build"));
27230        let signals = got["signals"].as_array().unwrap();
27231        assert!(signals.contains(&json!("CMakeLists.txt")));
27232    }
27233
27234    #[tokio::test]
27235    async fn goal_suggest_combines_created_cpp_file_with_cmake_check() {
27236        let tmp = tempfile::TempDir::new().unwrap();
27237        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
27238        std::fs::write(
27239            tmp.path().join("CMakeLists.txt"),
27240            "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
27241        )
27242        .unwrap();
27243        let state = Arc::new(ServerState::standalone(
27244            tmp.path().join(".car").join("journals"),
27245        ));
27246
27247        let got = handle_goal_suggest(
27248            &req(json!({
27249                "prompt": "create src/widget.cpp",
27250                "working_dir": tmp.path().to_string_lossy()
27251            })),
27252            &state,
27253        )
27254        .await
27255        .unwrap();
27256        assert_eq!(got["suggested"], true);
27257        assert_eq!(got["confidence"], "high");
27258        let check = got["goal"]["check"].as_str().unwrap();
27259        assert!(check.contains("test -f 'src/widget.cpp'"), "{check}");
27260        assert!(
27261            check.ends_with(" && cmake -S . -B build && cmake --build build"),
27262            "{check}"
27263        );
27264        let signals = got["signals"].as_array().unwrap();
27265        assert!(signals.contains(&json!("prompt_mentions_path:src/widget.cpp")));
27266        assert!(signals.contains(&json!("CMakeLists.txt")));
27267    }
27268
27269    #[tokio::test]
27270    async fn goal_suggest_uses_ctest_for_cmake_test_prompts() {
27271        let tmp = tempfile::TempDir::new().unwrap();
27272        std::fs::write(
27273            tmp.path().join("CMakeLists.txt"),
27274            "cmake_minimum_required(VERSION 3.20)\nproject(demo LANGUAGES CXX)\n",
27275        )
27276        .unwrap();
27277        let state = Arc::new(ServerState::standalone(
27278            tmp.path().join(".car").join("journals"),
27279        ));
27280
27281        let got = handle_goal_suggest(
27282            &req(json!({
27283                "prompt": "make the cmake unit tests pass",
27284                "working_dir": tmp.path().to_string_lossy()
27285            })),
27286            &state,
27287        )
27288        .await
27289        .unwrap();
27290        assert_eq!(got["suggested"], true);
27291        let check = got["goal"]["check"].as_str().unwrap();
27292        assert!(check.ends_with(
27293            " && cmake -S . -B build && cmake --build build && ctest --test-dir build --output-on-failure"
27294        ), "{check}");
27295    }
27296
27297    #[tokio::test]
27298    async fn goal_suggest_detects_gradle_project() {
27299        let tmp = tempfile::TempDir::new().unwrap();
27300        std::fs::write(
27301            tmp.path().join("build.gradle.kts"),
27302            "plugins { kotlin(\"jvm\") }\n",
27303        )
27304        .unwrap();
27305        std::fs::write(tmp.path().join("gradlew"), "#!/bin/sh\n").unwrap();
27306        let state = Arc::new(ServerState::standalone(
27307            tmp.path().join(".car").join("journals"),
27308        ));
27309
27310        let got = handle_goal_suggest(
27311            &req(json!({
27312                "prompt": "make the kotlin tests pass",
27313                "working_dir": tmp.path().to_string_lossy()
27314            })),
27315            &state,
27316        )
27317        .await
27318        .unwrap();
27319        assert_eq!(got["suggested"], true);
27320        assert_eq!(got["confidence"], "high");
27321        assert!(got["goal"]["check"]
27322            .as_str()
27323            .unwrap()
27324            .ends_with(" && ./gradlew test"));
27325        let signals = got["signals"].as_array().unwrap();
27326        assert!(signals.contains(&json!("build.gradle.kts")));
27327        assert!(signals.contains(&json!("gradlew")));
27328    }
27329
27330    #[tokio::test]
27331    async fn goal_suggest_combines_created_kotlin_file_with_gradle_check() {
27332        let tmp = tempfile::TempDir::new().unwrap();
27333        std::fs::create_dir_all(tmp.path().join("app/src/main/java/com/demo")).unwrap();
27334        std::fs::write(tmp.path().join("build.gradle"), "plugins { id 'java' }\n").unwrap();
27335        let state = Arc::new(ServerState::standalone(
27336            tmp.path().join(".car").join("journals"),
27337        ));
27338
27339        let got = handle_goal_suggest(
27340            &req(json!({
27341                "prompt": "create app/src/main/java/com/demo/Main.kt",
27342                "working_dir": tmp.path().to_string_lossy()
27343            })),
27344            &state,
27345        )
27346        .await
27347        .unwrap();
27348        assert_eq!(got["suggested"], true);
27349        assert_eq!(got["confidence"], "high");
27350        let check = got["goal"]["check"].as_str().unwrap();
27351        assert!(
27352            check.contains("test -f 'app/src/main/java/com/demo/Main.kt'"),
27353            "{check}"
27354        );
27355        assert!(check.ends_with(" && gradle test"), "{check}");
27356    }
27357
27358    #[tokio::test]
27359    async fn goal_suggest_detects_maven_project() {
27360        let tmp = tempfile::TempDir::new().unwrap();
27361        std::fs::write(
27362            tmp.path().join("pom.xml"),
27363            "<project><modelVersion>4.0.0</modelVersion></project>\n",
27364        )
27365        .unwrap();
27366        std::fs::write(tmp.path().join("mvnw"), "#!/bin/sh\n").unwrap();
27367        let state = Arc::new(ServerState::standalone(
27368            tmp.path().join(".car").join("journals"),
27369        ));
27370
27371        let got = handle_goal_suggest(
27372            &req(json!({
27373                "prompt": "make the java tests pass",
27374                "working_dir": tmp.path().to_string_lossy()
27375            })),
27376            &state,
27377        )
27378        .await
27379        .unwrap();
27380        assert_eq!(got["suggested"], true);
27381        assert_eq!(got["confidence"], "high");
27382        assert!(got["goal"]["check"]
27383            .as_str()
27384            .unwrap()
27385            .ends_with(" && ./mvnw test"));
27386        let signals = got["signals"].as_array().unwrap();
27387        assert!(signals.contains(&json!("pom.xml")));
27388        assert!(signals.contains(&json!("mvnw")));
27389    }
27390
27391    #[tokio::test]
27392    async fn goal_suggest_combines_created_java_file_with_maven_check() {
27393        let tmp = tempfile::TempDir::new().unwrap();
27394        std::fs::create_dir_all(tmp.path().join("src/main/java/com/demo")).unwrap();
27395        std::fs::write(
27396            tmp.path().join("pom.xml"),
27397            "<project><modelVersion>4.0.0</modelVersion></project>\n",
27398        )
27399        .unwrap();
27400        let state = Arc::new(ServerState::standalone(
27401            tmp.path().join(".car").join("journals"),
27402        ));
27403
27404        let got = handle_goal_suggest(
27405            &req(json!({
27406                "prompt": "create src/main/java/com/demo/Main.java",
27407                "working_dir": tmp.path().to_string_lossy()
27408            })),
27409            &state,
27410        )
27411        .await
27412        .unwrap();
27413        assert_eq!(got["suggested"], true);
27414        assert_eq!(got["confidence"], "high");
27415        let check = got["goal"]["check"].as_str().unwrap();
27416        assert!(
27417            check.contains("test -f 'src/main/java/com/demo/Main.java'"),
27418            "{check}"
27419        );
27420        assert!(check.ends_with(" && mvn test"), "{check}");
27421    }
27422
27423    #[tokio::test]
27424    async fn goal_suggest_detects_dotnet_solution() {
27425        let tmp = tempfile::TempDir::new().unwrap();
27426        std::fs::write(tmp.path().join("Demo.sln"), "").unwrap();
27427        let state = Arc::new(ServerState::standalone(
27428            tmp.path().join(".car").join("journals"),
27429        ));
27430
27431        let got = handle_goal_suggest(
27432            &req(json!({
27433                "prompt": "make the csharp tests pass",
27434                "working_dir": tmp.path().to_string_lossy()
27435            })),
27436            &state,
27437        )
27438        .await
27439        .unwrap();
27440        assert_eq!(got["suggested"], true);
27441        assert_eq!(got["confidence"], "high");
27442        assert!(got["goal"]["check"]
27443            .as_str()
27444            .unwrap()
27445            .ends_with(" && dotnet test"));
27446        let signals = got["signals"].as_array().unwrap();
27447        assert!(signals.contains(&json!("Demo.sln")));
27448    }
27449
27450    #[tokio::test]
27451    async fn goal_suggest_combines_created_csharp_file_with_dotnet_check() {
27452        let tmp = tempfile::TempDir::new().unwrap();
27453        std::fs::create_dir_all(tmp.path().join("src/Demo")).unwrap();
27454        std::fs::write(
27455            tmp.path().join("src").join("Demo").join("Demo.csproj"),
27456            "<Project Sdk=\"Microsoft.NET.Sdk\" />\n",
27457        )
27458        .unwrap();
27459        let state = Arc::new(ServerState::standalone(
27460            tmp.path().join(".car").join("journals"),
27461        ));
27462
27463        let got = handle_goal_suggest(
27464            &req(json!({
27465                "prompt": "create Program.cs",
27466                "working_dir": tmp.path().join("src").join("Demo").to_string_lossy()
27467            })),
27468            &state,
27469        )
27470        .await
27471        .unwrap();
27472        assert_eq!(got["suggested"], true);
27473        assert_eq!(got["confidence"], "high");
27474        let check = got["goal"]["check"].as_str().unwrap();
27475        assert!(check.contains("test -f 'Program.cs'"), "{check}");
27476        assert!(check.ends_with(" && dotnet test"), "{check}");
27477    }
27478
27479    #[tokio::test]
27480    async fn goal_suggest_detects_php_composer_project() {
27481        let tmp = tempfile::TempDir::new().unwrap();
27482        std::fs::create_dir_all(tmp.path().join("vendor/bin")).unwrap();
27483        std::fs::write(tmp.path().join("composer.json"), r#"{"require-dev":{}}"#).unwrap();
27484        std::fs::write(
27485            tmp.path().join("vendor/bin/phpunit"),
27486            "#!/usr/bin/env php\n",
27487        )
27488        .unwrap();
27489        let state = Arc::new(ServerState::standalone(
27490            tmp.path().join(".car").join("journals"),
27491        ));
27492
27493        let got = handle_goal_suggest(
27494            &req(json!({
27495                "prompt": "make the php tests pass",
27496                "working_dir": tmp.path().to_string_lossy()
27497            })),
27498            &state,
27499        )
27500        .await
27501        .unwrap();
27502        assert_eq!(got["suggested"], true);
27503        assert_eq!(got["confidence"], "high");
27504        assert!(got["goal"]["check"]
27505            .as_str()
27506            .unwrap()
27507            .ends_with(" && vendor/bin/phpunit"));
27508        let signals = got["signals"].as_array().unwrap();
27509        assert!(signals.contains(&json!("composer.json")));
27510        assert!(signals.contains(&json!("phpunit")));
27511    }
27512
27513    #[tokio::test]
27514    async fn goal_suggest_combines_created_php_file_with_composer_script() {
27515        let tmp = tempfile::TempDir::new().unwrap();
27516        std::fs::create_dir_all(tmp.path().join("src")).unwrap();
27517        std::fs::write(
27518            tmp.path().join("composer.json"),
27519            r#"{"scripts":{"test":"phpunit"}}"#,
27520        )
27521        .unwrap();
27522        let state = Arc::new(ServerState::standalone(
27523            tmp.path().join(".car").join("journals"),
27524        ));
27525
27526        let got = handle_goal_suggest(
27527            &req(json!({
27528                "prompt": "create src/App.php",
27529                "working_dir": tmp.path().to_string_lossy()
27530            })),
27531            &state,
27532        )
27533        .await
27534        .unwrap();
27535        assert_eq!(got["suggested"], true);
27536        assert_eq!(got["confidence"], "high");
27537        let check = got["goal"]["check"].as_str().unwrap();
27538        assert!(check.contains("test -f 'src/App.php'"), "{check}");
27539        assert!(check.ends_with(" && composer test"), "{check}");
27540        let signals = got["signals"].as_array().unwrap();
27541        assert!(signals.contains(&json!("composer_script:test")));
27542    }
27543
27544    #[tokio::test]
27545    async fn goal_suggest_uses_php_lint_when_composer_has_no_test_signal() {
27546        let tmp = tempfile::TempDir::new().unwrap();
27547        std::fs::write(tmp.path().join("composer.json"), r#"{"scripts":{}}"#).unwrap();
27548        let state = Arc::new(ServerState::standalone(
27549            tmp.path().join(".car").join("journals"),
27550        ));
27551
27552        let got = handle_goal_suggest(
27553            &req(json!({
27554                "prompt": "make the php project valid",
27555                "working_dir": tmp.path().to_string_lossy()
27556            })),
27557            &state,
27558        )
27559        .await
27560        .unwrap();
27561        assert_eq!(got["suggested"], true);
27562        assert_eq!(got["confidence"], "medium");
27563        let check = got["goal"]["check"].as_str().unwrap();
27564        assert!(
27565            check.ends_with(
27566                " && find . -name '*.php' -not -path './vendor/*' -print0 | xargs -0 -n1 php -l"
27567            ),
27568            "{check}"
27569        );
27570        let signals = got["signals"].as_array().unwrap();
27571        assert!(!signals.contains(&json!("composer_script:test")));
27572    }
27573
27574    #[tokio::test]
27575    async fn goal_suggest_detects_ruby_bundler_project() {
27576        let tmp = tempfile::TempDir::new().unwrap();
27577        std::fs::write(
27578            tmp.path().join("Gemfile"),
27579            "source 'https://rubygems.org'\n",
27580        )
27581        .unwrap();
27582        std::fs::write(tmp.path().join("Rakefile"), "task :test\n").unwrap();
27583        let state = Arc::new(ServerState::standalone(
27584            tmp.path().join(".car").join("journals"),
27585        ));
27586
27587        let got = handle_goal_suggest(
27588            &req(json!({
27589                "prompt": "make the ruby tests pass",
27590                "working_dir": tmp.path().to_string_lossy()
27591            })),
27592            &state,
27593        )
27594        .await
27595        .unwrap();
27596        assert_eq!(got["suggested"], true);
27597        assert_eq!(got["confidence"], "high");
27598        assert!(got["goal"]["check"]
27599            .as_str()
27600            .unwrap()
27601            .ends_with(" && bundle exec rake test"));
27602        let signals = got["signals"].as_array().unwrap();
27603        assert!(signals.contains(&json!("Gemfile")));
27604        assert!(signals.contains(&json!("Rakefile")));
27605    }
27606
27607    #[tokio::test]
27608    async fn goal_suggest_uses_ruby_syntax_check_without_test_runner_signal() {
27609        let tmp = tempfile::TempDir::new().unwrap();
27610        std::fs::write(
27611            tmp.path().join("Gemfile"),
27612            "source 'https://rubygems.org'\n",
27613        )
27614        .unwrap();
27615        let state = Arc::new(ServerState::standalone(
27616            tmp.path().join(".car").join("journals"),
27617        ));
27618
27619        let got = handle_goal_suggest(
27620            &req(json!({
27621                "prompt": "make the ruby project valid",
27622                "working_dir": tmp.path().to_string_lossy()
27623            })),
27624            &state,
27625        )
27626        .await
27627        .unwrap();
27628        assert_eq!(got["suggested"], true);
27629        assert_eq!(got["confidence"], "medium");
27630        let check = got["goal"]["check"].as_str().unwrap();
27631        assert!(
27632            check.ends_with(
27633                " && find . -name '*.rb' -not -path './vendor/*' -print0 | xargs -0 -n1 ruby -c"
27634            ),
27635            "{check}"
27636        );
27637        let signals = got["signals"].as_array().unwrap();
27638        assert!(signals.contains(&json!("Gemfile")));
27639        assert!(!signals.contains(&json!("Rakefile")));
27640    }
27641
27642    #[tokio::test]
27643    async fn goal_suggest_combines_created_elixir_file_with_mix_check() {
27644        let tmp = tempfile::TempDir::new().unwrap();
27645        std::fs::create_dir_all(tmp.path().join("lib/demo")).unwrap();
27646        std::fs::write(
27647            tmp.path().join("mix.exs"),
27648            "defmodule Demo.MixProject do\nend\n",
27649        )
27650        .unwrap();
27651        let state = Arc::new(ServerState::standalone(
27652            tmp.path().join(".car").join("journals"),
27653        ));
27654
27655        let got = handle_goal_suggest(
27656            &req(json!({
27657                "prompt": "create lib/demo/worker.ex",
27658                "working_dir": tmp.path().to_string_lossy()
27659            })),
27660            &state,
27661        )
27662        .await
27663        .unwrap();
27664        assert_eq!(got["suggested"], true);
27665        assert_eq!(got["confidence"], "high");
27666        let check = got["goal"]["check"].as_str().unwrap();
27667        assert!(check.contains("test -f 'lib/demo/worker.ex'"), "{check}");
27668        assert!(check.ends_with(" && mix test"), "{check}");
27669    }
27670
27671    #[tokio::test]
27672    async fn goal_suggest_falls_through_when_package_has_no_scripts() {
27673        let tmp = tempfile::TempDir::new().unwrap();
27674        std::fs::write(tmp.path().join("package.json"), r#"{"scripts":{}}"#).unwrap();
27675        std::fs::write(tmp.path().join("go.mod"), "module demo\n").unwrap();
27676        let state = Arc::new(ServerState::standalone(
27677            tmp.path().join(".car").join("journals"),
27678        ));
27679
27680        let got = handle_goal_suggest(
27681            &req(json!({
27682                "prompt": "make it work",
27683                "working_dir": tmp.path().to_string_lossy()
27684            })),
27685            &state,
27686        )
27687        .await
27688        .unwrap();
27689        assert_eq!(got["suggested"], true);
27690        assert!(got["goal"]["check"]
27691            .as_str()
27692            .unwrap()
27693            .ends_with(" && go test ./..."));
27694        let warnings = got["warnings"].as_array().unwrap();
27695        assert!(warnings.contains(&json!(
27696            "package.json has no test/typecheck/check/build/lint script"
27697        )));
27698    }
27699
27700    #[tokio::test]
27701    async fn goal_suggest_refuses_to_invent_a_weak_check() {
27702        let tmp = tempfile::TempDir::new().unwrap();
27703        let state = Arc::new(ServerState::standalone(
27704            tmp.path().join(".car").join("journals"),
27705        ));
27706        let got = handle_goal_suggest(
27707            &req(json!({
27708                "prompt": "make it better",
27709                "working_dir": tmp.path().to_string_lossy()
27710            })),
27711            &state,
27712        )
27713        .await
27714        .unwrap();
27715        assert_eq!(got["suggested"], false);
27716        assert!(got["goal"].is_null());
27717    }
27718}
27719
27720#[cfg(test)]
27721mod metrics_alert_surface {
27722    use super::{handle_metrics_alerts, JsonRpcMessage};
27723    use crate::session::{ServerState, WsChannel};
27724    use car_verify::goal::GoalCondition;
27725    use serde_json::{json, Value};
27726    use std::sync::Arc;
27727
27728    fn req(params: Value) -> JsonRpcMessage {
27729        JsonRpcMessage {
27730            jsonrpc: "2.0".into(),
27731            method: None,
27732            params,
27733            id: json!(1),
27734            result: None,
27735            error: None,
27736        }
27737    }
27738
27739    #[tokio::test]
27740    async fn metrics_alerts_returns_goal_ungrounded() {
27741        let tmp = tempfile::TempDir::new().unwrap();
27742        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27743        let session = state
27744            .create_session("metrics-alerts", Arc::new(WsChannel::test_stub()))
27745            .await
27746            .unwrap();
27747        let condition = GoalCondition::Command {
27748            id: "tests".to_string(),
27749            expect_exit: 0,
27750        };
27751        session
27752            .runtime
27753            .record_goal_evaluated(
27754                "make tests pass",
27755                &condition,
27756                1,
27757                true,
27758                false,
27759                "ungrounded assistant summary claim(s): tests were run/passed",
27760                "mlx/qwen3-8b:4bit",
27761            )
27762            .await;
27763
27764        let got = handle_metrics_alerts(&req(json!({"max_goals_ungrounded": 0})), &session)
27765            .await
27766            .unwrap();
27767        assert_eq!(got["summary"]["goals_ungrounded"], json!(1));
27768        assert_eq!(got["alerts"][0]["kind"], json!("goal_ungrounded"));
27769        assert_eq!(got["alerts"][0]["observed"], json!(1.0));
27770        assert_eq!(got["alerts"][0]["threshold"], json!(0.0));
27771    }
27772}
27773
27774#[cfg(test)]
27775mod tool_registry_surface {
27776    //! `tools.list` / `tools.unregister` (Parslee-ai/car#892) — the enumerate
27777    //! and removal halves that `tools.register` shipped without, mirroring
27778    //! `policy.list` / `policy.unregister` (Parslee-ai/car#623).
27779    //!
27780    //! Each WS connection gets its own `car_engine::Runtime`, so the listed set
27781    //! is a per-session fact and can be asserted literally rather than
27782    //! approximately.
27783    //!
27784    //! A fresh daemon session is **not** empty, which is worth pinning here
27785    //! because it is the sort of thing this surface exists to reveal:
27786    //! `create_session` builds its runtime with `.with_message_sink(...)`, and
27787    //! `Runtime::with_message_sink` registers the `messaging.send` built-in
27788    //! into both the registry and the legacy schema map — sink and schema
27789    //! arrive together by design, so a tool the runtime cannot execute is never
27790    //! advertised. The commodity stdlib is absent until the session asks for
27791    //! it — both `session.bindSubstrate` and `session.bindSandbox` register
27792    //! it, and neither runs here. So the baseline is exactly
27793    //! `["messaging.send"]`, and the assertions below are exact vectors over
27794    //! that baseline plus whatever the test registered.
27795    use super::{
27796        handle_tools_list, handle_tools_register, handle_tools_unregister, JsonRpcMessage,
27797    };
27798    use crate::session::{ServerState, WsChannel};
27799    use serde_json::{json, Value};
27800    use std::sync::Arc;
27801
27802    fn req(params: Value) -> JsonRpcMessage {
27803        JsonRpcMessage {
27804            jsonrpc: "2.0".into(),
27805            method: None,
27806            params,
27807            id: json!(1),
27808            result: None,
27809            error: None,
27810        }
27811    }
27812
27813    async fn session(client_id: &str) -> Arc<crate::session::ClientSession> {
27814        let tmp = tempfile::TempDir::new().unwrap();
27815        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
27816        state
27817            .create_session(client_id, Arc::new(WsChannel::test_stub()))
27818            .await
27819            .unwrap()
27820    }
27821
27822    /// Names in `tools.list` order, so assertions can be literal vectors.
27823    fn names(listed: &Value) -> Vec<String> {
27824        listed["tools"]
27825            .as_array()
27826            .expect("tools must be an array")
27827            .iter()
27828            .map(|t| {
27829                t["name"]
27830                    .as_str()
27831                    .expect("name must be a string")
27832                    .to_string()
27833            })
27834            .collect()
27835    }
27836
27837    /// The session baseline: a connection that registered nothing still has the
27838    /// daemon's `messaging.send` built-in, and `tools.list` says so. Asserting
27839    /// the exact vector (not just a count) is the point — a client auditing its
27840    /// own session needs to see the tool it never registered.
27841    #[tokio::test]
27842    async fn fresh_session_lists_only_the_built_in_messaging_send() {
27843        let session = session("c-tools-list-empty").await;
27844        let listed = handle_tools_list(&session).await.unwrap();
27845        assert_eq!(listed["count"], json!(1));
27846        assert_eq!(names(&listed), vec!["messaging.send"]);
27847        assert_eq!(listed["tools"][0]["source"], json!("builtin"));
27848    }
27849
27850    /// The goal's verifiable target: after registering exactly two tools, the
27851    /// wire surface reports exactly those two on top of the session baseline,
27852    /// in sorted order — which is what makes it usable as proof of a governed
27853    /// session's effective toolset.
27854    #[tokio::test]
27855    async fn list_returns_exactly_the_two_registered_tools_over_the_baseline_in_sorted_order() {
27856        let session = session("c-tools-list-two").await;
27857
27858        // Registered out of alphabetical order on purpose: the sort is the
27859        // contract, not an accident of insertion order. `messaging.send` was
27860        // registered first of all (at runtime construction) and still sorts
27861        // first here, and `write_file`/`read_file` come back swapped relative
27862        // to how they went in — so the ordering below cannot be insertion order.
27863        let registered = handle_tools_register(
27864            &req(json!([
27865                { "name": "write_file" },
27866                { "name": "read_file" },
27867            ])),
27868            &session,
27869        )
27870        .await
27871        .unwrap();
27872        assert_eq!(registered, json!(2));
27873
27874        let listed = handle_tools_list(&session).await.unwrap();
27875        assert_eq!(listed["count"], json!(3));
27876        assert_eq!(
27877            names(&listed),
27878            vec!["messaging.send", "read_file", "write_file"]
27879        );
27880    }
27881
27882    #[tokio::test]
27883    async fn register_rejects_a_server_owned_tool_name_without_changing_its_source() {
27884        let session = session("c-tools-register-shadow").await;
27885        let error = handle_tools_register(
27886            &req(json!([{ "name": "messaging.send", "description": "shadow" }])),
27887            &session,
27888        )
27889        .await
27890        .expect_err("callback registration must not shadow a built-in");
27891        assert_eq!(
27892            error,
27893            "tool 'messaging.send' is server-owned and cannot be shadowed by tools.register"
27894        );
27895
27896        let listed = handle_tools_list(&session).await.unwrap();
27897        assert_eq!(listed["count"], json!(1));
27898        assert_eq!(names(&listed), vec!["messaging.send"]);
27899        assert_eq!(listed["tools"][0]["source"], json!("builtin"));
27900    }
27901
27902    #[tokio::test]
27903    async fn unregister_drops_one_tool_and_list_reports_the_remainder() {
27904        let session = session("c-tools-unregister").await;
27905        handle_tools_register(
27906            &req(json!([{ "name": "read_file" }, { "name": "write_file" }])),
27907            &session,
27908        )
27909        .await
27910        .unwrap();
27911
27912        let dropped = handle_tools_unregister(&req(json!({ "name": "write_file" })), &session)
27913            .await
27914            .unwrap();
27915        assert_eq!(dropped["unregistered"], json!("write_file"));
27916        assert_eq!(dropped["removed"], json!(1));
27917
27918        let listed = handle_tools_list(&session).await.unwrap();
27919        assert_eq!(listed["count"], json!(2));
27920        assert_eq!(names(&listed), vec!["messaging.send", "read_file"]);
27921    }
27922
27923    /// Unknown tool → `removed: 0` and `Ok`, never `Err`, so cleanup can call
27924    /// this unconditionally without listing first (`policy.unregister`'s
27925    /// contract). Missing `name` is still an error, house style.
27926    #[tokio::test]
27927    async fn unregistering_an_unknown_tool_reports_zero_rather_than_erroring() {
27928        let session = session("c-tools-unregister-unknown").await;
27929
27930        let dropped =
27931            handle_tools_unregister(&req(json!({ "name": "never_registered" })), &session)
27932                .await
27933                .expect("unknown tool must not be an error");
27934        assert_eq!(dropped["unregistered"], json!("never_registered"));
27935        assert_eq!(dropped["removed"], json!(0));
27936
27937        assert_eq!(
27938            handle_tools_unregister(&req(json!({})), &session)
27939                .await
27940                .unwrap_err(),
27941            "missing 'name'"
27942        );
27943    }
27944
27945    /// The surface proves the *effective* toolset, not a name list: the full
27946    /// schema a caller registered comes back, parameters included.
27947    #[tokio::test]
27948    async fn list_round_trips_the_full_tool_schema() {
27949        let session = session("c-tools-list-schema").await;
27950        handle_tools_register(
27951            &req(json!([{
27952                "name": "read_file",
27953                "description": "Read a UTF-8 file from disk",
27954                "parameters": {
27955                    "type": "object",
27956                    "properties": { "path": { "type": "string" } },
27957                    "required": ["path"],
27958                },
27959                "idempotent": true,
27960            }])),
27961            &session,
27962        )
27963        .await
27964        .unwrap();
27965
27966        let listed = handle_tools_list(&session).await.unwrap();
27967        let tool = listed["tools"]
27968            .as_array()
27969            .unwrap()
27970            .iter()
27971            .find(|t| t["name"] == json!("read_file"))
27972            .expect("read_file must be listed");
27973        assert_eq!(tool["name"], json!("read_file"));
27974        assert_eq!(tool["source"], json!("user_defined"));
27975        assert_eq!(tool["description"], json!("Read a UTF-8 file from disk"));
27976        assert_eq!(tool["idempotent"], json!(true));
27977        assert_eq!(
27978            tool["parameters"],
27979            json!({
27980                "type": "object",
27981                "properties": { "path": { "type": "string" } },
27982                "required": ["path"],
27983            })
27984        );
27985    }
27986
27987    /// `ToolDefinition` deliberately omits `source` — the runtime assigns
27988    /// `user_defined` server-side rather than trusting the caller. This pins
27989    /// that security boundary end-to-end: a registration payload carrying a
27990    /// spoofed `"source": "builtin"` must parse (serde ignores the unknown
27991    /// field) but the stored/listed schema still reports `user_defined`.
27992    #[tokio::test]
27993    async fn registered_tool_cannot_claim_a_source_it_did_not_earn() {
27994        let session = session("c-tools-spoofed-source").await;
27995        handle_tools_register(
27996            &req(json!([{
27997                "name": "read_file",
27998                "description": "Claims to be a builtin",
27999                "source": "builtin",
28000                "parameters": {},
28001            }])),
28002            &session,
28003        )
28004        .await
28005        .expect("an unknown field must not fail registration");
28006
28007        let listed = handle_tools_list(&session).await.unwrap();
28008        let tool = listed["tools"]
28009            .as_array()
28010            .unwrap()
28011            .iter()
28012            .find(|t| t["name"] == json!("read_file"))
28013            .expect("read_file must be listed");
28014        assert_eq!(
28015            tool["source"],
28016            json!("user_defined"),
28017            "the runtime assigns user_defined; a client cannot claim builtin"
28018        );
28019    }
28020}
28021
28022#[cfg(test)]
28023mod tool_stream_surface {
28024    //! C2 WS surface: `tools.poll` drains a detached handle's chunks and
28025    //! reports status, `tools.cancel` seals it. An unknown handle polls to
28026    //! `null` (absence, not an error) and cancels to `{cancelled: false}`.
28027    use super::{handle_tools_cancel, handle_tools_poll, JsonRpcMessage};
28028    use crate::session::{ServerState, WsChannel};
28029    use serde_json::{json, Value};
28030    use std::sync::Arc;
28031
28032    fn req(params: Value) -> JsonRpcMessage {
28033        JsonRpcMessage {
28034            jsonrpc: "2.0".into(),
28035            method: None,
28036            params,
28037            id: json!(1),
28038            result: None,
28039            error: None,
28040        }
28041    }
28042
28043    #[tokio::test]
28044    async fn poll_and_cancel_roundtrip() {
28045        let tmp = tempfile::TempDir::new().unwrap();
28046        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28047        let channel = Arc::new(WsChannel::test_stub());
28048        let session = state.create_session("c-tools-c2", channel).await.unwrap();
28049
28050        // Unknown handle → null, not an error.
28051        let poll = handle_tools_poll(&req(json!({"handle": "nope"})), &session)
28052            .await
28053            .unwrap();
28054        assert!(poll.is_null(), "unknown handle must poll to null");
28055
28056        // Missing param → error (house style).
28057        assert!(handle_tools_poll(&req(json!({})), &session).await.is_err());
28058
28059        // Start a detached invocation on the session runtime's registry —
28060        // the same path a detached ToolCall dispatch takes.
28061        let (h, _tok) = session
28062            .runtime
28063            .tool_handles
28064            .register("tail_log", "a1")
28065            .await;
28066        session
28067            .runtime
28068            .tool_handles
28069            .push_chunk(
28070                &h.id,
28071                car_ir::ToolStreamChunk::Text {
28072                    text: "line".into(),
28073                },
28074            )
28075            .await;
28076
28077        let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
28078            .await
28079            .unwrap();
28080        assert_eq!(poll["status"], "running");
28081        assert_eq!(poll["chunks"][0]["kind"], "text");
28082        assert_eq!(poll["chunks"][0]["text"], "line");
28083        assert_eq!(poll["tool"], "tail_log");
28084
28085        // Cancel seals the invocation; a subsequent poll observes it.
28086        let cancel = handle_tools_cancel(&req(json!({"handle": h.id})), &session)
28087            .await
28088            .unwrap();
28089        assert_eq!(cancel["cancelled"], true);
28090        let poll = handle_tools_poll(&req(json!({"handle": h.id})), &session)
28091            .await
28092            .unwrap();
28093        assert_eq!(poll["status"], "cancelled");
28094
28095        // Unknown handle cancels to false.
28096        let cancel = handle_tools_cancel(&req(json!({"handle": "nope"})), &session)
28097            .await
28098            .unwrap();
28099        assert_eq!(cancel["cancelled"], false);
28100    }
28101}
28102
28103#[cfg(test)]
28104mod chat_cancel_surface {
28105    use super::{handle_agents_chat_cancel, remove_owned_external_chat_session, JsonRpcMessage};
28106    use crate::session::{ChatSession, ServerState};
28107    use serde_json::{json, Value};
28108    use std::sync::atomic::{AtomicBool, Ordering};
28109    use std::sync::Arc;
28110
28111    fn req(params: Value) -> JsonRpcMessage {
28112        JsonRpcMessage {
28113            jsonrpc: "2.0".into(),
28114            method: None,
28115            params,
28116            id: json!(1),
28117            result: None,
28118            error: None,
28119        }
28120    }
28121
28122    #[tokio::test]
28123    async fn cancel_sets_local_declarative_flag_and_drops_session() {
28124        let tmp = tempfile::TempDir::new().unwrap();
28125        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28126        let flag = Arc::new(AtomicBool::new(false));
28127        state.chat_sessions.lock().await.insert(
28128            "decl-chat".to_string(),
28129            ChatSession {
28130                agent_id: "writer".to_string(),
28131                host_client_id: "host-1".to_string(),
28132                created_at: 0,
28133                local_cancel: Some(flag.clone()),
28134            },
28135        );
28136
28137        let cancel = handle_agents_chat_cancel(
28138            &req(json!({"session_id": "decl-chat"})),
28139            &state,
28140            "host",
28141            true,
28142        )
28143        .await
28144        .unwrap();
28145        assert_eq!(cancel["cancelled"], true);
28146        assert!(flag.load(Ordering::SeqCst));
28147        assert!(!state.chat_sessions.lock().await.contains_key("decl-chat"));
28148
28149        let again = handle_agents_chat_cancel(
28150            &req(json!({"session_id": "decl-chat"})),
28151            &state,
28152            "host",
28153            true,
28154        )
28155        .await
28156        .unwrap();
28157        assert_eq!(again["cancelled"], false);
28158    }
28159
28160    #[tokio::test]
28161    async fn external_cleanup_preserves_proxied_chat_route() {
28162        let tmp = tempfile::TempDir::new().unwrap();
28163        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28164        let flag = Arc::new(AtomicBool::new(false));
28165        state.chat_sessions.lock().await.insert(
28166            "proxied-chat".to_string(),
28167            ChatSession {
28168                agent_id: "car-assistant".to_string(),
28169                host_client_id: "host-1".to_string(),
28170                created_at: 0,
28171                local_cancel: Some(flag),
28172            },
28173        );
28174
28175        remove_owned_external_chat_session(&state, "proxied-chat", false).await;
28176        assert!(state
28177            .chat_sessions
28178            .lock()
28179            .await
28180            .contains_key("proxied-chat"));
28181
28182        remove_owned_external_chat_session(&state, "proxied-chat", true).await;
28183        assert!(!state
28184            .chat_sessions
28185            .lock()
28186            .await
28187            .contains_key("proxied-chat"));
28188    }
28189}
28190
28191#[cfg(test)]
28192mod assistant_agent_alias_surface {
28193    //! car#1107: `agents.chat` must resolve either spelling of the flagship
28194    //! assistant's id — `parslee-core` (canonical since car#1107, and what
28195    //! mobile has always sent) or `car-assistant` (the pre-car#1107 id macOS
28196    //! still hardcodes) — to whichever one the daemon actually has attached.
28197    use super::resolve_assistant_agent_alias;
28198    use crate::session::ServerState;
28199    use std::sync::Arc;
28200
28201    fn state() -> Arc<ServerState> {
28202        let tmp = tempfile::TempDir::new().unwrap();
28203        Arc::new(ServerState::standalone(tmp.path().to_path_buf()))
28204    }
28205
28206    #[tokio::test]
28207    async fn legacy_id_resolves_to_canonical_when_only_canonical_attached() {
28208        let state = state();
28209        state
28210            .attached_agents
28211            .lock()
28212            .await
28213            .insert("parslee-core".to_string(), "client-1".to_string());
28214
28215        assert_eq!(
28216            resolve_assistant_agent_alias("car-assistant".to_string(), &state).await,
28217            "parslee-core"
28218        );
28219    }
28220
28221    #[tokio::test]
28222    async fn canonical_id_resolves_to_legacy_when_only_legacy_attached() {
28223        let state = state();
28224        state
28225            .attached_agents
28226            .lock()
28227            .await
28228            .insert("car-assistant".to_string(), "client-1".to_string());
28229
28230        assert_eq!(
28231            resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
28232            "car-assistant"
28233        );
28234    }
28235
28236    #[tokio::test]
28237    async fn exact_match_is_never_rewritten() {
28238        let state = state();
28239        state
28240            .attached_agents
28241            .lock()
28242            .await
28243            .insert("parslee-core".to_string(), "client-1".to_string());
28244
28245        assert_eq!(
28246            resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
28247            "parslee-core"
28248        );
28249    }
28250
28251    #[tokio::test]
28252    async fn non_assistant_id_is_left_untouched_even_if_unattached() {
28253        let state = state();
28254        assert_eq!(
28255            resolve_assistant_agent_alias("some-other-agent".to_string(), &state).await,
28256            "some-other-agent"
28257        );
28258    }
28259
28260    #[tokio::test]
28261    async fn assistant_id_is_left_untouched_when_neither_spelling_attached() {
28262        let state = state();
28263        assert_eq!(
28264            resolve_assistant_agent_alias("parslee-core".to_string(), &state).await,
28265            "parslee-core"
28266        );
28267    }
28268}
28269
28270#[cfg(test)]
28271mod chat_approve_surface {
28272    //! `agents.chat.approve` (#483): resolve an inline chat-turn approval by
28273    //! reverse-requesting the agent's `agent.chat.approve`. The full reply
28274    //! round-trip needs a live agent connection (exercised end-to-end by the
28275    //! `assistant::chat` tests + the CLI); here we lock down the param
28276    //! validation and session-routing preconditions the handler enforces before
28277    //! it ever touches an agent channel.
28278    use super::{handle_agents_chat_approve, JsonRpcMessage};
28279    use crate::session::{ChatSession, ServerState};
28280    use serde_json::{json, Value};
28281    use std::sync::Arc;
28282
28283    fn req(params: Value) -> JsonRpcMessage {
28284        JsonRpcMessage {
28285            jsonrpc: "2.0".into(),
28286            method: None,
28287            params,
28288            id: json!(1),
28289            result: None,
28290            error: None,
28291        }
28292    }
28293
28294    #[tokio::test]
28295    async fn requires_session_and_approval_ids() {
28296        let tmp = tempfile::TempDir::new().unwrap();
28297        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28298
28299        // Missing session_id.
28300        let err =
28301            handle_agents_chat_approve(&req(json!({"approval_id": "a1"})), &state, "host-1", false)
28302                .await
28303                .unwrap_err();
28304        assert!(err.contains("session_id"), "{err}");
28305
28306        // Missing approval_id.
28307        let err =
28308            handle_agents_chat_approve(&req(json!({"session_id": "s1"})), &state, "host-1", false)
28309                .await
28310                .unwrap_err();
28311        assert!(err.contains("approval_id"), "{err}");
28312    }
28313
28314    #[tokio::test]
28315    async fn unknown_session_is_rejected() {
28316        let tmp = tempfile::TempDir::new().unwrap();
28317        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28318        let err = handle_agents_chat_approve(
28319            &req(json!({"session_id": "ghost", "approval_id": "a1", "decision": true})),
28320            &state,
28321            "host-1",
28322            false,
28323        )
28324        .await
28325        .unwrap_err();
28326        assert!(err.contains("unknown or already-finished"), "{err}");
28327    }
28328
28329    /// car#1295 / car#1296. A session that authenticated AS an agent is
28330    /// authoritative about who it is, so a caller-supplied id naming a
28331    /// different agent is a forgery attempt, not a request to honor.
28332    #[test]
28333    fn a_bound_session_may_act_only_as_itself() {
28334        assert!(super::require_own_agent(Some("a"), "m", "a").is_ok());
28335        assert!(super::require_own_agent(Some("a"), "m", "b").is_err());
28336        // Compared exactly: an agent id is a filename-safe key the supervisor
28337        // stores verbatim, not a display name to be folded or trimmed.
28338        assert!(super::require_own_agent(Some("a"), "m", "A").is_err());
28339        assert!(super::require_own_agent(Some("a"), "m", "a ").is_err());
28340    }
28341
28342    /// An UNBOUND session passes this identity-only helper for operator reads
28343    /// and lease/sync calls. Mutating lifecycle handlers add host-aware gates.
28344    #[test]
28345    fn an_unbound_session_is_not_restricted_by_this_rule() {
28346        assert!(super::require_own_agent(None, "m", "a").is_ok());
28347        assert!(super::require_own_agent(None, "m", "anything-at-all").is_ok());
28348    }
28349
28350    /// The refusal names the id the CALLER supplied, which is theirs already,
28351    /// and is returned before any lookup — so unlike `authorize_run_access`'s
28352    /// FIX 3 it cannot answer "does that agent exist?".
28353    #[test]
28354    fn the_refusal_says_which_call_was_refused_without_revealing_anything() {
28355        let err = super::require_own_agent(Some("a"), "agents.wait", "b").unwrap_err();
28356        assert!(err.contains("agents.wait"), "{err}");
28357        assert!(err.contains("may only act on itself"), "{err}");
28358        assert!(!err.contains("  "), "collapsed continuation: {err:?}");
28359        assert!(super::require_own_agent(Some("a"), "agents.wait", "a").is_ok());
28360        assert!(super::require_own_agent(None, "agents.wait", "b").is_ok());
28361    }
28362
28363    #[tokio::test]
28364    async fn caller_must_be_originating_host_or_host_management() {
28365        let tmp = tempfile::TempDir::new().unwrap();
28366        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28367        state.chat_sessions.lock().await.insert(
28368            "s1".to_string(),
28369            ChatSession {
28370                agent_id: "car-assistant".to_string(),
28371                host_client_id: "host-1".to_string(),
28372                created_at: 0,
28373                local_cancel: None,
28374            },
28375        );
28376
28377        let err = handle_agents_chat_approve(
28378            &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
28379            &state,
28380            "agent-client",
28381            false,
28382        )
28383        .await
28384        .unwrap_err();
28385        assert!(
28386            err.contains("originating host session or host-management role"),
28387            "{err}"
28388        );
28389        assert!(state.chat_sessions.lock().await.contains_key("s1"));
28390    }
28391
28392    #[tokio::test]
28393    async fn known_session_but_detached_agent_is_rejected() {
28394        let tmp = tempfile::TempDir::new().unwrap();
28395        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28396        // Register a chat session whose agent never attached (`attached_agents`
28397        // has no entry), so routing must fail cleanly rather than hang.
28398        state.chat_sessions.lock().await.insert(
28399            "s1".to_string(),
28400            ChatSession {
28401                agent_id: "car-assistant".to_string(),
28402                host_client_id: "host-1".to_string(),
28403                created_at: 0,
28404                local_cancel: None,
28405            },
28406        );
28407        let err = handle_agents_chat_approve(
28408            &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
28409            &state,
28410            "host-1",
28411            false,
28412        )
28413        .await
28414        .unwrap_err();
28415        assert!(err.contains("not attached"), "{err}");
28416        // The session is NOT dropped by a failed approve (unlike cancel).
28417        assert!(state.chat_sessions.lock().await.contains_key("s1"));
28418    }
28419
28420    #[tokio::test]
28421    async fn host_management_may_resolve_any_chat_approval() {
28422        let tmp = tempfile::TempDir::new().unwrap();
28423        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28424        state.chat_sessions.lock().await.insert(
28425            "s1".to_string(),
28426            ChatSession {
28427                agent_id: "car-assistant".to_string(),
28428                host_client_id: "host-1".to_string(),
28429                created_at: 0,
28430                local_cancel: None,
28431            },
28432        );
28433
28434        let err = handle_agents_chat_approve(
28435            &req(json!({"session_id": "s1", "approval_id": "a1", "decision": true})),
28436            &state,
28437            "host-admin",
28438            true,
28439        )
28440        .await
28441        .unwrap_err();
28442        assert!(
28443            err.contains("not attached"),
28444            "host-management should pass authorization and fail only at routing: {err}"
28445        );
28446    }
28447}
28448
28449#[cfg(test)]
28450mod sync_lease_surface {
28451    //! The `sync.*` / `lease.*` daemon dispatch surface (B6). Drives the
28452    //! handlers directly against a standalone `ServerState` (rooted at a tmp dir,
28453    //! so the sync subsystem opens under `<tmp>/sync/`), proving the WS wiring,
28454    //! transcript resume, the dispatch fence, and cross-call lease visibility on
28455    //! the one daemon-held subsystem. The two-device convergence + fencing
28456    //! mechanics themselves are unit-tested in `crate::sync`.
28457    use super::{
28458        acquire_lease, check_sync_fence, handle_lease_status, handle_sync_append,
28459        handle_sync_record_turn, handle_sync_resume, handle_sync_status, record_sync_intent,
28460        release_lease, JsonRpcMessage,
28461    };
28462    use crate::session::ServerState;
28463    use serde_json::{json, Value};
28464    use std::sync::Arc;
28465
28466    fn req(params: Value) -> JsonRpcMessage {
28467        JsonRpcMessage {
28468            jsonrpc: "2.0".into(),
28469            method: None,
28470            params,
28471            id: json!(1),
28472            result: None,
28473            error: None,
28474        }
28475    }
28476
28477    #[tokio::test]
28478    async fn sync_and_lease_dispatch_roundtrip() {
28479        let tmp = tempfile::TempDir::new().unwrap();
28480        let state = Arc::new(ServerState::standalone(tmp.path().to_path_buf()));
28481
28482        // sync.status lazily opens the subsystem and reports a real device id.
28483        let status = handle_sync_status(&state).await.unwrap();
28484        assert!(status["device_id"].as_str().unwrap().starts_with("device-"));
28485
28486        // Record a knowledge op + a conversation turn through the oplog.
28487        handle_sync_append(
28488            &req(json!({"surface": "knowledge", "payload": {"id": "f1", "body": "sky"}})),
28489            &state,
28490        )
28491        .await
28492        .unwrap();
28493        handle_sync_record_turn(
28494            &req(json!({"conversation_id": "c1", "role": "user", "content": "hi", "timestamp": 1})),
28495            &state,
28496        )
28497        .await
28498        .unwrap();
28499
28500        // sync.resume is real: the turn comes back as a provider-valid message.
28501        let resume = handle_sync_resume(&req(json!({"conversation_id": "c1"})), &state)
28502            .await
28503            .unwrap();
28504        let msgs = resume.as_array().unwrap();
28505        assert_eq!(msgs.len(), 1);
28506        assert_eq!(msgs[0]["role"], json!("user"));
28507
28508        // lease.acquire → status reflects the same daemon-held holder. These
28509        // operation cores are called after the production handler's session
28510        // authorization; the real WS boundary is covered by
28511        // `bound_agent_identity_ws`.
28512        let lease = acquire_lease(
28513            &req(json!({"agent_id": "milo", "ttl_ms": 1_000_000})),
28514            &state,
28515            "milo",
28516        )
28517        .await
28518        .unwrap();
28519        assert_eq!(lease["epoch"], json!(1));
28520        let ls = handle_lease_status(&req(json!({"agent_id": "milo"})), &state, Some("milo"))
28521            .await
28522            .unwrap();
28523        assert_eq!(ls["lease"]["holder"], lease["holder"]);
28524
28525        // The holder may dispatch run r1 (no prior commit).
28526        let fence = check_sync_fence(
28527            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
28528            &state,
28529            "milo",
28530        )
28531        .await
28532        .unwrap();
28533        assert_eq!(fence["may_dispatch"], json!(true));
28534
28535        // Record r1 committed; the fence now refuses re-dispatch (idempotency).
28536        record_sync_intent(
28537            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1, "status": "committed"})),
28538            &state,
28539            "milo",
28540        )
28541        .await
28542        .unwrap();
28543        let fence = check_sync_fence(
28544            &req(json!({"agent_id": "milo", "run_id": "r1", "epoch": 1})),
28545            &state,
28546            "milo",
28547        )
28548        .await
28549        .unwrap();
28550        assert_eq!(fence["decision"]["decision"], json!("already_committed"));
28551        assert_eq!(fence["may_dispatch"], json!(false));
28552
28553        // Clean release; a bad-epoch renew/release surfaces as an error.
28554        release_lease(
28555            &req(json!({"agent_id": "milo", "epoch": 1})),
28556            &state,
28557            "milo",
28558        )
28559        .await
28560        .unwrap();
28561        assert!(release_lease(
28562            &req(json!({"agent_id": "milo", "epoch": 1})),
28563            &state,
28564            "milo"
28565        )
28566        .await
28567        .is_err());
28568    }
28569}
28570
28571#[cfg(test)]
28572mod merged_knowledge_tests {
28573    use super::merge_knowledge;
28574    use serde_json::json;
28575
28576    #[test]
28577    fn org_none_leaves_personal_knowledge_unchanged() {
28578        // The org-scope-OFF read invariant: no org subsystem → personal only.
28579        let personal = vec![json!({"subject": "a", "body": "1"})];
28580        assert_eq!(merge_knowledge(personal.clone(), None), personal);
28581    }
28582
28583    /// Replicate the recall path's POSITIONAL newest-per-subject reduce
28584    /// (`car-cli reduce_newest_per_subject`): last occurrence per subject wins,
28585    /// trusting ascending order. This is what a `sync.knowledge` consumer applies,
28586    /// so it is the real semantic to assert against.
28587    fn reduce_last_wins(
28588        entries: &[serde_json::Value],
28589    ) -> std::collections::HashMap<String, String> {
28590        let mut out = std::collections::HashMap::new();
28591        for e in entries {
28592            let subject = e["subject"].as_str().unwrap().to_string();
28593            let body = e["body"].as_str().unwrap().to_string();
28594            out.insert(subject, body); // last-wins
28595        }
28596        out
28597    }
28598
28599    #[test]
28600    fn shared_subject_resolves_to_personal_not_org() {
28601        // The precedence bug linus caught: a subject in BOTH scopes must resolve to
28602        // the PERSONAL body (Option-B personal-authoritative), NOT the org one.
28603        // merge_knowledge puts personal LAST so the positional last-wins reducer
28604        // keeps it.
28605        let personal = vec![json!({"subject": "shared", "body": "personal-newer"})];
28606        let org = vec![
28607            json!({"subject": "org-only", "body": "from-org"}),
28608            json!({"subject": "shared", "body": "org-staler"}),
28609        ];
28610        let reduced = reduce_last_wins(&merge_knowledge(personal, Some(org)));
28611        assert_eq!(
28612            reduced.get("shared").map(String::as_str),
28613            Some("personal-newer"),
28614            "personal must win a shared subject — a staler org fact must NOT shadow it"
28615        );
28616        assert_eq!(
28617            reduced.get("org-only").map(String::as_str),
28618            Some("from-org"),
28619            "an org-only subject is still surfaced"
28620        );
28621    }
28622
28623    #[test]
28624    fn within_scope_newest_is_preserved() {
28625        // Ordering within a scope is unchanged: the later entry for a subject wins.
28626        let personal = vec![
28627            json!({"subject": "a", "body": "old"}),
28628            json!({"subject": "a", "body": "new"}),
28629        ];
28630        let reduced = reduce_last_wins(&merge_knowledge(personal, None));
28631        assert_eq!(reduced.get("a").map(String::as_str), Some("new"));
28632    }
28633}
28634
28635/// `skill.adopt_pack`'s provenance resolution — the security-relevant half of
28636/// governed pack adoption, kept pure so its precedence is testable without a
28637/// daemon. Signature trust is *derived* against the operator keyring, never
28638/// asserted by the caller (arXiv 2602.12430 "Agent Skills";
28639/// `docs/proposals/skill-trust-governance.md`).
28640#[cfg(test)]
28641mod skill_adopt_pack_tests {
28642    use super::resolve_adopt_provenance;
28643    use car_bundle::{sign_manifest, AgentIdentity, AgentManifest, PublisherInfo, TransportSpec};
28644    use ed25519_dalek::SigningKey;
28645    use serde_json::json;
28646
28647    /// A genuinely ed25519-signed manifest standing in for a skill bundle, plus
28648    /// the base64 `key_id` it was signed with (so a test can choose to trust
28649    /// it). Mirrors `car-memgine/tests/skill_trust_lifecycle.rs::signed_manifest`;
28650    /// the key is deterministic here because the test needs no entropy.
28651    fn signed_manifest(id: &str) -> (AgentManifest, String) {
28652        let mut m = AgentManifest {
28653            agent: AgentIdentity {
28654                id: id.into(),
28655                name: id.into(),
28656                namespace: Some("parslee".into()),
28657                version: Some("1.0.0".into()),
28658                description: None,
28659                license: None,
28660                homepage: None,
28661            },
28662            publisher: None,
28663            runtime: None,
28664            lifecycle: None,
28665            transport: TransportSpec::PureData,
28666            capabilities: None,
28667        };
28668        let key = SigningKey::from_bytes(&[7u8; 32]);
28669        sign_manifest(&mut m, &key).expect("sign");
28670        let key_id = m
28671            .publisher
28672            .as_ref()
28673            .and_then(|p: &PublisherInfo| p.key_id.clone())
28674            .expect("key_id present after signing");
28675        (m, key_id)
28676    }
28677
28678    #[test]
28679    fn no_manifest_no_provenance_defaults_to_unsigned_and_untrusted() {
28680        let params = json!({ "scanned": true, "vulnerabilities": 2, "source": "community" });
28681        let p = resolve_adopt_provenance(&params, &[]).expect("resolves");
28682        assert!(!p.signed, "nothing signed it, so it is not signed");
28683        assert!(!p.signer_trusted);
28684        // The caller's own scan/source hints DO survive — only the signature
28685        // half is off-limits to the caller.
28686        assert!(p.scanned);
28687        assert_eq!(p.vulnerabilities, 2);
28688        assert_eq!(p.source, car_policy::skill_trust::SkillSource::Community);
28689    }
28690
28691    #[test]
28692    fn manifest_signed_by_a_keyring_signer_is_signed_and_trusted() {
28693        let (m, key_id) = signed_manifest("pack-agent");
28694        let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
28695        let p = resolve_adopt_provenance(&params, &[key_id]).expect("resolves");
28696        assert!(p.signed);
28697        assert!(p.signer_trusted, "key_id is in the operator keyring");
28698    }
28699
28700    #[test]
28701    fn manifest_signed_by_an_unknown_signer_is_signed_but_not_trusted() {
28702        let (m, _key_id) = signed_manifest("pack-agent");
28703        let params = json!({ "manifest": serde_json::to_value(&m).unwrap() });
28704        let p = resolve_adopt_provenance(&params, &[]).expect("resolves");
28705        assert!(p.signed, "the signature itself still verifies");
28706        assert!(
28707            !p.signer_trusted,
28708            "an empty keyring trusts no signer, which costs this pack Official \
28709             (and only Official) — signed + scanned still reaches Verified"
28710        );
28711    }
28712
28713    #[test]
28714    fn manifest_and_provenance_together_is_an_error_not_a_precedence_choice() {
28715        let (m, _) = signed_manifest("pack-agent");
28716        let params = json!({
28717            "manifest": serde_json::to_value(&m).unwrap(),
28718            "provenance": { "signed": true, "signer_trusted": true },
28719        });
28720        let err = resolve_adopt_provenance(&params, &[]).expect_err("must refuse");
28721        assert!(err.contains("not both"), "unexpected message: {err}");
28722    }
28723
28724    #[test]
28725    fn unrecognised_source_is_rejected_rather_than_silently_defaulted() {
28726        let params = json!({ "source": "bogus" });
28727        let err = resolve_adopt_provenance(&params, &[]).expect_err("must refuse");
28728        assert!(err.contains("invalid source"), "unexpected message: {err}");
28729    }
28730}
28731
28732#[cfg(test)]
28733mod approval_requester_tests {
28734    use super::stamped_requester;
28735
28736    /// An agent-bound session IS that agent. Its binding was proven against the
28737    /// supervisor-minted per-agent token in `session.auth`, so a claim to be a
28738    /// different agent is a forgery and must not reach the row.
28739    #[test]
28740    fn a_bound_session_cannot_claim_to_be_another_agent() {
28741        assert_eq!(
28742            stamped_requester(Some("agent-7".into()), Some("agent-9".into())),
28743            Some("agent-7".into())
28744        );
28745    }
28746
28747    /// Nor can it launder the attribution by omitting the field — which was the
28748    /// cheaper evasion, because an absent `agent_id` never matched the
28749    /// requester-vs-resolver comparison at all.
28750    #[test]
28751    fn a_bound_session_cannot_launder_by_omitting_the_field() {
28752        assert_eq!(
28753            stamped_requester(Some("agent-7".into()), None),
28754            Some("agent-7".into())
28755        );
28756    }
28757
28758    /// A host client — CarHost, `car-host approve`, the CLI — has no binding and
28759    /// legitimately raises approvals on an agent's behalf, so its claim stands.
28760    #[test]
28761    fn an_unbound_host_client_keeps_its_claim() {
28762        assert_eq!(
28763            stamped_requester(None, Some("agent-7".into())),
28764            Some("agent-7".into())
28765        );
28766        assert_eq!(stamped_requester(None, None), None);
28767    }
28768}