Skip to main content

warble_claude_code/
interactive.rs

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