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