Skip to main content

warble_claude_code/
interactive.rs

1//! Shared, deliberately small handoff contract for native interactive CLIs.
2
3use crate::error::DispatchError;
4use crate::ir::WarbleIr;
5use serde::{Deserialize, Serialize};
6use serde_json::{json, Value};
7use sha2::{Digest, Sha256};
8use std::fs;
9use std::path::{Component, Path, PathBuf};
10use url::{Host, Url};
11
12pub const LAUNCH_SPEC_VERSION: &str = "1";
13pub const NATIVE_SESSION_LAUNCH_SPEC_VERSION: &str = "2";
14/// Native Sessions v4 adds the closed, producer-authored first prompt as the
15/// final vendor argv element. The host must validate the whole argv exactly;
16/// it must never write a prompt through the PTY after spawning.
17pub const NATIVE_SESSION_MCP_LAUNCH_SPEC_VERSION: &str = "4";
18pub const NATIVE_SCOPE_VERSION: &str = "3";
19pub const NATIVE_WREN_RUNTIME_VERSION: &str = "1";
20pub const NATIVE_MCP_DESCRIPTOR_VERSION: &str = "1";
21pub const NATIVE_MCP_SERVER_NAME: &str = "genbi_session";
22pub const NATIVE_MCP_CREDENTIAL_ENV_VAR: &str = "WARBLE_MCP_CONNECTION_CREDENTIAL";
23pub const NATIVE_DASHBOARD_SAVE_TOOL: &str = "save_dashboard";
24pub const NATIVE_PERSIST_ANSWER_TOOL: &str = "persist_answer";
25/// The only project-creation root a native Setup TUI may receive. GenBI sets
26/// this after it revalidates the producer-authored v4 bootstrap_root; callers
27/// and browsers never supply it.
28pub const NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR: &str = "WARBLE_SETUP_BOOTSTRAP_ROOT";
29
30const CODEX_WREN_PERMISSION_PROFILE: &str = "warble_native_wren";
31
32const SETUP_RECOVERY_REPORT_VERSION: &str = "1";
33const MAX_SAFE_SETUP_RECOVERY_SEQUENCE: u64 = 9_007_199_254_740_991;
34
35/// The public, transport-independent input schema for the `genbi_session`
36/// `report_setup_recovery` MCP tool. The host validates monotonicity against its last accepted
37/// report; the schema deliberately carries neither identity nor any diagnostic text.
38pub fn setup_recovery_input_schema() -> Value {
39    json!({
40        "$schema": "https://json-schema.org/draft/2020-12/schema",
41        "title": "report_setup_recovery v1 input",
42        "type": "object",
43        "additionalProperties": false,
44        "required": ["version", "sequence", "phase", "state", "code"],
45        "properties": {
46            "version": { "const": SETUP_RECOVERY_REPORT_VERSION },
47            "sequence": {
48                "type": "integer",
49                "minimum": 1,
50                "maximum": MAX_SAFE_SETUP_RECOVERY_SEQUENCE,
51            },
52            "phase": { "enum": ["connect", "context"] },
53            "state": {
54                "enum": [
55                    "working",
56                    "needs_input",
57                    "needs_decision",
58                    "retryable_failure",
59                    "reported_complete",
60                ]
61            },
62            "code": {
63                "enum": [
64                    "in_progress",
65                    "user_action_required",
66                    "continue_or_stop",
67                    "retryable",
68                    "completion_reported",
69                ]
70            },
71            "decision": {
72                "type": "object",
73                "additionalProperties": false,
74                "required": ["kind", "choices"],
75                "properties": {
76                    "kind": { "const": "continue_or_stop" },
77                    "choices": {
78                        "type": "array",
79                        "prefixItems": [{ "const": "continue" }, { "const": "stop" }],
80                        "items": false,
81                        "minItems": 2,
82                        "maxItems": 2,
83                    }
84                }
85            }
86        },
87        "allOf": [
88            {
89                "if": { "properties": { "state": { "const": "working" } }, "required": ["state"] },
90                "then": { "properties": { "code": { "const": "in_progress" } }, "required": ["code"], "not": { "required": ["decision"] } }
91            },
92            {
93                "if": { "properties": { "state": { "const": "needs_input" } }, "required": ["state"] },
94                "then": { "properties": { "code": { "const": "user_action_required" } }, "required": ["code"], "not": { "required": ["decision"] } }
95            },
96            {
97                "if": { "properties": { "state": { "const": "needs_decision" } }, "required": ["state"] },
98                "then": { "properties": { "code": { "const": "continue_or_stop" } }, "required": ["code", "decision"] }
99            },
100            {
101                "if": { "properties": { "state": { "const": "retryable_failure" } }, "required": ["state"] },
102                "then": { "properties": { "code": { "const": "retryable" } }, "required": ["code"], "not": { "required": ["decision"] } }
103            },
104            {
105                "if": { "properties": { "state": { "const": "reported_complete" } }, "required": ["state"] },
106                "then": { "properties": { "code": { "const": "completion_reported" } }, "required": ["code"], "not": { "required": ["decision"] } }
107            }
108        ]
109    })
110}
111
112/// The generated Setup-agent instruction for the v3 `genbi_session` discovery contract.
113/// This is deliberately prose plus closed examples rather than a second tool configuration.
114pub fn setup_recovery_instructions() -> &'static str {
115    r#"## Setup recovery reporting (v1)
116
117When the discovered `genbi_session` MCP server exposes `report_setup_recovery`, use that exact tool to report only an honest, redacted Setup lifecycle update. Do not create another transport or attempt to report through terminal output.
118
119Every report has exactly these fields: `version: "1"`, a positive integer `sequence`, `phase`, `state`, and `code`. Use a strictly increasing `sequence` for each report in this native session. `phase` is exactly `connect` or `context`. The only valid state/code pairs are:
120
121- `working` / `in_progress`
122- `needs_input` / `user_action_required`
123- `needs_decision` / `continue_or_stop`
124- `retryable_failure` / `retryable`
125- `reported_complete` / `completion_reported`
126
127Only `needs_decision` carries `decision`, and it is exactly `{ "kind": "continue_or_stop", "choices": ["continue", "stop"] }`. Do not send `decision` for another state. Do not add fields or free text: never include identity, scope, project/session/vendor details, terminal output, paths, commands, prompts, credentials, tool arguments/results, or arbitrary options.
128
129`reported_complete` is only this agent's report; the host independently validates completion. Never infer a report from terminal bytes, an exit status, or a tool result. If a truthful report cannot be made, omit it; silence is an honest host-lifecycle outcome, not a fabricated `needs_input` or decision.
130"#
131}
132
133/// Native analysis keeps the structured step values as private orchestration
134/// material while giving the person in the terminal a useful answer. Headless
135/// dispatch remains the owner of the JSON result contract.
136pub fn native_analysis_terminal_presentation_instructions() -> &'static str {
137    r#"## Native terminal presentation
138
139For the final response to the person in this interactive session, write concise conversational Markdown. Lead with the grounded answer, then include only a small table or bullets when they make the result clearer. Mention verification or query provenance only when it helps the person understand the conclusion.
140
141The structured values used to coordinate steps are internal. Do not print a JSON result, render envelope, step envelope, orchestration field name, driver/subagent transcript, or tool request/response dump in the terminal. In particular, do not expose fields such as `warble_step`, `produces`, `query_result`, `repaired_result`, `columns`, `rows`, or `definition` as an internal payload. Programmatic and headless callers retain their structured JSON contracts; this rule changes only the native interactive presentation."#
142}
143
144/// Native analysis persists the final typed answer before turning it into a
145/// conversational terminal response. The host owns the stored bytes and the
146/// opaque reference; the driver must never invent a second payload shape.
147pub fn native_answer_persistence_instructions() -> &'static str {
148    r#"## Persist the final answer before presentation
149
150After the final step has produced and validated the answer, but before writing any conversational Markdown, call `genbi_session.persist_answer` once with the exact typed answer envelope. Its `blocks` array contains exactly one `table` block with `columns` and `rows`, and zero or one `definition` block with `sql`, `source_tables`, and `filters`. Keep `verified: true`. Do not add a summary, chart, raw result, or any other block or representation.
151
152```json
153{
154  "version": "1",
155  "idempotency_key": "<stable retry key for this exact computed answer>",
156  "envelope": {
157    "blocks": [
158      {"type": "table", "columns": ["..."], "rows": [["..."]]},
159      {"type": "definition", "sql": "...", "source_tables": ["..."], "filters": ["..."]}
160    ],
161    "verified": true
162  }
163}
164```
165
166The `definition` block is optional; omit it rather than substituting another shape. `idempotency_key` is only for retrying this same already-computed persistence request, never caller-asserted provenance. On success, retain the host-returned `answer_ref` for a later dashboard save; keep `answer_ref`, the digest, and `persisted_at` internal rather than printing them in the terminal.
167
168If persistence ultimately fails, do not recompute, rerun `answer_query`, generate SQL, or ask the user to supply the payload again. Still present the already-computed answer conversationally, followed by a concise honest warning that it was not retained and cannot later be saved by reference. Do not claim that it was retained or saved."#
169}
170
171/// Native analysis reuses the programmatic component IR for its data-work
172/// instructions, but its final terminal response is deliberately not a JSON
173/// transport. Keep this narrowly scoped to the authored final-output mandate:
174/// the structured per-step contracts still coordinate the native workflow.
175pub fn native_analysis_prompt_fragment(prompt: &str) -> String {
176    prompt.replace(
177        r#"FINAL message must be a single JSON object of the form
178  `{"columns": ["model"], "rows": [["<model_name>"], ...]}` listing every model in the layer
179  (one row per model), so coverage can be checked deterministically. You may add a short prose
180  summary after the JSON."#,
181        "final response should concisely summarize the semantic coverage in conversational Markdown.",
182    )
183}
184
185/// Native Context Enrichment retains the proposal data model for the host, but
186/// it is not a JSON transport for the person using a terminal.  Keep this
187/// target-specific override at the emitter seam: the profile and its headless
188/// consumers continue to own the canonical proposal contract.
189pub fn native_context_enrichment_terminal_presentation_instructions() -> &'static str {
190    r#"## Native context-enrichment presentation
191
192For the final response to the person in this interactive session, write concise conversational Markdown, never JSON. Explain the proposed change in plain language, then cover its evidence and confidence, impact/risk, and destination in the semantic context. End with a clear choice: **accept**, **edit**, or **skip**.
193
194In Grill mode, present only the one change currently awaiting a choice. If the proposal must pause because a prerequisite is missing or the destination is ambiguous, say that it is paused, explain the missing prerequisite or ambiguity and its risk, and ask what the person wants to clarify. Do not fabricate a draft to avoid a pause.
195
196The proposal's structured values are internal host-coordination material. Do not print an `enrichment_proposal` JSON object, YAML payload, step envelope, orchestration field name, host/session/provider detail, tool request/response dump, or raw context material. In particular, do not expose labels such as `recommended_yaml`, `relative_sink`, `requires_approval`, `autopilot_eligible`, `project_revision`, or `produces` as a machine payload. Programmatic and headless callers retain their canonical structured proposal contracts; this rule changes only the native interactive presentation."#
197}
198
199/// Remove the profile's headless-only JSON-final requirement from an emitted
200/// native Context Enrichment artifact.  Earlier prompt constraints remain the
201/// source of truth for how a proposal is reasoned about; this only changes how
202/// the completed proposal is presented in the terminal.
203pub fn native_context_enrichment_prompt_fragment(prompt: &str) -> String {
204    const HEADLESS_JSON_FINAL_MANDATE: &str = r#"Produce `enrichment_proposal`; approval, canonical hashes/digests, and application are deterministic
205host responsibilities. Your FINAL message must be one JSON object only. Do not include prose or
206Markdown fences. The top level is `{ "enrichment_proposal": { ... } }`; for Grill it contains the
207supplied `project_revision`, exactly one operation with `relative_sink` and `recommended_yaml`,
208confidence/evidence locators, `impact: "high"`, `requires_approval: true`,
209`autopilot_eligible: false`, and one decision whose allowed responses are exactly
210`["accept", "edit", "skip"]`."#;
211
212    prompt.replace(
213        HEADLESS_JSON_FINAL_MANDATE,
214        "Prepare the same safe, read-only proposal internally, then give the person a concise conversational Markdown summary. Explain the proposed change, evidence and confidence, impact/risk, and destination without exposing the proposal's JSON or field names. In Grill mode, ask for exactly one of: accept, edit, or skip. If a prerequisite is missing or the destination is ambiguous, explain that the proposal is paused instead of inventing a draft.",
215    )
216}
217
218/// The host owns persistence for a native dashboard session. This instruction
219/// is emitted only when the server supplied the allowlisted session MCP.
220pub fn native_dashboard_save_instructions() -> &'static str {
221    r#"## Save a GenBI dashboard
222
223When the user asks to create or save a dashboard from a prior answer, call `genbi_session.save_dashboard` exactly for that persistence action. When the retained `answer_ref` is still available in this conversation, prefer the exact reference:
224
225```json
226{
227  "version": "1",
228  "name": "<concise dashboard name>",
229  "answer_ref": "<answer_ref returned by persist_answer>",
230  "idempotency_key": "<stable key for this same dashboard request>"
231}
232```
233
234If the user asks to save this or the most recent answer and the opaque `answer_ref` is no longer available in the conversation, use the host-owned session selector instead of refusing or recomputing:
235
236```json
237{
238  "version": "1",
239  "name": "<concise dashboard name>",
240  "answer_selection": "latest",
241  "idempotency_key": "<stable key for this same dashboard request>"
242}
243```
244
245Provide exactly one of `answer_ref` or `answer_selection`. Reuse the same idempotency key when retrying the same request; choose a new key only for a materially new dashboard request. The host reuses the exact stored bytes for the selected answer and resolves `latest` only within this native session. Do not use `latest` when the user explicitly identifies an older answer whose reference is unavailable; explain that the older reference cannot be recovered. Do not rerun `answer_query`, generate SQL, repair SQL, or otherwise recompute; do not re-supply or reconstruct the payload. If the host reports that no persisted answer is available, say that reference saving is unavailable rather than claiming a save or attempting recomputation. The saved dashboard appears on the **GenBI Artifacts page**. Do not substitute a vendor-hosted Artifact feature, artifact URL, share URL, or any external vendor-hosted link for `genbi_session.save_dashboard`. Do not claim it was saved unless that tool succeeds."#
246}
247
248/// Shared vendor-neutral instruction for Setup's two-root launch contract.
249/// The private materialization cwd contains only dispatcher artifacts; the
250/// explicit server-owned environment value is the sole creation authority.
251pub fn setup_bootstrap_authority_instructions() -> String {
252    format!(
253        r#"## Setup bootstrap write authority
254
255`{NATIVE_SETUP_BOOTSTRAP_ROOT_ENV_VAR}` is set by the host to the only server-authorized project-creation root for this Setup session. Create or modify project files only below that exact root. Do not write into the current working directory (it contains private launch artifacts), do not change cwd to broaden authority, and do not write outside the configured bootstrap root. Treat a missing, relative, or unexpected value as a host-configuration failure: do not guess a path or accept one from the user.
256"#
257    )
258}
259
260#[derive(Deserialize)]
261#[serde(deny_unknown_fields)]
262struct SetupRecoveryReport {
263    version: String,
264    sequence: u64,
265    phase: String,
266    state: String,
267    code: String,
268    #[serde(default)]
269    decision: Option<SetupRecoveryDecision>,
270}
271
272#[derive(Deserialize)]
273#[serde(deny_unknown_fields)]
274struct SetupRecoveryDecision {
275    kind: String,
276    choices: Vec<String>,
277}
278
279/// Validate the producer-owned closed vocabulary without accepting or echoing a raw report.
280/// Hosts additionally own per-session authorization and monotonic sequence fencing.
281pub fn validate_setup_recovery_report(value: &Value) -> Result<(), DispatchError> {
282    let report: SetupRecoveryReport = serde_json::from_value(value.clone())
283        .map_err(|_| DispatchError("invalid report_setup_recovery v1 input".to_string()))?;
284    if report.version != SETUP_RECOVERY_REPORT_VERSION
285        || report.sequence == 0
286        || report.sequence > MAX_SAFE_SETUP_RECOVERY_SEQUENCE
287        || !matches!(report.phase.as_str(), "connect" | "context")
288    {
289        return Err(DispatchError(
290            "invalid report_setup_recovery v1 input".to_string(),
291        ));
292    }
293    let valid = match report.state.as_str() {
294        "working" => report.code == "in_progress" && report.decision.is_none(),
295        "needs_input" => report.code == "user_action_required" && report.decision.is_none(),
296        "needs_decision" => {
297            report.code == "continue_or_stop"
298                && matches!(
299                    report.decision,
300                    Some(SetupRecoveryDecision { kind, choices })
301                        if kind == "continue_or_stop"
302                            && choices == ["continue", "stop"]
303                )
304        }
305        "retryable_failure" => report.code == "retryable" && report.decision.is_none(),
306        "reported_complete" => report.code == "completion_reported" && report.decision.is_none(),
307        _ => false,
308    };
309    valid
310        .then_some(())
311        .ok_or_else(|| DispatchError("invalid report_setup_recovery v1 input".to_string()))
312}
313
314/// A server-derived launch scope. This is deliberately a small producer input rather
315/// than session state: GenBI creates and authorizes it, Warble verifies its shape/canonical cwd
316/// and carries its opaque binding identity into the launch artifact, and the future runtime owns
317/// comparing it to its live binding generation/revision before spawning.
318#[derive(Debug, Clone, Deserialize, Serialize)]
319#[serde(deny_unknown_fields)]
320pub struct NativeSessionScope {
321    pub version: String,
322    pub kind: String,
323    pub scope_id: String,
324    pub cwd: PathBuf,
325    /// Which component the session starts in, and the first thing it is told.
326    ///
327    /// The caller declares this rather than the dispatcher inferring it: a profile can carry
328    /// several materializable components (every shipped one does), so structure alone cannot
329    /// choose an entry, and a built-in table of entry names would tie this crate to whichever
330    /// product authored the profile. What the dispatcher still owns is the check that the
331    /// declaration is true of the IR, and the authorship of the argv built from it.
332    pub entry: NativeEntry,
333    /// Setup alone carries this separately authorized project-creation root.
334    /// `cwd` remains the native materialization root and must equal `--out`.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub bootstrap_root: Option<PathBuf>,
337    /// Optional to preserve the existing cross-vendor native scope contract. Native Codex
338    /// materialization requires this exact server-resolved launcher and Python runtime closure;
339    /// it is never derived from the browser, environment, or PATH by the dispatcher.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub wren_runtime: Option<NativeWrenRuntime>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub binding: Option<NativeBinding>,
344}
345
346/// How a native session enters its profile.
347///
348/// `Agent` pins the session to one materialized component. `Scope` starts it at the scope
349/// document instead, leaving every emitted component a delegable subagent and letting the
350/// session's own driver choose which one owns the request.
351///
352/// `Agent` is the default on purpose. A descriptor written before this field existed carries a
353/// `verb` and no `kind`, and must keep meaning exactly what it meant. Scope entry is the wider of
354/// the two, so it has to be asked for by name and can never be arrived at by omission.
355///
356/// How much wider is worth stating plainly, because nothing here narrows it. A pinned session IS
357/// the named agent, so its tools are that agent's declared `tools:` list. A scope session is a
358/// plain vendor session with no agent frontmatter, so it carries the vendor's default surface —
359/// and the emitted `.claude/settings.json` pre-approves the UNION of every mounted component's
360/// grants, because the vendor loads one settings file per session rather than one per agent. A
361/// scope session can therefore use a tool that no single component was granted, without being
362/// prompted for it. Per-agent `tools:` still bounds each delegated subagent; it is only the
363/// top-level session that is unbounded. Closing that needs the scope session to carry a declared
364/// tool surface of its own, which this does not attempt.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
366#[serde(rename_all = "snake_case")]
367pub enum NativeEntryKind {
368    Agent,
369    Scope,
370}
371
372/// The caller-declared session entry. `kind` picks the form; `verb` names the component to
373/// materialize and is present for `Agent` only; `prompt` is the single positional first turn and
374/// is required by both forms. All of it is validated against the compiled IR before any of it
375/// reaches an argv — see `NativePurpose::validate_profile`.
376#[derive(Debug, Clone, Deserialize, Serialize)]
377#[serde(deny_unknown_fields)]
378pub struct NativeEntry {
379    #[serde(default = "NativeEntry::default_kind")]
380    pub kind: NativeEntryKind,
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub verb: Option<String>,
383    pub prompt: String,
384}
385
386impl NativeEntry {
387    fn default_kind() -> NativeEntryKind {
388        NativeEntryKind::Agent
389    }
390
391    /// The pinned component verb, or `None` for a scope entry.
392    pub fn pinned_verb(&self) -> Option<&str> {
393        self.verb.as_deref()
394    }
395}
396
397#[derive(Debug, Clone, Deserialize, Serialize)]
398#[serde(deny_unknown_fields)]
399pub struct NativeBinding {
400    pub project_identity: String,
401    pub generation: String,
402    pub revision: String,
403}
404
405/// A closed, server-derived Wren launcher chain. The Codex sandbox's `read` filesystem grant
406/// includes execution permission, so its rendered entries must be the minimum transitive closure:
407/// the PATH shim, the generated console-script launcher, the venv Python symlink, both runtime
408/// library roots, and the server-pinned editable Wren source root that the venv's closed `.pth`
409/// resolver names.
410#[derive(Debug, Clone, Deserialize, Serialize)]
411#[serde(deny_unknown_fields)]
412pub struct NativeWrenRuntime {
413    pub version: String,
414    pub shim: PathBuf,
415    pub launcher: PathBuf,
416    pub venv_python: PathBuf,
417    pub tool_root: PathBuf,
418    pub site_packages: PathBuf,
419    pub source_root: PathBuf,
420    pub interpreter: PathBuf,
421    pub interpreter_root: PathBuf,
422}
423
424struct CanonicalWrenRuntime {
425    shim: PathBuf,
426    shim_parent: PathBuf,
427    launcher: PathBuf,
428    venv_python: PathBuf,
429    tool_bin: PathBuf,
430    tool_pyvenv: PathBuf,
431    site_packages: PathBuf,
432    interpreter: PathBuf,
433    interpreter_bin: PathBuf,
434    interpreter_lib: PathBuf,
435    source_root: PathBuf,
436}
437
438/// Exact server-derived connection material for the allowlisted native-session MCP server.
439///
440/// The credential is deliberately opaque to Warble. It is emitted only into the vendor discovery
441/// mechanism that needs it: Claude's HTTP header or Codex's dedicated credential environment
442/// variable. It never appears in the launch spec, ownership record, agent Markdown, argv, or a
443/// prompt. The host resolves this credential to live session/vendor/project/revision/capability
444/// state when the native client connects.
445#[derive(Clone, Deserialize)]
446#[serde(deny_unknown_fields)]
447pub struct NativeMcpDescriptor {
448    version: String,
449    url: String,
450    credential: String,
451}
452
453impl std::fmt::Debug for NativeMcpDescriptor {
454    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455        formatter
456            .debug_struct("NativeMcpDescriptor")
457            .field("version", &self.version)
458            .field("url", &self.url)
459            .field("credential", &"[REDACTED]")
460            .finish()
461    }
462}
463
464impl NativeMcpDescriptor {
465    pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
466        let raw = fs::read_to_string(path).map_err(|e| {
467            DispatchError(format!(
468                "read native MCP descriptor {}: {e}",
469                path.display()
470            ))
471        })?;
472        serde_json::from_str(&raw).map_err(|e| {
473            DispatchError(format!(
474                "parse native MCP descriptor {}: {e}",
475                path.display()
476            ))
477        })
478    }
479
480    fn validate(&self) -> Result<(), DispatchError> {
481        if self.version != NATIVE_MCP_DESCRIPTOR_VERSION {
482            return Err(DispatchError(format!(
483                "unsupported native MCP descriptor version '{}' (expected: {NATIVE_MCP_DESCRIPTOR_VERSION})",
484                self.version
485            )));
486        }
487        // The generic descriptor has no redirect, DNS, or vendor-specific transport
488        // semantics. Parse its authority before accepting it: HTTPS may use any exact
489        // parsed host, while HTTP is confined to a literal loopback host on this machine.
490        // The credential, not the URL, carries the bounded session binding.
491        let url = Url::parse(&self.url).map_err(|_| {
492            DispatchError(
493                "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
494            )
495        })?;
496        let loopback = match url.host() {
497            Some(Host::Domain("localhost")) => true,
498            Some(Host::Ipv4(address)) => address.is_loopback(),
499            Some(Host::Ipv6(address)) => address.is_loopback(),
500            _ => false,
501        };
502        let allowed_scheme = url.scheme() == "https" || url.scheme() == "http" && loopback;
503        // `Url::scheme()` provides the case-normalized scheme. Retain an explicit `://`
504        // shape check so a parsed relative/scheme-only URL can never stand in for a
505        // network authority.
506        let has_network_authority = self
507            .url
508            .split_once(':')
509            .is_some_and(|(_, after_scheme)| after_scheme.starts_with("//"));
510        if self.url.len() > 2048
511            || url.host().is_none()
512            || !url.username().is_empty()
513            || url.password().is_some()
514            || url.query().is_some()
515            || url.fragment().is_some()
516            || self.url.chars().any(char::is_control)
517            || self.url.chars().any(char::is_whitespace)
518            || !has_network_authority
519            || !allowed_scheme
520        {
521            return Err(DispatchError(
522                "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
523            ));
524        }
525        if self.credential.is_empty()
526            || self.credential.len() > 4096
527            || self.credential.chars().any(char::is_control)
528            || self.credential.chars().any(char::is_whitespace)
529        {
530            return Err(DispatchError(
531                "native MCP descriptor requires a bounded opaque credential".to_string(),
532            ));
533        }
534        Ok(())
535    }
536
537    fn ownership_digest(&self) -> String {
538        let bytes = serde_json::to_vec(&json!({
539            "version": self.version,
540            "url": self.url,
541            "credential": self.credential,
542        }))
543        .expect("canonical native MCP descriptor serializes");
544        format!("sha256:{:x}", Sha256::digest(bytes))
545    }
546
547    pub fn claude_discovery_config(&self) -> Result<String, DispatchError> {
548        serde_json::to_string_pretty(&json!({
549            "mcpServers": {
550                NATIVE_MCP_SERVER_NAME: {
551                    "type": "http",
552                    "url": self.url,
553                    "headers": { "Authorization": format!("Bearer {}", self.credential) },
554                }
555            }
556        }))
557        .map(|value| format!("{value}\n"))
558        .map_err(|e| DispatchError(e.to_string()))
559    }
560
561    pub(crate) fn codex_host_discovery_config(&self, tools: &[&str]) -> String {
562        format!("[mcp_servers.{NATIVE_MCP_SERVER_NAME}]\nurl = {}\nbearer_token_env_var = {}\nenabled_tools = {}\n",
563            serde_json::to_string(&self.url).expect("URL"), serde_json::to_string(NATIVE_MCP_CREDENTIAL_ENV_VAR).expect("env"),
564            serde_json::to_string(tools).expect("tools"))
565    }
566
567    pub fn codex_discovery_config(
568        &self,
569        enable_setup_recovery_tool: bool,
570        enable_dashboard_save_tool: bool,
571        enable_persist_answer_tool: bool,
572    ) -> String {
573        // JSON strings are valid TOML basic strings and give us a single established escaping
574        // primitive for the server URL. Codex reads the credential only at native-process launch.
575        let mut config = format!(
576            "[mcp_servers.{NATIVE_MCP_SERVER_NAME}]\nurl = {}\nbearer_token_env_var = {}\n",
577            serde_json::to_string(&self.url).expect("MCP URL serializes"),
578            serde_json::to_string(NATIVE_MCP_CREDENTIAL_ENV_VAR)
579                .expect("credential env name serializes"),
580        );
581        let mut enabled_tools = Vec::new();
582        if enable_setup_recovery_tool {
583            enabled_tools.push("report_setup_recovery");
584        }
585        if enable_dashboard_save_tool {
586            enabled_tools.push(NATIVE_DASHBOARD_SAVE_TOOL);
587        }
588        if enable_persist_answer_tool {
589            enabled_tools.push(NATIVE_PERSIST_ANSWER_TOOL);
590        }
591        if !enabled_tools.is_empty() {
592            config.push_str(&format!(
593                "enabled_tools = {}\n",
594                serde_json::to_string(&enabled_tools).expect("MCP tool names serialize"),
595            ));
596        }
597        config
598    }
599}
600
601impl NativeSessionScope {
602    pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
603        let raw = fs::read_to_string(path).map_err(|e| {
604            DispatchError(format!("read native session scope {}: {e}", path.display()))
605        })?;
606        serde_json::from_str(&raw).map_err(|e| {
607            DispatchError(format!(
608                "parse native session scope {}: {e}",
609                path.display()
610            ))
611        })
612    }
613
614    fn validate_preflight(&self, purpose: NativePurpose) -> Result<(), DispatchError> {
615        if self.version != NATIVE_SCOPE_VERSION {
616            return Err(DispatchError(format!(
617                "unsupported native session scope version '{}' (expected: {NATIVE_SCOPE_VERSION})",
618                self.version
619            )));
620        }
621        if self.kind != purpose.scope_kind() {
622            return Err(DispatchError(format!(
623                "native session scope kind '{}' does not match purpose '{}' ({})",
624                self.kind,
625                purpose.as_str(),
626                purpose.scope_kind()
627            )));
628        }
629        if self.scope_id.trim().is_empty() {
630            return Err(DispatchError(
631                "native session scope requires a non-empty opaque scope_id".to_string(),
632            ));
633        }
634        match (self.entry.kind, self.entry.verb.as_deref()) {
635            (NativeEntryKind::Agent, None) => {
636                return Err(DispatchError(
637                    "native session scope entry kind 'agent' requires the component verb it pins"
638                        .to_string(),
639                ));
640            }
641            (NativeEntryKind::Agent, Some(verb)) if verb.trim().is_empty() => {
642                return Err(DispatchError(
643                    "native session scope requires a non-empty entry verb".to_string(),
644                ));
645            }
646            // A verb alongside `scope` is a contradiction, not a harmless extra: the caller has
647            // asked for two different sessions at once, and guessing which it meant would pick a
648            // privilege level on its behalf.
649            (NativeEntryKind::Scope, Some(_)) => {
650                return Err(DispatchError(
651                    "native session scope entry kind 'scope' must not name a verb: scope entry \
652                     starts at the scope document and pins no component"
653                        .to_string(),
654                ));
655            }
656            (NativeEntryKind::Agent, Some(_)) | (NativeEntryKind::Scope, None) => {}
657        }
658        if self.entry.prompt.trim().is_empty() {
659            return Err(DispatchError(
660                "native session scope requires a non-empty entry prompt".to_string(),
661            ));
662        }
663        // Both entry fields become positional argv elements, and neither CLI is invoked behind a
664        // `--` sentinel. A dash-leading value would therefore be parsed as an option rather than
665        // as the prompt or the agent name -- silently changing what was launched, since both
666        // vendors expose permission-affecting flags. This surface did not exist while the prompt
667        // was a constant compiled into this dispatcher; it does now that a caller supplies it, so
668        // the shape has to be refused here, which is the last gate before argv is assembled.
669        for (field, value) in self
670            .entry
671            .verb
672            .iter()
673            .map(|verb| ("verb", verb))
674            .chain(std::iter::once(("prompt", &self.entry.prompt)))
675        {
676            if value.trim_start().starts_with('-') {
677                return Err(DispatchError(format!(
678                    "native session scope entry {field} must not begin with '-': a dash-leading \
679                     value would be parsed as a vendor CLI option instead of argv content"
680                )));
681            }
682        }
683        if !self.cwd.is_absolute() {
684            return Err(DispatchError(
685                "native session scope cwd must be an absolute server-derived path".to_string(),
686            ));
687        }
688        match (&self.kind[..], &self.binding, &self.bootstrap_root) {
689            ("bootstrap", None, Some(bootstrap_root)) => {
690                if !bootstrap_root.is_absolute() {
691                    return Err(DispatchError(
692                        "bootstrap native session scope root must be an absolute server-derived path".to_string(),
693                    ));
694                }
695                let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
696                    DispatchError(format!(
697                        "canonicalize bootstrap native session scope root {}: {e}",
698                        bootstrap_root.display()
699                    ))
700                })?;
701                if *bootstrap_root != canonical_bootstrap_root {
702                    return Err(DispatchError(
703                        "bootstrap native session scope root must already be canonical".to_string(),
704                    ));
705                }
706                Ok(())
707            }
708            ("bootstrap", None, None) => Err(DispatchError(
709                "bootstrap native session scope requires a separately authorized bootstrap_root"
710                    .to_string(),
711            )),
712            ("bootstrap", Some(_), _) => Err(DispatchError(
713                "bootstrap native session scope must not carry a bound-project identity".to_string(),
714            )),
715            ("bound_project", Some(binding), None)
716                if !binding.project_identity.trim().is_empty()
717                    && !binding.generation.trim().is_empty()
718                    && !binding.revision.trim().is_empty() =>
719            {
720                Ok(())
721            }
722            ("bound_project", _, Some(_)) => Err(DispatchError(
723                "bound_project native session scope must not carry a bootstrap_root".to_string(),
724            )),
725            ("bound_project", _, None) => Err(DispatchError(
726                "bound_project native session scope requires non-empty project_identity, generation, and revision"
727                    .to_string(),
728            )),
729            _ => unreachable!("purpose mapping has a closed scope vocabulary"),
730        }
731    }
732
733    fn validate(&self, purpose: NativePurpose, root: &Path) -> Result<(), DispatchError> {
734        self.validate_preflight(purpose)?;
735        let scope_cwd = match fs::canonicalize(&self.cwd) {
736            Ok(canonical) => {
737                if self.cwd != canonical {
738                    return Err(DispatchError(
739                        "native session scope cwd must already be canonical".to_string(),
740                    ));
741                }
742                canonical
743            }
744            Err(error) if error.kind() == std::io::ErrorKind::NotFound && self.cwd == root => {
745                root.to_path_buf()
746            }
747            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
748                return Err(DispatchError(format!(
749                    "native session scope cwd {} does not match canonical output root {}",
750                    self.cwd.display(),
751                    root.display()
752                )))
753            }
754            Err(error) => {
755                return Err(DispatchError(format!(
756                    "canonicalize native session scope cwd {}: {error}",
757                    self.cwd.display()
758                )))
759            }
760        };
761        if scope_cwd != root {
762            return Err(DispatchError(format!(
763                "native session scope cwd {} does not match canonical output root {}",
764                scope_cwd.display(),
765                root.display()
766            )));
767        }
768        if let Some(bootstrap_root) = &self.bootstrap_root {
769            let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
770                DispatchError(format!(
771                    "canonicalize bootstrap native session scope root {}: {e}",
772                    bootstrap_root.display()
773                ))
774            })?;
775            if canonical_bootstrap_root == root {
776                return Err(DispatchError(
777                    "bootstrap native session scope root must be distinct from the canonical output root"
778                        .to_string(),
779                ));
780            }
781        }
782        Ok(())
783    }
784
785    fn ownership_digest(&self, canonical_cwd: &Path) -> String {
786        // This is a collision identifier only, not a signature or provenance proof. GenBI owns
787        // invocation authorization; the digest merely keeps opaque caller values out of Markdown
788        // ownership markers while ensuring a changed descriptor cannot reuse an old artifact set.
789        let canonical = json!({
790            "version": self.version,
791            "kind": self.kind,
792            "scope_id": self.scope_id,
793            "cwd": canonical_cwd,
794            "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
795                fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
796            }),
797            "wren_runtime": self.wren_runtime,
798            "binding": self.binding,
799            // The entry is part of the descriptor, so it has to be part of the identifier the
800            // docstring above promises. Omitting it let an entry-only change reuse a previous
801            // artifact set's marker -- the exact reuse this digest exists to prevent.
802            "entry": self.entry,
803        });
804        let bytes = serde_json::to_vec(&canonical).expect("canonical native scope serializes");
805        format!("sha256:{:x}", Sha256::digest(bytes))
806    }
807
808    fn launch_value(&self) -> serde_json::Value {
809        json!({
810            "kind": self.kind,
811            "scope_id": self.scope_id,
812            "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
813                fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
814            }),
815            "binding": self.binding,
816        })
817    }
818
819    /// Render the complete, server-selected Codex permission profile. It deliberately has no
820    /// caller-selected filesystem, executable, PATH, browser, credential, or network input.
821    pub fn codex_permission_profile(&self) -> Result<String, DispatchError> {
822        let mut config = self
823            .wren_runtime
824            .as_ref()
825            .ok_or_else(|| {
826                DispatchError(
827                    "native Codex purpose requires a server-derived wren_runtime closure"
828                        .to_string(),
829                )
830            })?
831            .codex_permission_profile()?;
832        config.push_str(&format!(
833            "\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}.filesystem.\":workspace_roots\"]\n"
834        ));
835        if self.kind == "bootstrap" {
836            let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
837                DispatchError(
838                    "bootstrap native session scope requires a separately authorized bootstrap_root"
839                        .to_string(),
840                )
841            })?;
842            let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
843                DispatchError("bootstrap native session scope root is unavailable".to_string())
844            })?;
845            if *bootstrap_root != canonical_bootstrap_root {
846                return Err(DispatchError(
847                    "bootstrap native session scope root must already be canonical".to_string(),
848                ));
849            }
850            // The native cwd is a private artifact root: readable for discovery, never writable
851            // by Setup. Only the separately authorized bootstrap root receives write authority.
852            config.push_str("\".\" = \"read\"\n");
853            config.push_str(&format!(
854                "{} = \"write\"\n",
855                serde_json::to_string(&canonical_bootstrap_root.to_string_lossy())
856                    .expect("bootstrap root serializes")
857            ));
858        } else {
859            config.push_str("\".\" = \"write\"\n");
860        }
861        Ok(config)
862    }
863
864    /// Claude's permission grammar carries the same server-selected Setup
865    /// root as its only Edit/Write scope. The string is generated only after
866    /// NativeSessionScope::validate has canonicalized the sealed descriptor.
867    pub fn claude_setup_write_permissions(&self) -> Result<[String; 2], DispatchError> {
868        let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
869            DispatchError(
870                "bootstrap native session scope requires a separately authorized bootstrap_root"
871                    .to_string(),
872            )
873        })?;
874        let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
875            DispatchError("bootstrap native session scope root is unavailable".to_string())
876        })?;
877        if self.kind != "bootstrap" || *bootstrap_root != canonical_bootstrap_root {
878            return Err(DispatchError(
879                "bootstrap native session scope root is incompatible".to_string(),
880            ));
881        }
882        let recursive = canonical_bootstrap_root
883            .join("**")
884            .to_string_lossy()
885            .to_string();
886        Ok([format!("Edit({recursive})"), format!("Write({recursive})")])
887    }
888}
889
890impl NativeWrenRuntime {
891    fn validate(&self) -> Result<CanonicalWrenRuntime, DispatchError> {
892        if self.version != NATIVE_WREN_RUNTIME_VERSION {
893            return Err(DispatchError(format!(
894                "unsupported native Wren runtime version '{}' (expected: {NATIVE_WREN_RUNTIME_VERSION})",
895                self.version
896            )));
897        }
898
899        let tool_root = canonical_directory(&self.tool_root, "tool_root")?;
900        let interpreter_root = canonical_directory(&self.interpreter_root, "interpreter_root")?;
901        let site_packages = canonical_directory(&self.site_packages, "site_packages")?;
902        let source_root = canonical_directory(&self.source_root, "source_root")?;
903        let launcher = canonical_regular_file(&self.launcher, "launcher")?;
904        let interpreter = canonical_regular_file(&self.interpreter, "interpreter")?;
905        let shim = canonical_regular_file(&self.shim, "shim")?;
906        let venv_python = canonical_regular_file(&self.venv_python, "venv_python")?;
907        let shim_parent = canonical_directory(
908            self.shim.parent().ok_or_else(|| {
909                DispatchError("native Wren runtime shim must have a parent directory".to_string())
910            })?,
911            "shim parent",
912        )?;
913
914        let tool_bin = tool_root.join("bin");
915        let interpreter_bin = interpreter_root.join("bin");
916        let tool_pyvenv = tool_root.join("pyvenv.cfg");
917        let tool_lib = tool_root.join("lib");
918        let interpreter_lib = interpreter_root.join("lib");
919        for (path, label) in [
920            (&tool_bin, "tool_root/bin"),
921            (&interpreter_bin, "interpreter_root/bin"),
922            (&interpreter_lib, "interpreter_root/lib"),
923        ] {
924            if !path.is_dir() {
925                return Err(DispatchError(format!(
926                    "native Wren runtime {label} must be an existing directory"
927                )));
928            }
929        }
930        if !tool_pyvenv.is_file() {
931            return Err(DispatchError(
932                "native Wren runtime tool_root/pyvenv.cfg must be an existing regular file"
933                    .to_string(),
934            ));
935        }
936        if !site_packages.starts_with(&tool_lib) {
937            return Err(DispatchError(
938                "native Wren runtime site_packages must be inside tool_root/lib".to_string(),
939            ));
940        }
941        let editable_pth = site_packages.join("_editable_impl_wrenai.pth");
942        let editable_source = fs::read_to_string(&editable_pth)
943            .ok()
944            .and_then(|contents| {
945                let paths = contents
946                    .lines()
947                    .map(str::trim)
948                    .filter(|line| !line.is_empty())
949                    .collect::<Vec<_>>();
950                let [path] = paths.as_slice() else {
951                    return None;
952                };
953                Path::new(path).is_absolute().then(|| PathBuf::from(path))
954            })
955            .and_then(|path| fs::canonicalize(path).ok());
956        if editable_source.as_deref() != Some(source_root.as_path())
957            || !source_root.join("wren/__init__.py").is_file()
958        {
959            return Err(DispatchError(
960                "native Wren runtime editable source must exactly match the server-approved .pth closure"
961                    .to_string(),
962            ));
963        }
964
965        // The launcher and venv-Python names are fixed contract points rather than flexible
966        // command inputs. A server must resolve the real chain before handing it to Warble.
967        if self.launcher != tool_bin.join("wren") || self.venv_python != tool_bin.join("python") {
968            return Err(DispatchError(
969                "native Wren runtime launcher chain must use tool_root/bin/wren and tool_root/bin/python"
970                    .to_string(),
971            ));
972        }
973        if !fs::symlink_metadata(&self.shim)
974            .map_err(|e| {
975                DispatchError(format!(
976                    "inspect native Wren runtime shim {}: {e}",
977                    self.shim.display()
978                ))
979            })?
980            .file_type()
981            .is_symlink()
982        {
983            return Err(DispatchError(
984                "native Wren runtime shim must be a symlink to the server-approved launcher"
985                    .to_string(),
986            ));
987        }
988        if !fs::symlink_metadata(&self.venv_python)
989            .map_err(|e| {
990                DispatchError(format!(
991                    "inspect native Wren runtime venv_python {}: {e}",
992                    self.venv_python.display()
993                ))
994            })?
995            .file_type()
996            .is_symlink()
997        {
998            return Err(DispatchError(
999                "native Wren runtime tool_root/bin/python must be a symlink to the server-approved interpreter"
1000                    .to_string(),
1001            ));
1002        }
1003        if shim != launcher
1004            || venv_python != interpreter
1005            || !interpreter.starts_with(&interpreter_bin)
1006        {
1007            return Err(DispatchError(
1008                "native Wren runtime shim/interpreter chain does not resolve to the declared closure"
1009                    .to_string(),
1010            ));
1011        }
1012        if !is_executable(&launcher) || !is_executable(&interpreter) {
1013            return Err(DispatchError(
1014                "native Wren runtime launcher and interpreter must be executable regular files"
1015                    .to_string(),
1016            ));
1017        }
1018        let shebang = fs::read_to_string(&launcher)
1019            .map_err(|e| {
1020                DispatchError(format!(
1021                    "read native Wren launcher {}: {e}",
1022                    launcher.display()
1023                ))
1024            })?
1025            .lines()
1026            .next()
1027            .and_then(|line| line.strip_prefix("#!"))
1028            .filter(|path| !path.is_empty() && !path.contains(char::is_whitespace))
1029            .map(PathBuf::from)
1030            .ok_or_else(|| {
1031                DispatchError(
1032                    "native Wren runtime launcher must start with an absolute interpreter shebang"
1033                        .to_string(),
1034                )
1035            })?;
1036        if !shebang.is_absolute() || shebang != self.venv_python {
1037            return Err(DispatchError(
1038                "native Wren runtime launcher shebang must exactly name tool_root/bin/python"
1039                    .to_string(),
1040            ));
1041        }
1042
1043        Ok(CanonicalWrenRuntime {
1044            shim: self.shim.clone(),
1045            shim_parent,
1046            launcher: self.launcher.clone(),
1047            venv_python: self.venv_python.clone(),
1048            tool_bin,
1049            tool_pyvenv,
1050            site_packages: self.site_packages.clone(),
1051            interpreter: self.interpreter.clone(),
1052            interpreter_bin,
1053            interpreter_lib,
1054            source_root: self.source_root.clone(),
1055        })
1056    }
1057
1058    fn codex_permission_profile(&self) -> Result<String, DispatchError> {
1059        let paths = self.validate()?;
1060        let entry = |path: &Path| {
1061            format!(
1062                "{} = \"read\"\n",
1063                serde_json::to_string(&path.to_string_lossy()).expect("runtime path serializes")
1064            )
1065        };
1066        let mut config = format!(
1067            "# Server-owned native Wren runtime closure. `read` is Codex's read-and-execute\n# filesystem grant; no PATH, browser, credential, or caller filesystem input is accepted.\ndefault_permissions = \"{CODEX_WREN_PERMISSION_PROFILE}\"\n\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}]\ndescription = \"Warble native session workspace plus exact Wren runtime closure\"\n\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}.filesystem]\n\":minimal\" = \"read\"\n"
1068        );
1069        for path in [
1070            &paths.shim,
1071            &paths.shim_parent,
1072            &paths.tool_bin,
1073            &paths.launcher,
1074            &paths.venv_python,
1075            &paths.tool_pyvenv,
1076            &paths.site_packages,
1077            &paths.interpreter,
1078            &paths.interpreter_bin,
1079            &paths.interpreter_lib,
1080            &paths.source_root,
1081        ] {
1082            config.push_str(&entry(path));
1083        }
1084        Ok(config)
1085    }
1086}
1087
1088fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
1089    if !path.is_absolute() {
1090        return Err(DispatchError(format!(
1091            "native Wren runtime {label} must be an absolute server-derived path"
1092        )));
1093    }
1094    let canonical = fs::canonicalize(path).map_err(|e| {
1095        DispatchError(format!(
1096            "canonicalize native Wren runtime {label} {}: {e}",
1097            path.display()
1098        ))
1099    })?;
1100    canonical.is_dir().then_some(canonical).ok_or_else(|| {
1101        DispatchError(format!(
1102            "native Wren runtime {label} must be an existing directory"
1103        ))
1104    })
1105}
1106
1107fn canonical_regular_file(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
1108    if !path.is_absolute() {
1109        return Err(DispatchError(format!(
1110            "native Wren runtime {label} must be an absolute server-derived path"
1111        )));
1112    }
1113    let canonical = fs::canonicalize(path).map_err(|e| {
1114        DispatchError(format!(
1115            "canonicalize native Wren runtime {label} {}: {e}",
1116            path.display()
1117        ))
1118    })?;
1119    canonical.is_file().then_some(canonical).ok_or_else(|| {
1120        DispatchError(format!(
1121            "native Wren runtime {label} must be an existing regular file"
1122        ))
1123    })
1124}
1125
1126#[cfg(unix)]
1127fn is_executable(path: &Path) -> bool {
1128    use std::os::unix::fs::PermissionsExt;
1129
1130    fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
1131}
1132
1133#[cfg(not(unix))]
1134fn is_executable(_path: &Path) -> bool {
1135    false
1136}
1137
1138/// The only product purposes that may opt into the native Sessions launch contract.
1139///
1140/// This enum is intentionally closed: a caller cannot use the launch artifact to select an
1141/// arbitrary profile, agent, cwd, or command. The GenBI runtime chooses a purpose and vendor,
1142/// then materializes the corresponding Warble profile in a server-owned scope.
1143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1144pub enum NativePurpose {
1145    Analysis,
1146    Setup,
1147    ContextEnrichment,
1148}
1149
1150impl NativePurpose {
1151    pub fn parse(value: &str) -> Option<Self> {
1152        match value {
1153            "analysis" => Some(Self::Analysis),
1154            "setup" => Some(Self::Setup),
1155            "context_enrichment" => Some(Self::ContextEnrichment),
1156            _ => None,
1157        }
1158    }
1159
1160    pub fn as_str(self) -> &'static str {
1161        match self {
1162            Self::Analysis => "analysis",
1163            Self::Setup => "setup",
1164            Self::ContextEnrichment => "context_enrichment",
1165        }
1166    }
1167
1168    fn scope_kind(self) -> &'static str {
1169        match self {
1170            Self::Setup => "bootstrap",
1171            Self::Analysis | Self::ContextEnrichment => "bound_project",
1172        }
1173    }
1174
1175    pub fn codex_skill(self) -> &'static str {
1176        match self {
1177            Self::Analysis => "genbi-analysis",
1178            Self::Setup => "genbi-setup",
1179            Self::ContextEnrichment => "genbi-enrich-context",
1180        }
1181    }
1182
1183    pub fn codex_description(self) -> &'static str {
1184        match self {
1185            Self::Analysis => "Analyze the server-bound semantic project using the GenBI analysis behavior.",
1186            Self::Setup => "Connect a source and build a semantic context only in the server-created bootstrap scope.",
1187            Self::ContextEnrichment => "Inspect a pinned project and draft read-only enrichment proposals; never apply an enrichment.",
1188        }
1189    }
1190
1191    /// The one initial interactive prompt for this closed native purpose.
1192    ///
1193    /// This is deliberately data owned by the producer's purpose enum, rather
1194    /// than a browser value or terminal write. Both vendor CLIs accept one
1195    /// positional prompt while staying interactive, so it is passed as a
1196    /// single argv element without invoking a shell.
1197    /// Checks the caller's declared entry against the compiled IR.
1198    ///
1199    /// This replaced an allowlist of profile names. The allowlist could only answer "is this the
1200    /// profile I was built expecting", which says nothing about whether the thing about to be
1201    /// launched is safe to launch, and it tied this crate to one product's vocabulary. The
1202    /// question worth asking is structural, and it is asked of every profile equally: does the
1203    /// declared verb name exactly one component here, and is that component something a native
1204    /// interactive session may materialize at all?
1205    ///
1206    /// The caller therefore cannot widen what runs by naming a profile differently — only by
1207    /// naming a component that already satisfies these conditions.
1208    pub fn validate_profile(self, ir: &WarbleIr, entry: &NativeEntry) -> Result<(), DispatchError> {
1209        // This runs BEFORE `NativeSessionScope::validate`, so a malformed pair is reported here
1210        // rather than assumed away: the descriptor's own field checks have not happened yet.
1211        match (entry.kind, entry.verb.as_deref()) {
1212            (NativeEntryKind::Agent, Some(verb)) => self.validate_pinned_entry(ir, verb),
1213            (NativeEntryKind::Agent, None) => Err(DispatchError(format!(
1214                "native purpose '{}' entry kind 'agent' requires the component verb it pins",
1215                self.as_str()
1216            ))),
1217            (NativeEntryKind::Scope, None) => self.validate_scope_entry(ir),
1218            (NativeEntryKind::Scope, Some(verb)) => Err(DispatchError(format!(
1219                "native purpose '{}' declared scope entry but also named the verb '{verb}': scope entry must not name a verb",
1220                self.as_str()
1221            ))),
1222        }
1223    }
1224
1225    /// Whether `node` can be materialized as a native interactive agent in its own right.
1226    ///
1227    /// Factored out of the pinned check so scope entry can ask the same question of a whole
1228    /// profile instead of one named verb. The conditions are unchanged: a component is eligible
1229    /// exactly when pinning to it would have been allowed.
1230    fn is_native_entry_eligible(node: &crate::ir::ComponentNode) -> bool {
1231        node.id == node.verb
1232            && node.realization_kind == crate::ir::RealizationKind::Skill
1233            && node.trigger.kind == crate::ir::TriggerKind::OneShot
1234            && node.effect.outcome.kind == crate::ir::OutcomeKind::None
1235            && !node
1236                .required_capabilities
1237                .iter()
1238                .any(|capability| capability == "enrichment_apply:deterministic")
1239    }
1240
1241    fn validate_pinned_entry(self, ir: &WarbleIr, verb: &str) -> Result<(), DispatchError> {
1242        let entries = ir
1243            .components
1244            .iter()
1245            .filter(|node| node.verb == verb)
1246            .collect::<Vec<_>>();
1247        let [component] = entries.as_slice() else {
1248            return Err(DispatchError(format!(
1249                "native purpose '{}' requires exactly one component with the declared entry verb '{}', found {}",
1250                self.as_str(),
1251                verb,
1252                entries.len()
1253            )));
1254        };
1255        if !Self::is_native_entry_eligible(component) {
1256            return Err(DispatchError(format!(
1257                "native purpose '{}' entry '{}' is not materializable as a native interactive agent: it must be a one-shot skill with no outcome and must not require enrichment_apply:deterministic",
1258                self.as_str(),
1259                verb
1260            )));
1261        }
1262        Ok(())
1263    }
1264
1265    /// A scope entry names no component, so what is checked instead is that the scope is worth
1266    /// entering: more than one component the session could actually delegate to.
1267    ///
1268    /// One eligible component makes pinning strictly better — same behavior, but the top-level
1269    /// session carries that agent's declared `tools:` instead of the vendor default surface. Zero
1270    /// makes the scope inert: agents on disk, none of them launchable, and a session with nothing
1271    /// to delegate to would quietly do the work itself outside any component's guardrails.
1272    fn validate_scope_entry(self, ir: &WarbleIr) -> Result<(), DispatchError> {
1273        let eligible = ir
1274            .components
1275            .iter()
1276            .filter(|node| Self::is_native_entry_eligible(node))
1277            .count();
1278        if eligible < 2 {
1279            return Err(DispatchError(format!(
1280                "native purpose '{}' declared scope entry, but this profile materializes {} native-eligible component(s): scope entry needs at least 2 so the session has something to choose between; pin the entry instead",
1281                self.as_str(),
1282                eligible
1283            )));
1284        }
1285        Ok(())
1286    }
1287}
1288
1289pub struct InteractiveOutput {
1290    native_host: Option<crate::native_host::NativeHost>,
1291    pub root: PathBuf,
1292    pub launch_path: PathBuf,
1293    pub handoff_path: PathBuf,
1294    ownership_path: PathBuf,
1295    owned_paths: Vec<String>,
1296    marker: String,
1297    target: String,
1298    executable: String,
1299    purpose: Option<NativePurpose>,
1300    native_scope: Option<NativeSessionScope>,
1301    native_mcp: Option<NativeMcpDescriptor>,
1302    /// The materialized profile's id. A scope entry names it in place of a component, since the
1303    /// thing being entered is the profile itself.
1304    profile: String,
1305}
1306
1307enum ResolvedInteractiveOutput {
1308    Existing(PathBuf),
1309    Missing(PathBuf),
1310}
1311
1312fn resolve_interactive_output(path: &Path) -> Result<ResolvedInteractiveOutput, DispatchError> {
1313    let mut resolved = if path.is_absolute() {
1314        PathBuf::new()
1315    } else {
1316        let current = std::env::current_dir()
1317            .map_err(|e| DispatchError(format!("resolve current directory: {e}")))?;
1318        fs::canonicalize(&current).map_err(|e| {
1319            DispatchError(format!(
1320                "canonicalize current directory {}: {e}",
1321                current.display()
1322            ))
1323        })?
1324    };
1325    let mut missing = false;
1326    for component in path.components() {
1327        match component {
1328            Component::CurDir => {}
1329            Component::ParentDir => {
1330                if missing {
1331                    return Err(DispatchError(format!(
1332                        "interactive output cannot traverse '..' after a missing component: {}",
1333                        path.display()
1334                    )));
1335                }
1336                let parent = resolved.parent().unwrap_or(resolved.as_path());
1337                resolved = fs::canonicalize(parent).map_err(|e| {
1338                    DispatchError(format!(
1339                        "resolve interactive output parent {}: {e}",
1340                        parent.display()
1341                    ))
1342                })?;
1343            }
1344            Component::Prefix(_) | Component::RootDir => {
1345                resolved.push(component.as_os_str());
1346            }
1347            Component::Normal(name) if missing => resolved.push(name),
1348            Component::Normal(name) => {
1349                if !resolved.as_os_str().is_empty() {
1350                    let metadata = fs::metadata(&resolved).map_err(|e| {
1351                        DispatchError(format!(
1352                            "inspect interactive output ancestor {}: {e}",
1353                            resolved.display()
1354                        ))
1355                    })?;
1356                    if !metadata.is_dir() {
1357                        return Err(DispatchError(format!(
1358                            "interactive dispatch output has a non-directory ancestor: {}",
1359                            resolved.display()
1360                        )));
1361                    }
1362                }
1363                let next = resolved.join(name);
1364                match fs::symlink_metadata(&next) {
1365                    Ok(_) => {
1366                        resolved = fs::canonicalize(&next).map_err(|e| {
1367                            DispatchError(format!(
1368                                "resolve interactive output component {}: {e}",
1369                                next.display()
1370                            ))
1371                        })?;
1372                    }
1373                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1374                        resolved = next;
1375                        missing = true;
1376                    }
1377                    Err(error) => {
1378                        return Err(DispatchError(format!(
1379                            "inspect interactive output component {}: {error}",
1380                            next.display()
1381                        )))
1382                    }
1383                }
1384            }
1385        }
1386    }
1387
1388    if missing {
1389        Ok(ResolvedInteractiveOutput::Missing(resolved))
1390    } else if fs::metadata(&resolved)
1391        .map_err(|e| {
1392            DispatchError(format!(
1393                "inspect interactive dispatch output {}: {e}",
1394                path.display()
1395            ))
1396        })?
1397        .is_dir()
1398    {
1399        Ok(ResolvedInteractiveOutput::Existing(resolved))
1400    } else {
1401        Err(DispatchError(format!(
1402            "interactive dispatch output exists but is not a directory: {}",
1403            path.display()
1404        )))
1405    }
1406}
1407
1408#[allow(clippy::too_many_arguments)]
1409pub(crate) fn prepare_interactive_output_with_host(
1410    out_dir: &Path,
1411    target: &str,
1412    executable: &str,
1413    profile_signature: &str,
1414    profile: &str,
1415    owned_paths: &[PathBuf],
1416    purpose: Option<NativePurpose>,
1417    native_scope: Option<NativeSessionScope>,
1418    native_mcp: Option<NativeMcpDescriptor>,
1419    native_host: Option<crate::native_host::NativeHost>,
1420) -> Result<InteractiveOutput, DispatchError> {
1421    match (purpose, native_scope.as_ref()) {
1422        (Some(purpose), Some(scope)) => scope.validate_preflight(purpose)?,
1423        (Some(_), None) => {
1424            return Err(DispatchError(
1425                "native Sessions purpose requires a server-derived --native-scope descriptor"
1426                    .to_string(),
1427            ))
1428        }
1429        (None, Some(_)) => {
1430            return Err(DispatchError(
1431                "--native-scope requires a native Sessions --purpose".to_string(),
1432            ))
1433        }
1434        (None, None) => {}
1435    }
1436    match (purpose, native_mcp.as_ref()) {
1437        (Some(_), Some(descriptor)) => descriptor.validate()?,
1438        (Some(_), None) | (None, None) => {}
1439        (None, Some(_)) => {
1440            return Err(DispatchError(
1441                "--native-mcp requires a native Sessions --purpose".to_string(),
1442            ))
1443        }
1444    }
1445    let (prospective_root, create_root) = match resolve_interactive_output(out_dir)? {
1446        ResolvedInteractiveOutput::Existing(root) => (root, false),
1447        ResolvedInteractiveOutput::Missing(root) => (root, true),
1448    };
1449    if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
1450        scope.validate(purpose, &prospective_root)?;
1451    }
1452    if create_root {
1453        fs::create_dir_all(out_dir).map_err(|e| {
1454            DispatchError(format!(
1455                "create interactive output directory {}: {e}",
1456                out_dir.display()
1457            ))
1458        })?;
1459    }
1460    let root = fs::canonicalize(out_dir).map_err(|e| {
1461        DispatchError(format!(
1462            "canonicalize interactive output {}: {e}",
1463            out_dir.display()
1464        ))
1465    })?;
1466    if root != prospective_root {
1467        return Err(DispatchError(format!(
1468            "interactive output canonical path changed during creation: expected {}, got {}",
1469            prospective_root.display(),
1470            root.display()
1471        )));
1472    }
1473    if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
1474        scope.validate(purpose, &root)?;
1475    }
1476    let handoff_path = root.join("RUN.md");
1477    let launch_path = root.join(".warble/interactive-launch.json");
1478    ensure_inside(&root, &handoff_path)?;
1479    ensure_inside(&root, &launch_path)?;
1480    ensure_safe_path(&root, Path::new(".warble/interactive-launch.json"))?;
1481    ensure_safe_path(&root, Path::new(".warble/interactive-ownership.json"))?;
1482    let marker = format!(
1483        "<!-- warble-interactive-artifact target={target} profile={profile_signature}{}{}{} -->",
1484        native_scope
1485            .as_ref()
1486            .map(|scope| format!(" scope_digest={}", scope.ownership_digest(&root)))
1487            .unwrap_or_default(),
1488        native_mcp
1489            .as_ref()
1490            .map(|descriptor| format!(" mcp_digest={}", descriptor.ownership_digest()))
1491            .unwrap_or_default(),
1492        native_host
1493            .as_ref()
1494            .map(|host| format!(
1495                " host_digest={}",
1496                host.document()["host_plan_sha256"]
1497                    .as_str()
1498                    .expect("digest")
1499            ))
1500            .unwrap_or_default(),
1501    );
1502
1503    let mut planned = owned_paths
1504        .iter()
1505        .map(|path| path.to_string_lossy().to_string())
1506        .collect::<Vec<_>>();
1507    planned.sort();
1508    planned.dedup();
1509    let ownership_path = root.join(".warble/interactive-ownership.json");
1510    ensure_inside(&root, &ownership_path)?;
1511    for relative in owned_paths {
1512        ensure_safe_path(&root, relative)?;
1513    }
1514    let any_existing = planned.iter().any(|relative| root.join(relative).exists())
1515        || launch_path.exists()
1516        || ownership_path.exists();
1517    if any_existing {
1518        let existing = fs::read_to_string(&ownership_path).map_err(|_| {
1519            DispatchError(format!(
1520                "refusing to overwrite existing interactive artifacts: {} is missing",
1521                ownership_path.display()
1522            ))
1523        })?;
1524        verify_ownership(&existing, &marker, &planned, &root)?;
1525    }
1526    for relative in owned_paths {
1527        let path = root.join(relative);
1528        if path.exists()
1529            && matches!(
1530                path.file_name().and_then(|name| name.to_str()),
1531                Some("RUN.md" | "AGENTS.md" | "SKILL.md" | "CLAUDE.md")
1532            )
1533        {
1534            let contents = fs::read_to_string(&path).map_err(|e| {
1535                DispatchError(format!(
1536                    "read existing interactive artifact {}: {e}",
1537                    path.display()
1538                ))
1539            })?;
1540            if !contents.contains(&marker) {
1541                return Err(DispatchError(format!(
1542                    "refusing to overwrite user-owned interactive artifact {}; it lacks the expected Warble ownership marker",
1543                    path.display()
1544                )));
1545            }
1546        }
1547    }
1548    if launch_path.exists() {
1549        let existing = fs::read_to_string(&launch_path).map_err(|e| {
1550            DispatchError(format!(
1551                "read existing launch spec {}: {e}",
1552                launch_path.display()
1553            ))
1554        })?;
1555        if existing
1556            != render_launch_spec(LaunchSpecInputs {
1557                target,
1558                executable,
1559                profile,
1560                root: &root,
1561                handoff: &handoff_path,
1562                purpose,
1563                native_scope: native_scope.as_ref(),
1564                native_mcp: native_mcp.as_ref(),
1565                native_host: native_host.as_ref(),
1566            })?
1567        {
1568            return Err(DispatchError(format!(
1569                "refusing to overwrite user-owned or mismatched launch spec {}",
1570                launch_path.display()
1571            )));
1572        }
1573    }
1574    Ok(InteractiveOutput {
1575        native_host,
1576        root,
1577        launch_path,
1578        handoff_path,
1579        ownership_path,
1580        owned_paths: planned,
1581        marker,
1582        target: target.into(),
1583        executable: executable.into(),
1584        purpose,
1585        native_scope,
1586        native_mcp,
1587        profile: profile.into(),
1588    })
1589}
1590
1591impl InteractiveOutput {
1592    pub fn marker(&self) -> &str {
1593        &self.marker
1594    }
1595    pub fn write_launch_spec(&self) -> Result<(), DispatchError> {
1596        let parent = self.launch_path.parent().expect(".warble parent");
1597        fs::create_dir_all(parent)
1598            .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1599        fs::write(
1600            &self.launch_path,
1601            render_launch_spec(LaunchSpecInputs {
1602                target: &self.target,
1603                executable: &self.executable,
1604                profile: &self.profile,
1605                root: &self.root,
1606                handoff: &self.handoff_path,
1607                purpose: self.purpose,
1608                native_scope: self.native_scope.as_ref(),
1609                native_mcp: self.native_mcp.as_ref(),
1610                native_host: self.native_host.as_ref(),
1611            })?,
1612        )
1613        .map_err(|e| {
1614            DispatchError(format!(
1615                "write launch spec {}: {e}",
1616                self.launch_path.display()
1617            ))
1618        })
1619    }
1620    pub fn write_ownership(&self) -> Result<(), DispatchError> {
1621        if let Some(host) = &self.native_host {
1622            use std::io::Write;
1623            fs::create_dir_all(self.root.join(".warble"))
1624                .map_err(|e| DispatchError(e.to_string()))?;
1625            let path = self.root.join(".warble/component-plans.json");
1626            let mut options = fs::OpenOptions::new();
1627            options.write(true).create(true).truncate(true);
1628            #[cfg(unix)]
1629            {
1630                use std::os::unix::fs::OpenOptionsExt;
1631                options.mode(0o600);
1632            }
1633            let mut file = options
1634                .open(&path)
1635                .map_err(|e| DispatchError(e.to_string()))?;
1636            #[cfg(unix)]
1637            {
1638                use std::os::unix::fs::PermissionsExt;
1639                // Creation mode does not narrow an existing file on repeat dispatch.
1640                file.set_permissions(fs::Permissions::from_mode(0o600))
1641                    .map_err(|e| DispatchError(e.to_string()))?;
1642            }
1643            file.write_all(host.document().to_string().as_bytes())
1644                .and_then(|()| file.sync_all())
1645                .map_err(|e| DispatchError(e.to_string()))?;
1646        }
1647        let parent = self.ownership_path.parent().expect(".warble parent");
1648        fs::create_dir_all(parent)
1649            .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1650        fs::write(
1651            &self.ownership_path,
1652            ownership_document(&self.marker, &self.owned_paths, &self.root)?,
1653        )
1654        .map_err(|e| {
1655            DispatchError(format!(
1656                "write ownership record {}: {e}",
1657                self.ownership_path.display()
1658            ))
1659        })
1660    }
1661}
1662
1663fn ensure_inside(root: &Path, path: &Path) -> Result<(), DispatchError> {
1664    if !path.starts_with(root) {
1665        return Err(DispatchError(format!(
1666            "interactive artifact path escapes canonical output root: {}",
1667            path.display()
1668        )));
1669    }
1670    Ok(())
1671}
1672
1673/// Reject unsafe existing path components before any output write. A lexical `starts_with` check
1674/// is not a containment proof when `.claude`, `.agents`, or `.warble` can redirect writes
1675/// elsewhere, and a regular-file parent would make creation order-dependent.
1676fn ensure_safe_path(root: &Path, relative: &Path) -> Result<(), DispatchError> {
1677    if relative.is_absolute()
1678        || relative
1679            .components()
1680            .any(|component| matches!(component, std::path::Component::ParentDir))
1681    {
1682        return Err(DispatchError(format!(
1683            "interactive artifact path escapes canonical output root: {}",
1684            relative.display()
1685        )));
1686    }
1687    let components = relative.components().collect::<Vec<_>>();
1688    let mut cursor = root.to_path_buf();
1689    for (index, component) in components.iter().enumerate() {
1690        cursor.push(component.as_os_str());
1691        if let Ok(metadata) = fs::symlink_metadata(&cursor) {
1692            if metadata.file_type().is_symlink() {
1693                return Err(DispatchError(format!(
1694                    "refusing interactive artifact path with symlink component: {}",
1695                    cursor.display()
1696                )));
1697            }
1698            if index + 1 < components.len() && !metadata.file_type().is_dir() {
1699                return Err(DispatchError(format!(
1700                    "refusing interactive artifact path with non-directory ancestor component: {}",
1701                    cursor.display()
1702                )));
1703            }
1704        }
1705    }
1706    Ok(())
1707}
1708
1709/// Everything the launch spec is rendered from, grouped so the renderer keeps one parameter as it
1710/// grows. These are all identity or location values the emitter already holds; nothing here is
1711/// derived, so the struct is a bundle rather than a stage.
1712struct LaunchSpecInputs<'a> {
1713    target: &'a str,
1714    executable: &'a str,
1715    profile: &'a str,
1716    root: &'a Path,
1717    handoff: &'a Path,
1718    purpose: Option<NativePurpose>,
1719    native_scope: Option<&'a NativeSessionScope>,
1720    native_mcp: Option<&'a NativeMcpDescriptor>,
1721    native_host: Option<&'a crate::native_host::NativeHost>,
1722}
1723
1724fn render_launch_spec(inputs: LaunchSpecInputs<'_>) -> Result<String, DispatchError> {
1725    let LaunchSpecInputs {
1726        target,
1727        executable,
1728        profile,
1729        root,
1730        handoff,
1731        purpose,
1732        native_scope,
1733        native_mcp,
1734        native_host,
1735    } = inputs;
1736    ensure_inside(root, handoff)?;
1737    // This is intentionally the entire schema: no command string, prompt/model material, auth,
1738    // provider state, or session identity can be represented here.
1739    let mut document = match (purpose, native_mcp) {
1740        // v1 remains byte-for-byte schema-compatible for its enrichment consumer.
1741        (None, None) => json!({
1742            "version": LAUNCH_SPEC_VERSION,
1743            "target": target,
1744            "executable": executable,
1745            "argv": [],
1746            "cwd": root,
1747            "artifact_root": root,
1748            "handoff_path": handoff,
1749        }),
1750        (Some(purpose), None) => json!({
1751            "version": NATIVE_SESSION_LAUNCH_SPEC_VERSION,
1752            "target": target,
1753            "purpose": purpose.as_str(),
1754            "executable": executable,
1755            // Claude selects an agent with `--agent` when the caller pinned one, and otherwise
1756            // starts at the scope document with no selection flag at all — `--agent` is this
1757            // dispatcher's choice, never a requirement of the CLI. Codex loads the named skill
1758            // from its repository-scoped discovery artifacts and has no scope form. The argv is
1759            // still authored here and nowhere else; what it says comes from the caller's declared
1760            // entry, already checked against the IR by `validate_profile`.
1761            "argv": if target == "claude-code:interactive" {
1762                match native_scope.expect("v3 native scope preflighted").entry.pinned_verb() {
1763                    Some(verb) => json!(["--agent", verb]),
1764                    None => json!([]),
1765                }
1766            } else {
1767                json!([])
1768            },
1769            "agent": if target == "claude-code:interactive" {
1770                match native_scope.expect("v3 native scope preflighted").entry.pinned_verb() {
1771                    Some(verb) => json!({ "kind": "claude_agent", "name": verb }),
1772                    None => json!({ "kind": "claude_scope", "name": profile }),
1773                }
1774            } else {
1775                json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1776            },
1777            "scope": native_scope.expect("v3 native scope preflighted").launch_value(),
1778            "cwd": root,
1779            "artifact_root": root,
1780            "handoff_path": handoff,
1781        }),
1782        (Some(purpose), Some(_)) => {
1783            let entry = &native_scope.expect("v3 native scope preflighted").entry;
1784            let mut document = json!({
1785                "version": NATIVE_SESSION_MCP_LAUNCH_SPEC_VERSION,
1786                "target": target,
1787                "purpose": purpose.as_str(),
1788                "executable": executable,
1789            // Both interactive CLIs accept one positional prompt. Keeping it
1790            // in this closed argv contract makes the first turn exactly-once
1791            // without a shell or post-spawn PTY input injection.
1792                "argv": if target == "claude-code:interactive" {
1793                    match entry.pinned_verb() {
1794                        Some(verb) => json!(["--agent", verb, entry.prompt]),
1795                        // Scope entry drops only the selection flag. The prompt stays positional,
1796                        // so the first turn is still delivered exactly once by the same contract.
1797                        None => json!([entry.prompt]),
1798                    }
1799                } else {
1800                    json!([entry.prompt])
1801                },
1802                "agent": if target == "claude-code:interactive" {
1803                    match entry.pinned_verb() {
1804                        Some(verb) => json!({ "kind": "claude_agent", "name": verb }),
1805                        None => json!({ "kind": "claude_scope", "name": profile }),
1806                    }
1807                } else {
1808                    json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1809                },
1810                "mcp": {
1811                    "server_name": NATIVE_MCP_SERVER_NAME,
1812                    "credential_env_var": NATIVE_MCP_CREDENTIAL_ENV_VAR,
1813                },
1814                "cwd": root,
1815                "artifact_root": root,
1816                "handoff_path": handoff,
1817            });
1818            if purpose == NativePurpose::Setup {
1819                document
1820                    .as_object_mut()
1821                    .expect("launch spec is an object")
1822                    .insert(
1823                        "bootstrap_root".to_string(),
1824                        serde_json::to_value(
1825                            native_scope
1826                                .expect("v4 Setup native scope preflighted")
1827                                .bootstrap_root
1828                                .as_ref()
1829                                .map(|root| {
1830                                    fs::canonicalize(root)
1831                                        .expect("validated bootstrap root remains canonicalizable")
1832                                }),
1833                        )
1834                        .expect("bootstrap root serializes"),
1835                    );
1836            }
1837            document
1838        }
1839        (None, Some(_)) => {
1840            return Err(DispatchError(
1841                "--native-mcp requires a native Sessions --purpose".to_string(),
1842            ))
1843        }
1844    };
1845    if let Some(host) = native_host {
1846        document["version"] = json!("5");
1847        document["component_host"] = host.launch_value();
1848    }
1849    serde_json::to_string_pretty(&document)
1850        .map(|v| format!("{v}\n"))
1851        .map_err(|e| DispatchError(e.to_string()))
1852}
1853
1854fn ownership_document(
1855    marker: &str,
1856    paths: &[String],
1857    root: &Path,
1858) -> Result<String, DispatchError> {
1859    let artifacts = paths
1860        .iter()
1861        .map(|path| {
1862            let bytes = fs::read(root.join(path))
1863                .map_err(|e| DispatchError(format!("read owned artifact {path}: {e}")))?;
1864            Ok(json!({ "path": path, "sha256": format!("{:x}", Sha256::digest(bytes)) }))
1865        })
1866        .collect::<Result<Vec<_>, DispatchError>>()?;
1867    serde_json::to_string_pretty(&json!({ "marker": marker, "artifacts": artifacts }))
1868        .map(|v| format!("{v}\n"))
1869        .map_err(|e| DispatchError(e.to_string()))
1870}
1871
1872fn verify_ownership(
1873    existing: &str,
1874    marker: &str,
1875    paths: &[String],
1876    root: &Path,
1877) -> Result<(), DispatchError> {
1878    let value: serde_json::Value = serde_json::from_str(existing).map_err(|_| {
1879        DispatchError("refusing to overwrite malformed interactive ownership record".to_string())
1880    })?;
1881    if value.get("marker").and_then(serde_json::Value::as_str) != Some(marker) {
1882        return Err(DispatchError("refusing to overwrite artifacts not owned by this exact Warble interactive emission plan".to_string()));
1883    }
1884    let artifacts = value
1885        .get("artifacts")
1886        .and_then(serde_json::Value::as_array)
1887        .ok_or_else(|| {
1888            DispatchError(
1889                "refusing to overwrite ownership record without per-artifact digests".to_string(),
1890            )
1891        })?;
1892    if artifacts.len() != paths.len() {
1893        return Err(DispatchError(
1894            "refusing to overwrite ownership record with a different artifact plan".to_string(),
1895        ));
1896    }
1897    for (artifact, path) in artifacts.iter().zip(paths) {
1898        if artifact.get("path").and_then(serde_json::Value::as_str) != Some(path.as_str()) {
1899            return Err(DispatchError(
1900                "refusing to overwrite ownership record with a different artifact plan".to_string(),
1901            ));
1902        }
1903        let bytes = fs::read(root.join(path)).map_err(|_| {
1904            DispatchError(format!(
1905                "refusing to overwrite missing owned artifact {path}"
1906            ))
1907        })?;
1908        let digest = format!("{:x}", Sha256::digest(bytes));
1909        if artifact.get("sha256").and_then(serde_json::Value::as_str) != Some(digest.as_str()) {
1910            return Err(DispatchError(format!(
1911                "refusing to overwrite modified owned artifact {path}"
1912            )));
1913        }
1914    }
1915    Ok(())
1916}
1917
1918#[cfg(test)]
1919mod tests {
1920    use super::NativeMcpDescriptor;
1921
1922    #[test]
1923    fn native_mcp_descriptor_debug_redacts_the_opaque_credential() {
1924        let descriptor = NativeMcpDescriptor {
1925            version: "1".to_string(),
1926            url: "https://mcp.example.test/native".to_string(),
1927            credential: "sentinel-opaque-native-mcp-credential".to_string(),
1928        };
1929
1930        let rendered = format!("{descriptor:?}");
1931        assert!(rendered.contains("NativeMcpDescriptor"));
1932        assert!(rendered.contains("version: \"1\""));
1933        assert!(rendered.contains("url: \"https://mcp.example.test/native\""));
1934        assert!(rendered.contains("credential: \"[REDACTED]\""));
1935        assert!(!rendered.contains("sentinel-opaque-native-mcp-credential"));
1936    }
1937}