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::{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 = "2";
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    /// Setup alone carries this separately authorized project-creation root.
326    /// `cwd` remains the native materialization root and must equal `--out`.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub bootstrap_root: Option<PathBuf>,
329    /// Optional to preserve the existing cross-vendor native scope contract. Native Codex
330    /// materialization requires this exact server-resolved launcher and Python runtime closure;
331    /// it is never derived from the browser, environment, or PATH by the dispatcher.
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub wren_runtime: Option<NativeWrenRuntime>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub binding: Option<NativeBinding>,
336}
337
338#[derive(Debug, Clone, Deserialize, Serialize)]
339#[serde(deny_unknown_fields)]
340pub struct NativeBinding {
341    pub project_identity: String,
342    pub generation: String,
343    pub revision: String,
344}
345
346/// A closed, server-derived Wren launcher chain. The Codex sandbox's `read` filesystem grant
347/// includes execution permission, so its rendered entries must be the minimum transitive closure:
348/// the PATH shim, the generated console-script launcher, the venv Python symlink, both runtime
349/// library roots, and the server-pinned editable Wren source root that the venv's closed `.pth`
350/// resolver names.
351#[derive(Debug, Clone, Deserialize, Serialize)]
352#[serde(deny_unknown_fields)]
353pub struct NativeWrenRuntime {
354    pub version: String,
355    pub shim: PathBuf,
356    pub launcher: PathBuf,
357    pub venv_python: PathBuf,
358    pub tool_root: PathBuf,
359    pub site_packages: PathBuf,
360    pub source_root: PathBuf,
361    pub interpreter: PathBuf,
362    pub interpreter_root: PathBuf,
363}
364
365struct CanonicalWrenRuntime {
366    shim: PathBuf,
367    shim_parent: PathBuf,
368    launcher: PathBuf,
369    venv_python: PathBuf,
370    tool_bin: PathBuf,
371    tool_pyvenv: PathBuf,
372    site_packages: PathBuf,
373    interpreter: PathBuf,
374    interpreter_bin: PathBuf,
375    interpreter_lib: PathBuf,
376    source_root: PathBuf,
377}
378
379/// Exact server-derived connection material for the allowlisted native-session MCP server.
380///
381/// The credential is deliberately opaque to Warble. It is emitted only into the vendor discovery
382/// mechanism that needs it: Claude's HTTP header or Codex's dedicated credential environment
383/// variable. It never appears in the launch spec, ownership record, agent Markdown, argv, or a
384/// prompt. The host resolves this credential to live session/vendor/project/revision/capability
385/// state when the native client connects.
386#[derive(Clone, Deserialize)]
387#[serde(deny_unknown_fields)]
388pub struct NativeMcpDescriptor {
389    version: String,
390    url: String,
391    credential: String,
392}
393
394impl std::fmt::Debug for NativeMcpDescriptor {
395    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
396        formatter
397            .debug_struct("NativeMcpDescriptor")
398            .field("version", &self.version)
399            .field("url", &self.url)
400            .field("credential", &"[REDACTED]")
401            .finish()
402    }
403}
404
405impl NativeMcpDescriptor {
406    pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
407        let raw = fs::read_to_string(path).map_err(|e| {
408            DispatchError(format!(
409                "read native MCP descriptor {}: {e}",
410                path.display()
411            ))
412        })?;
413        serde_json::from_str(&raw).map_err(|e| {
414            DispatchError(format!(
415                "parse native MCP descriptor {}: {e}",
416                path.display()
417            ))
418        })
419    }
420
421    fn validate(&self) -> Result<(), DispatchError> {
422        if self.version != NATIVE_MCP_DESCRIPTOR_VERSION {
423            return Err(DispatchError(format!(
424                "unsupported native MCP descriptor version '{}' (expected: {NATIVE_MCP_DESCRIPTOR_VERSION})",
425                self.version
426            )));
427        }
428        // The generic descriptor has no redirect, DNS, or vendor-specific transport
429        // semantics. Parse its authority before accepting it: HTTPS may use any exact
430        // parsed host, while HTTP is confined to a literal loopback host on this machine.
431        // The credential, not the URL, carries the bounded session binding.
432        let url = Url::parse(&self.url).map_err(|_| {
433            DispatchError(
434                "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
435            )
436        })?;
437        let loopback = match url.host() {
438            Some(Host::Domain("localhost")) => true,
439            Some(Host::Ipv4(address)) => address.is_loopback(),
440            Some(Host::Ipv6(address)) => address.is_loopback(),
441            _ => false,
442        };
443        let allowed_scheme = url.scheme() == "https" || url.scheme() == "http" && loopback;
444        // `Url::scheme()` provides the case-normalized scheme. Retain an explicit `://`
445        // shape check so a parsed relative/scheme-only URL can never stand in for a
446        // network authority.
447        let has_network_authority = self
448            .url
449            .split_once(':')
450            .is_some_and(|(_, after_scheme)| after_scheme.starts_with("//"));
451        if self.url.len() > 2048
452            || url.host().is_none()
453            || !url.username().is_empty()
454            || url.password().is_some()
455            || url.query().is_some()
456            || url.fragment().is_some()
457            || self.url.chars().any(char::is_control)
458            || self.url.chars().any(char::is_whitespace)
459            || !has_network_authority
460            || !allowed_scheme
461        {
462            return Err(DispatchError(
463                "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
464            ));
465        }
466        if self.credential.is_empty()
467            || self.credential.len() > 4096
468            || self.credential.chars().any(char::is_control)
469            || self.credential.chars().any(char::is_whitespace)
470        {
471            return Err(DispatchError(
472                "native MCP descriptor requires a bounded opaque credential".to_string(),
473            ));
474        }
475        Ok(())
476    }
477
478    fn ownership_digest(&self) -> String {
479        let bytes = serde_json::to_vec(&json!({
480            "version": self.version,
481            "url": self.url,
482            "credential": self.credential,
483        }))
484        .expect("canonical native MCP descriptor serializes");
485        format!("sha256:{:x}", Sha256::digest(bytes))
486    }
487
488    pub fn claude_discovery_config(&self) -> Result<String, DispatchError> {
489        serde_json::to_string_pretty(&json!({
490            "mcpServers": {
491                NATIVE_MCP_SERVER_NAME: {
492                    "type": "http",
493                    "url": self.url,
494                    "headers": { "Authorization": format!("Bearer {}", self.credential) },
495                }
496            }
497        }))
498        .map(|value| format!("{value}\n"))
499        .map_err(|e| DispatchError(e.to_string()))
500    }
501
502    pub fn codex_discovery_config(
503        &self,
504        enable_setup_recovery_tool: bool,
505        enable_dashboard_save_tool: bool,
506        enable_persist_answer_tool: bool,
507    ) -> String {
508        // JSON strings are valid TOML basic strings and give us a single established escaping
509        // primitive for the server URL. Codex reads the credential only at native-process launch.
510        let mut config = format!(
511            "[mcp_servers.{NATIVE_MCP_SERVER_NAME}]\nurl = {}\nbearer_token_env_var = {}\n",
512            serde_json::to_string(&self.url).expect("MCP URL serializes"),
513            serde_json::to_string(NATIVE_MCP_CREDENTIAL_ENV_VAR)
514                .expect("credential env name serializes"),
515        );
516        let mut enabled_tools = Vec::new();
517        if enable_setup_recovery_tool {
518            enabled_tools.push("report_setup_recovery");
519        }
520        if enable_dashboard_save_tool {
521            enabled_tools.push(NATIVE_DASHBOARD_SAVE_TOOL);
522        }
523        if enable_persist_answer_tool {
524            enabled_tools.push(NATIVE_PERSIST_ANSWER_TOOL);
525        }
526        if !enabled_tools.is_empty() {
527            config.push_str(&format!(
528                "enabled_tools = {}\n",
529                serde_json::to_string(&enabled_tools).expect("MCP tool names serialize"),
530            ));
531        }
532        config
533    }
534}
535
536impl NativeSessionScope {
537    pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
538        let raw = fs::read_to_string(path).map_err(|e| {
539            DispatchError(format!("read native session scope {}: {e}", path.display()))
540        })?;
541        serde_json::from_str(&raw).map_err(|e| {
542            DispatchError(format!(
543                "parse native session scope {}: {e}",
544                path.display()
545            ))
546        })
547    }
548
549    fn validate(&self, purpose: NativePurpose, root: &Path) -> Result<(), DispatchError> {
550        if self.version != NATIVE_SCOPE_VERSION {
551            return Err(DispatchError(format!(
552                "unsupported native session scope version '{}' (expected: {NATIVE_SCOPE_VERSION})",
553                self.version
554            )));
555        }
556        if self.kind != purpose.scope_kind() {
557            return Err(DispatchError(format!(
558                "native session scope kind '{}' does not match purpose '{}' ({})",
559                self.kind,
560                purpose.as_str(),
561                purpose.scope_kind()
562            )));
563        }
564        if self.scope_id.trim().is_empty() {
565            return Err(DispatchError(
566                "native session scope requires a non-empty opaque scope_id".to_string(),
567            ));
568        }
569        if !self.cwd.is_absolute() {
570            return Err(DispatchError(
571                "native session scope cwd must be an absolute server-derived path".to_string(),
572            ));
573        }
574        let scope_cwd = fs::canonicalize(&self.cwd).map_err(|e| {
575            DispatchError(format!(
576                "canonicalize native session scope cwd {}: {e}",
577                self.cwd.display()
578            ))
579        })?;
580        if self.cwd != scope_cwd {
581            return Err(DispatchError(
582                "native session scope cwd must already be canonical".to_string(),
583            ));
584        }
585        if scope_cwd != root {
586            return Err(DispatchError(format!(
587                "native session scope cwd {} does not match canonical output root {}",
588                scope_cwd.display(),
589                root.display()
590            )));
591        }
592        match (&self.kind[..], &self.binding, &self.bootstrap_root) {
593            ("bootstrap", None, Some(bootstrap_root)) => {
594                if !bootstrap_root.is_absolute() {
595                    return Err(DispatchError(
596                        "bootstrap native session scope root must be an absolute server-derived path".to_string(),
597                    ));
598                }
599                let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
600                    DispatchError(format!(
601                        "canonicalize bootstrap native session scope root {}: {e}",
602                        bootstrap_root.display()
603                    ))
604                })?;
605                if *bootstrap_root != canonical_bootstrap_root {
606                    return Err(DispatchError(
607                        "bootstrap native session scope root must already be canonical".to_string(),
608                    ));
609                }
610                if canonical_bootstrap_root == root {
611                    return Err(DispatchError(
612                        "bootstrap native session scope root must be distinct from the canonical output root".to_string(),
613                    ));
614                }
615                Ok(())
616            }
617            ("bootstrap", None, None) => Err(DispatchError(
618                "bootstrap native session scope requires a separately authorized bootstrap_root".to_string(),
619            )),
620            ("bootstrap", Some(_), _) => Err(DispatchError(
621                "bootstrap native session scope must not carry a bound-project identity".to_string(),
622            )),
623            ("bound_project", Some(binding), None)
624                if !binding.project_identity.trim().is_empty()
625                    && !binding.generation.trim().is_empty()
626                    && !binding.revision.trim().is_empty() =>
627            {
628                Ok(())
629            }
630            ("bound_project", _, Some(_)) => Err(DispatchError(
631                "bound_project native session scope must not carry a bootstrap_root".to_string(),
632            )),
633            ("bound_project", _, None) => Err(DispatchError(
634                "bound_project native session scope requires non-empty project_identity, generation, and revision"
635                    .to_string(),
636            )),
637            _ => unreachable!("purpose mapping has a closed scope vocabulary"),
638        }
639    }
640
641    fn ownership_digest(&self, canonical_cwd: &Path) -> String {
642        // This is a collision identifier only, not a signature or provenance proof. GenBI owns
643        // invocation authorization; the digest merely keeps opaque caller values out of Markdown
644        // ownership markers while ensuring a changed descriptor cannot reuse an old artifact set.
645        let canonical = json!({
646            "version": self.version,
647            "kind": self.kind,
648            "scope_id": self.scope_id,
649            "cwd": canonical_cwd,
650            "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
651                fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
652            }),
653            "wren_runtime": self.wren_runtime,
654            "binding": self.binding,
655        });
656        let bytes = serde_json::to_vec(&canonical).expect("canonical native scope serializes");
657        format!("sha256:{:x}", Sha256::digest(bytes))
658    }
659
660    fn launch_value(&self) -> serde_json::Value {
661        json!({
662            "kind": self.kind,
663            "scope_id": self.scope_id,
664            "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
665                fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
666            }),
667            "binding": self.binding,
668        })
669    }
670
671    /// Render the complete, server-selected Codex permission profile. It deliberately has no
672    /// caller-selected filesystem, executable, PATH, browser, credential, or network input.
673    pub fn codex_permission_profile(&self) -> Result<String, DispatchError> {
674        let mut config = self
675            .wren_runtime
676            .as_ref()
677            .ok_or_else(|| {
678                DispatchError(
679                    "native Codex purpose requires a server-derived wren_runtime closure"
680                        .to_string(),
681                )
682            })?
683            .codex_permission_profile()?;
684        config.push_str(&format!(
685            "\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}.filesystem.\":workspace_roots\"]\n"
686        ));
687        if self.kind == "bootstrap" {
688            let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
689                DispatchError(
690                    "bootstrap native session scope requires a separately authorized bootstrap_root"
691                        .to_string(),
692                )
693            })?;
694            let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
695                DispatchError("bootstrap native session scope root is unavailable".to_string())
696            })?;
697            if *bootstrap_root != canonical_bootstrap_root {
698                return Err(DispatchError(
699                    "bootstrap native session scope root must already be canonical".to_string(),
700                ));
701            }
702            // The native cwd is a private artifact root: readable for discovery, never writable
703            // by Setup. Only the separately authorized bootstrap root receives write authority.
704            config.push_str("\".\" = \"read\"\n");
705            config.push_str(&format!(
706                "{} = \"write\"\n",
707                serde_json::to_string(&canonical_bootstrap_root.to_string_lossy())
708                    .expect("bootstrap root serializes")
709            ));
710        } else {
711            config.push_str("\".\" = \"write\"\n");
712        }
713        Ok(config)
714    }
715
716    /// Claude's permission grammar carries the same server-selected Setup
717    /// root as its only Edit/Write scope. The string is generated only after
718    /// NativeSessionScope::validate has canonicalized the sealed descriptor.
719    pub fn claude_setup_write_permissions(&self) -> Result<[String; 2], DispatchError> {
720        let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
721            DispatchError(
722                "bootstrap native session scope requires a separately authorized bootstrap_root"
723                    .to_string(),
724            )
725        })?;
726        let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
727            DispatchError("bootstrap native session scope root is unavailable".to_string())
728        })?;
729        if self.kind != "bootstrap" || *bootstrap_root != canonical_bootstrap_root {
730            return Err(DispatchError(
731                "bootstrap native session scope root is incompatible".to_string(),
732            ));
733        }
734        let recursive = canonical_bootstrap_root
735            .join("**")
736            .to_string_lossy()
737            .to_string();
738        Ok([format!("Edit({recursive})"), format!("Write({recursive})")])
739    }
740}
741
742impl NativeWrenRuntime {
743    fn validate(&self) -> Result<CanonicalWrenRuntime, DispatchError> {
744        if self.version != NATIVE_WREN_RUNTIME_VERSION {
745            return Err(DispatchError(format!(
746                "unsupported native Wren runtime version '{}' (expected: {NATIVE_WREN_RUNTIME_VERSION})",
747                self.version
748            )));
749        }
750
751        let tool_root = canonical_directory(&self.tool_root, "tool_root")?;
752        let interpreter_root = canonical_directory(&self.interpreter_root, "interpreter_root")?;
753        let site_packages = canonical_directory(&self.site_packages, "site_packages")?;
754        let source_root = canonical_directory(&self.source_root, "source_root")?;
755        let launcher = canonical_regular_file(&self.launcher, "launcher")?;
756        let interpreter = canonical_regular_file(&self.interpreter, "interpreter")?;
757        let shim = canonical_regular_file(&self.shim, "shim")?;
758        let venv_python = canonical_regular_file(&self.venv_python, "venv_python")?;
759        let shim_parent = canonical_directory(
760            self.shim.parent().ok_or_else(|| {
761                DispatchError("native Wren runtime shim must have a parent directory".to_string())
762            })?,
763            "shim parent",
764        )?;
765
766        let tool_bin = tool_root.join("bin");
767        let interpreter_bin = interpreter_root.join("bin");
768        let tool_pyvenv = tool_root.join("pyvenv.cfg");
769        let tool_lib = tool_root.join("lib");
770        let interpreter_lib = interpreter_root.join("lib");
771        for (path, label) in [
772            (&tool_bin, "tool_root/bin"),
773            (&interpreter_bin, "interpreter_root/bin"),
774            (&interpreter_lib, "interpreter_root/lib"),
775        ] {
776            if !path.is_dir() {
777                return Err(DispatchError(format!(
778                    "native Wren runtime {label} must be an existing directory"
779                )));
780            }
781        }
782        if !tool_pyvenv.is_file() {
783            return Err(DispatchError(
784                "native Wren runtime tool_root/pyvenv.cfg must be an existing regular file"
785                    .to_string(),
786            ));
787        }
788        if !site_packages.starts_with(&tool_lib) {
789            return Err(DispatchError(
790                "native Wren runtime site_packages must be inside tool_root/lib".to_string(),
791            ));
792        }
793        let editable_pth = site_packages.join("_editable_impl_wrenai.pth");
794        let editable_source = fs::read_to_string(&editable_pth)
795            .ok()
796            .and_then(|contents| {
797                let paths = contents
798                    .lines()
799                    .map(str::trim)
800                    .filter(|line| !line.is_empty())
801                    .collect::<Vec<_>>();
802                let [path] = paths.as_slice() else {
803                    return None;
804                };
805                Path::new(path).is_absolute().then(|| PathBuf::from(path))
806            })
807            .and_then(|path| fs::canonicalize(path).ok());
808        if editable_source.as_deref() != Some(source_root.as_path())
809            || !source_root.join("wren/__init__.py").is_file()
810        {
811            return Err(DispatchError(
812                "native Wren runtime editable source must exactly match the server-approved .pth closure"
813                    .to_string(),
814            ));
815        }
816
817        // The launcher and venv-Python names are fixed contract points rather than flexible
818        // command inputs. A server must resolve the real chain before handing it to Warble.
819        if self.launcher != tool_bin.join("wren") || self.venv_python != tool_bin.join("python") {
820            return Err(DispatchError(
821                "native Wren runtime launcher chain must use tool_root/bin/wren and tool_root/bin/python"
822                    .to_string(),
823            ));
824        }
825        if !fs::symlink_metadata(&self.shim)
826            .map_err(|e| {
827                DispatchError(format!(
828                    "inspect native Wren runtime shim {}: {e}",
829                    self.shim.display()
830                ))
831            })?
832            .file_type()
833            .is_symlink()
834        {
835            return Err(DispatchError(
836                "native Wren runtime shim must be a symlink to the server-approved launcher"
837                    .to_string(),
838            ));
839        }
840        if !fs::symlink_metadata(&self.venv_python)
841            .map_err(|e| {
842                DispatchError(format!(
843                    "inspect native Wren runtime venv_python {}: {e}",
844                    self.venv_python.display()
845                ))
846            })?
847            .file_type()
848            .is_symlink()
849        {
850            return Err(DispatchError(
851                "native Wren runtime tool_root/bin/python must be a symlink to the server-approved interpreter"
852                    .to_string(),
853            ));
854        }
855        if shim != launcher
856            || venv_python != interpreter
857            || !interpreter.starts_with(&interpreter_bin)
858        {
859            return Err(DispatchError(
860                "native Wren runtime shim/interpreter chain does not resolve to the declared closure"
861                    .to_string(),
862            ));
863        }
864        if !is_executable(&launcher) || !is_executable(&interpreter) {
865            return Err(DispatchError(
866                "native Wren runtime launcher and interpreter must be executable regular files"
867                    .to_string(),
868            ));
869        }
870        let shebang = fs::read_to_string(&launcher)
871            .map_err(|e| {
872                DispatchError(format!(
873                    "read native Wren launcher {}: {e}",
874                    launcher.display()
875                ))
876            })?
877            .lines()
878            .next()
879            .and_then(|line| line.strip_prefix("#!"))
880            .filter(|path| !path.is_empty() && !path.contains(char::is_whitespace))
881            .map(PathBuf::from)
882            .ok_or_else(|| {
883                DispatchError(
884                    "native Wren runtime launcher must start with an absolute interpreter shebang"
885                        .to_string(),
886                )
887            })?;
888        if !shebang.is_absolute() || shebang != self.venv_python {
889            return Err(DispatchError(
890                "native Wren runtime launcher shebang must exactly name tool_root/bin/python"
891                    .to_string(),
892            ));
893        }
894
895        Ok(CanonicalWrenRuntime {
896            shim: self.shim.clone(),
897            shim_parent,
898            launcher: self.launcher.clone(),
899            venv_python: self.venv_python.clone(),
900            tool_bin,
901            tool_pyvenv,
902            site_packages: self.site_packages.clone(),
903            interpreter: self.interpreter.clone(),
904            interpreter_bin,
905            interpreter_lib,
906            source_root: self.source_root.clone(),
907        })
908    }
909
910    fn codex_permission_profile(&self) -> Result<String, DispatchError> {
911        let paths = self.validate()?;
912        let entry = |path: &Path| {
913            format!(
914                "{} = \"read\"\n",
915                serde_json::to_string(&path.to_string_lossy()).expect("runtime path serializes")
916            )
917        };
918        let mut config = format!(
919            "# 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"
920        );
921        for path in [
922            &paths.shim,
923            &paths.shim_parent,
924            &paths.tool_bin,
925            &paths.launcher,
926            &paths.venv_python,
927            &paths.tool_pyvenv,
928            &paths.site_packages,
929            &paths.interpreter,
930            &paths.interpreter_bin,
931            &paths.interpreter_lib,
932            &paths.source_root,
933        ] {
934            config.push_str(&entry(path));
935        }
936        Ok(config)
937    }
938}
939
940fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
941    if !path.is_absolute() {
942        return Err(DispatchError(format!(
943            "native Wren runtime {label} must be an absolute server-derived path"
944        )));
945    }
946    let canonical = fs::canonicalize(path).map_err(|e| {
947        DispatchError(format!(
948            "canonicalize native Wren runtime {label} {}: {e}",
949            path.display()
950        ))
951    })?;
952    canonical.is_dir().then_some(canonical).ok_or_else(|| {
953        DispatchError(format!(
954            "native Wren runtime {label} must be an existing directory"
955        ))
956    })
957}
958
959fn canonical_regular_file(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
960    if !path.is_absolute() {
961        return Err(DispatchError(format!(
962            "native Wren runtime {label} must be an absolute server-derived path"
963        )));
964    }
965    let canonical = fs::canonicalize(path).map_err(|e| {
966        DispatchError(format!(
967            "canonicalize native Wren runtime {label} {}: {e}",
968            path.display()
969        ))
970    })?;
971    canonical.is_file().then_some(canonical).ok_or_else(|| {
972        DispatchError(format!(
973            "native Wren runtime {label} must be an existing regular file"
974        ))
975    })
976}
977
978#[cfg(unix)]
979fn is_executable(path: &Path) -> bool {
980    use std::os::unix::fs::PermissionsExt;
981
982    fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
983}
984
985#[cfg(not(unix))]
986fn is_executable(_path: &Path) -> bool {
987    false
988}
989
990/// The only product purposes that may opt into the native Sessions launch contract.
991///
992/// This enum is intentionally closed: a caller cannot use the launch artifact to select an
993/// arbitrary profile, agent, cwd, or command. The GenBI runtime chooses a purpose and vendor,
994/// then materializes the corresponding Warble profile in a server-owned scope.
995#[derive(Debug, Clone, Copy, PartialEq, Eq)]
996pub enum NativePurpose {
997    Analysis,
998    Setup,
999    ContextEnrichment,
1000}
1001
1002impl NativePurpose {
1003    pub fn parse(value: &str) -> Option<Self> {
1004        match value {
1005            "analysis" => Some(Self::Analysis),
1006            "setup" => Some(Self::Setup),
1007            "context_enrichment" => Some(Self::ContextEnrichment),
1008            _ => None,
1009        }
1010    }
1011
1012    pub fn as_str(self) -> &'static str {
1013        match self {
1014            Self::Analysis => "analysis",
1015            Self::Setup => "setup",
1016            Self::ContextEnrichment => "context_enrichment",
1017        }
1018    }
1019
1020    fn expected_profile(self) -> &'static str {
1021        match self {
1022            Self::Analysis => "genbi-default",
1023            Self::Setup => "genbi-setup",
1024            Self::ContextEnrichment => "genbi-enrich-context",
1025        }
1026    }
1027
1028    fn scope_kind(self) -> &'static str {
1029        match self {
1030            Self::Setup => "bootstrap",
1031            Self::Analysis | Self::ContextEnrichment => "bound_project",
1032        }
1033    }
1034
1035    pub fn claude_agent(self) -> &'static str {
1036        match self {
1037            // These are the entry-point agents from their corresponding, allowlisted profiles.
1038            // The profile's other materialized agents remain available as vendor-native support
1039            // artifacts; callers never choose them through the launch spec.
1040            Self::Analysis => "answer_query",
1041            Self::Setup => "connect_source",
1042            Self::ContextEnrichment => "draft_enrichment",
1043        }
1044    }
1045
1046    pub fn codex_skill(self) -> &'static str {
1047        match self {
1048            Self::Analysis => "genbi-analysis",
1049            Self::Setup => "genbi-setup",
1050            Self::ContextEnrichment => "genbi-enrich-context",
1051        }
1052    }
1053
1054    pub fn codex_description(self) -> &'static str {
1055        match self {
1056            Self::Analysis => "Analyze the server-bound semantic project using the GenBI analysis behavior.",
1057            Self::Setup => "Connect a source and build a semantic context only in the server-created bootstrap scope.",
1058            Self::ContextEnrichment => "Inspect a pinned project and draft read-only enrichment proposals; never apply an enrichment.",
1059        }
1060    }
1061
1062    /// The one initial interactive prompt for this closed native purpose.
1063    ///
1064    /// This is deliberately data owned by the producer's purpose enum, rather
1065    /// than a browser value or terminal write. Both vendor CLIs accept one
1066    /// positional prompt while staying interactive, so it is passed as a
1067    /// single argv element without invoking a shell.
1068    pub fn welcome_prompt(self) -> &'static str {
1069        match self {
1070            Self::Setup => "Help me set up this GenBI project. Start by explaining the next setup step and ask what data source I want to connect.",
1071            Self::Analysis => "Help me analyze this data. Ask me what question I want to answer about the server-bound project.",
1072            Self::ContextEnrichment => "Help me inspect this project's context and draft a read-only enrichment proposal. Do not apply changes; ask what context I want to review.",
1073        }
1074    }
1075
1076    pub fn validate_profile(self, ir: &WarbleIr) -> Result<(), DispatchError> {
1077        if ir.profile != self.expected_profile() {
1078            return Err(DispatchError(format!(
1079                "native purpose '{}' requires Warble profile '{}', not '{}'",
1080                self.as_str(),
1081                self.expected_profile(),
1082                ir.profile
1083            )));
1084        }
1085        let entries = ir
1086            .components
1087            .iter()
1088            .filter(|node| node.verb == self.claude_agent())
1089            .collect::<Vec<_>>();
1090        let [entry] = entries.as_slice() else {
1091            return Err(DispatchError(format!(
1092                "native purpose '{}' requires exactly one materializable entry verb '{}'",
1093                self.as_str(),
1094                self.claude_agent()
1095            )));
1096        };
1097        if entry.id != self.claude_agent()
1098            || entry.realization_kind != crate::ir::RealizationKind::Skill
1099            || entry.trigger.kind != crate::ir::TriggerKind::OneShot
1100            || entry.effect.outcome.kind != crate::ir::OutcomeKind::None
1101            || entry
1102                .required_capabilities
1103                .iter()
1104                .any(|capability| capability == "enrichment_apply:deterministic")
1105        {
1106            return Err(DispatchError(format!(
1107                "native purpose '{}' entry '{}' is not materializable as a native interactive agent",
1108                self.as_str(),
1109                self.claude_agent()
1110            )));
1111        }
1112        Ok(())
1113    }
1114}
1115
1116pub struct InteractiveOutput {
1117    pub root: PathBuf,
1118    pub launch_path: PathBuf,
1119    pub handoff_path: PathBuf,
1120    ownership_path: PathBuf,
1121    owned_paths: Vec<String>,
1122    marker: String,
1123    target: String,
1124    executable: String,
1125    purpose: Option<NativePurpose>,
1126    native_scope: Option<NativeSessionScope>,
1127    native_mcp: Option<NativeMcpDescriptor>,
1128}
1129
1130#[allow(clippy::too_many_arguments)]
1131pub fn prepare_interactive_output(
1132    out_dir: &Path,
1133    target: &str,
1134    executable: &str,
1135    profile_signature: &str,
1136    owned_paths: &[PathBuf],
1137    purpose: Option<NativePurpose>,
1138    native_scope: Option<NativeSessionScope>,
1139    native_mcp: Option<NativeMcpDescriptor>,
1140) -> Result<InteractiveOutput, DispatchError> {
1141    if !out_dir.is_dir() {
1142        return Err(DispatchError(format!(
1143            "interactive dispatch output must be an existing directory so it can become the canonical launch cwd: {}",
1144            out_dir.display()
1145        )));
1146    }
1147    let root = fs::canonicalize(out_dir).map_err(|e| {
1148        DispatchError(format!(
1149            "canonicalize interactive output {}: {e}",
1150            out_dir.display()
1151        ))
1152    })?;
1153    match (purpose, native_scope.as_ref()) {
1154        (Some(purpose), Some(scope)) => scope.validate(purpose, &root)?,
1155        (Some(_), None) => {
1156            return Err(DispatchError(
1157                "native Sessions purpose requires a server-derived --native-scope descriptor"
1158                    .to_string(),
1159            ))
1160        }
1161        (None, Some(_)) => {
1162            return Err(DispatchError(
1163                "--native-scope requires a native Sessions --purpose".to_string(),
1164            ))
1165        }
1166        (None, None) => {}
1167    }
1168    match (purpose, native_mcp.as_ref()) {
1169        (Some(_), Some(descriptor)) => descriptor.validate()?,
1170        (Some(_), None) | (None, None) => {}
1171        (None, Some(_)) => {
1172            return Err(DispatchError(
1173                "--native-mcp requires a native Sessions --purpose".to_string(),
1174            ))
1175        }
1176    }
1177    let handoff_path = root.join("RUN.md");
1178    let launch_path = root.join(".warble/interactive-launch.json");
1179    ensure_inside(&root, &handoff_path)?;
1180    ensure_inside(&root, &launch_path)?;
1181    ensure_safe_path(&root, Path::new(".warble/interactive-launch.json"))?;
1182    ensure_safe_path(&root, Path::new(".warble/interactive-ownership.json"))?;
1183    let marker = format!(
1184        "<!-- warble-interactive-artifact target={target} profile={profile_signature}{}{} -->",
1185        native_scope
1186            .as_ref()
1187            .map(|scope| format!(" scope_digest={}", scope.ownership_digest(&root)))
1188            .unwrap_or_default(),
1189        native_mcp
1190            .as_ref()
1191            .map(|descriptor| format!(" mcp_digest={}", descriptor.ownership_digest()))
1192            .unwrap_or_default(),
1193    );
1194
1195    let mut planned = owned_paths
1196        .iter()
1197        .map(|path| path.to_string_lossy().to_string())
1198        .collect::<Vec<_>>();
1199    planned.sort();
1200    planned.dedup();
1201    let ownership_path = root.join(".warble/interactive-ownership.json");
1202    ensure_inside(&root, &ownership_path)?;
1203    for relative in owned_paths {
1204        ensure_safe_path(&root, relative)?;
1205    }
1206    let any_existing = planned.iter().any(|relative| root.join(relative).exists())
1207        || launch_path.exists()
1208        || ownership_path.exists();
1209    if any_existing {
1210        let existing = fs::read_to_string(&ownership_path).map_err(|_| {
1211            DispatchError(format!(
1212                "refusing to overwrite existing interactive artifacts: {} is missing",
1213                ownership_path.display()
1214            ))
1215        })?;
1216        verify_ownership(&existing, &marker, &planned, &root)?;
1217    }
1218    for relative in owned_paths {
1219        let path = root.join(relative);
1220        if path.exists()
1221            && matches!(
1222                path.file_name().and_then(|name| name.to_str()),
1223                Some("RUN.md" | "AGENTS.md" | "SKILL.md" | "CLAUDE.md")
1224            )
1225        {
1226            let contents = fs::read_to_string(&path).map_err(|e| {
1227                DispatchError(format!(
1228                    "read existing interactive artifact {}: {e}",
1229                    path.display()
1230                ))
1231            })?;
1232            if !contents.contains(&marker) {
1233                return Err(DispatchError(format!(
1234                    "refusing to overwrite user-owned interactive artifact {}; it lacks the expected Warble ownership marker",
1235                    path.display()
1236                )));
1237            }
1238        }
1239    }
1240    if launch_path.exists() {
1241        let existing = fs::read_to_string(&launch_path).map_err(|e| {
1242            DispatchError(format!(
1243                "read existing launch spec {}: {e}",
1244                launch_path.display()
1245            ))
1246        })?;
1247        if existing
1248            != render_launch_spec(
1249                target,
1250                executable,
1251                &root,
1252                &handoff_path,
1253                purpose,
1254                native_scope.as_ref(),
1255                native_mcp.as_ref(),
1256            )?
1257        {
1258            return Err(DispatchError(format!(
1259                "refusing to overwrite user-owned or mismatched launch spec {}",
1260                launch_path.display()
1261            )));
1262        }
1263    }
1264    Ok(InteractiveOutput {
1265        root,
1266        launch_path,
1267        handoff_path,
1268        ownership_path,
1269        owned_paths: planned,
1270        marker,
1271        target: target.into(),
1272        executable: executable.into(),
1273        purpose,
1274        native_scope,
1275        native_mcp,
1276    })
1277}
1278
1279impl InteractiveOutput {
1280    pub fn marker(&self) -> &str {
1281        &self.marker
1282    }
1283    pub fn write_launch_spec(&self) -> Result<(), DispatchError> {
1284        let parent = self.launch_path.parent().expect(".warble parent");
1285        fs::create_dir_all(parent)
1286            .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1287        fs::write(
1288            &self.launch_path,
1289            render_launch_spec(
1290                &self.target,
1291                &self.executable,
1292                &self.root,
1293                &self.handoff_path,
1294                self.purpose,
1295                self.native_scope.as_ref(),
1296                self.native_mcp.as_ref(),
1297            )?,
1298        )
1299        .map_err(|e| {
1300            DispatchError(format!(
1301                "write launch spec {}: {e}",
1302                self.launch_path.display()
1303            ))
1304        })
1305    }
1306    pub fn write_ownership(&self) -> Result<(), DispatchError> {
1307        let parent = self.ownership_path.parent().expect(".warble parent");
1308        fs::create_dir_all(parent)
1309            .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1310        fs::write(
1311            &self.ownership_path,
1312            ownership_document(&self.marker, &self.owned_paths, &self.root)?,
1313        )
1314        .map_err(|e| {
1315            DispatchError(format!(
1316                "write ownership record {}: {e}",
1317                self.ownership_path.display()
1318            ))
1319        })
1320    }
1321}
1322
1323fn ensure_inside(root: &Path, path: &Path) -> Result<(), DispatchError> {
1324    if !path.starts_with(root) {
1325        return Err(DispatchError(format!(
1326            "interactive artifact path escapes canonical output root: {}",
1327            path.display()
1328        )));
1329    }
1330    Ok(())
1331}
1332
1333/// Reject unsafe existing path components before any output write. A lexical `starts_with` check
1334/// is not a containment proof when `.claude`, `.agents`, or `.warble` can redirect writes
1335/// elsewhere, and a regular-file parent would make creation order-dependent.
1336fn ensure_safe_path(root: &Path, relative: &Path) -> Result<(), DispatchError> {
1337    if relative.is_absolute()
1338        || relative
1339            .components()
1340            .any(|component| matches!(component, std::path::Component::ParentDir))
1341    {
1342        return Err(DispatchError(format!(
1343            "interactive artifact path escapes canonical output root: {}",
1344            relative.display()
1345        )));
1346    }
1347    let components = relative.components().collect::<Vec<_>>();
1348    let mut cursor = root.to_path_buf();
1349    for (index, component) in components.iter().enumerate() {
1350        cursor.push(component.as_os_str());
1351        if let Ok(metadata) = fs::symlink_metadata(&cursor) {
1352            if metadata.file_type().is_symlink() {
1353                return Err(DispatchError(format!(
1354                    "refusing interactive artifact path with symlink component: {}",
1355                    cursor.display()
1356                )));
1357            }
1358            if index + 1 < components.len() && !metadata.file_type().is_dir() {
1359                return Err(DispatchError(format!(
1360                    "refusing interactive artifact path with non-directory ancestor component: {}",
1361                    cursor.display()
1362                )));
1363            }
1364        }
1365    }
1366    Ok(())
1367}
1368
1369fn render_launch_spec(
1370    target: &str,
1371    executable: &str,
1372    root: &Path,
1373    handoff: &Path,
1374    purpose: Option<NativePurpose>,
1375    native_scope: Option<&NativeSessionScope>,
1376    native_mcp: Option<&NativeMcpDescriptor>,
1377) -> Result<String, DispatchError> {
1378    ensure_inside(root, handoff)?;
1379    // This is intentionally the entire schema: no command string, prompt/model material, auth,
1380    // provider state, or session identity can be represented here.
1381    let document = match (purpose, native_mcp) {
1382        // v1 remains byte-for-byte schema-compatible for its enrichment consumer.
1383        (None, None) => json!({
1384            "version": LAUNCH_SPEC_VERSION,
1385            "target": target,
1386            "executable": executable,
1387            "argv": [],
1388            "cwd": root,
1389            "artifact_root": root,
1390            "handoff_path": handoff,
1391        }),
1392        (Some(purpose), None) => json!({
1393            "version": NATIVE_SESSION_LAUNCH_SPEC_VERSION,
1394            "target": target,
1395            "purpose": purpose.as_str(),
1396            "executable": executable,
1397            // Claude needs an explicit native agent selection; Codex loads the named skill from
1398            // its repository-scoped discovery artifacts. Both values are dispatcher-authored.
1399            "argv": if target == "claude-code:interactive" {
1400                json!(["--agent", purpose.claude_agent()])
1401            } else {
1402                json!([])
1403            },
1404            "agent": if target == "claude-code:interactive" {
1405                json!({ "kind": "claude_agent", "name": purpose.claude_agent() })
1406            } else {
1407                json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1408            },
1409            "scope": native_scope.expect("v2 native scope preflighted").launch_value(),
1410            "cwd": root,
1411            "artifact_root": root,
1412            "handoff_path": handoff,
1413        }),
1414        (Some(purpose), Some(_)) => {
1415            let mut document = json!({
1416                "version": NATIVE_SESSION_MCP_LAUNCH_SPEC_VERSION,
1417                "target": target,
1418                "purpose": purpose.as_str(),
1419                "executable": executable,
1420            // Both interactive CLIs accept one positional prompt. Keeping it
1421            // in this closed argv contract makes the first turn exactly-once
1422            // without a shell or post-spawn PTY input injection.
1423                "argv": if target == "claude-code:interactive" {
1424                    json!(["--agent", purpose.claude_agent(), purpose.welcome_prompt()])
1425                } else {
1426                    json!([purpose.welcome_prompt()])
1427                },
1428                "agent": if target == "claude-code:interactive" {
1429                    json!({ "kind": "claude_agent", "name": purpose.claude_agent() })
1430                } else {
1431                    json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1432                },
1433                "mcp": {
1434                    "server_name": NATIVE_MCP_SERVER_NAME,
1435                    "credential_env_var": NATIVE_MCP_CREDENTIAL_ENV_VAR,
1436                },
1437                "cwd": root,
1438                "artifact_root": root,
1439                "handoff_path": handoff,
1440            });
1441            if purpose == NativePurpose::Setup {
1442                document
1443                    .as_object_mut()
1444                    .expect("launch spec is an object")
1445                    .insert(
1446                        "bootstrap_root".to_string(),
1447                        serde_json::to_value(
1448                            native_scope
1449                                .expect("v4 Setup native scope preflighted")
1450                                .bootstrap_root
1451                                .as_ref()
1452                                .map(|root| {
1453                                    fs::canonicalize(root)
1454                                        .expect("validated bootstrap root remains canonicalizable")
1455                                }),
1456                        )
1457                        .expect("bootstrap root serializes"),
1458                    );
1459            }
1460            document
1461        }
1462        (None, Some(_)) => {
1463            return Err(DispatchError(
1464                "--native-mcp requires a native Sessions --purpose".to_string(),
1465            ))
1466        }
1467    };
1468    serde_json::to_string_pretty(&document)
1469        .map(|v| format!("{v}\n"))
1470        .map_err(|e| DispatchError(e.to_string()))
1471}
1472
1473fn ownership_document(
1474    marker: &str,
1475    paths: &[String],
1476    root: &Path,
1477) -> Result<String, DispatchError> {
1478    let artifacts = paths
1479        .iter()
1480        .map(|path| {
1481            let bytes = fs::read(root.join(path))
1482                .map_err(|e| DispatchError(format!("read owned artifact {path}: {e}")))?;
1483            Ok(json!({ "path": path, "sha256": format!("{:x}", Sha256::digest(bytes)) }))
1484        })
1485        .collect::<Result<Vec<_>, DispatchError>>()?;
1486    serde_json::to_string_pretty(&json!({ "marker": marker, "artifacts": artifacts }))
1487        .map(|v| format!("{v}\n"))
1488        .map_err(|e| DispatchError(e.to_string()))
1489}
1490
1491fn verify_ownership(
1492    existing: &str,
1493    marker: &str,
1494    paths: &[String],
1495    root: &Path,
1496) -> Result<(), DispatchError> {
1497    let value: serde_json::Value = serde_json::from_str(existing).map_err(|_| {
1498        DispatchError("refusing to overwrite malformed interactive ownership record".to_string())
1499    })?;
1500    if value.get("marker").and_then(serde_json::Value::as_str) != Some(marker) {
1501        return Err(DispatchError("refusing to overwrite artifacts not owned by this exact Warble interactive emission plan".to_string()));
1502    }
1503    let artifacts = value
1504        .get("artifacts")
1505        .and_then(serde_json::Value::as_array)
1506        .ok_or_else(|| {
1507            DispatchError(
1508                "refusing to overwrite ownership record without per-artifact digests".to_string(),
1509            )
1510        })?;
1511    if artifacts.len() != paths.len() {
1512        return Err(DispatchError(
1513            "refusing to overwrite ownership record with a different artifact plan".to_string(),
1514        ));
1515    }
1516    for (artifact, path) in artifacts.iter().zip(paths) {
1517        if artifact.get("path").and_then(serde_json::Value::as_str) != Some(path.as_str()) {
1518            return Err(DispatchError(
1519                "refusing to overwrite ownership record with a different artifact plan".to_string(),
1520            ));
1521        }
1522        let bytes = fs::read(root.join(path)).map_err(|_| {
1523            DispatchError(format!(
1524                "refusing to overwrite missing owned artifact {path}"
1525            ))
1526        })?;
1527        let digest = format!("{:x}", Sha256::digest(bytes));
1528        if artifact.get("sha256").and_then(serde_json::Value::as_str) != Some(digest.as_str()) {
1529            return Err(DispatchError(format!(
1530                "refusing to overwrite modified owned artifact {path}"
1531            )));
1532        }
1533    }
1534    Ok(())
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539    use super::NativeMcpDescriptor;
1540
1541    #[test]
1542    fn native_mcp_descriptor_debug_redacts_the_opaque_credential() {
1543        let descriptor = NativeMcpDescriptor {
1544            version: "1".to_string(),
1545            url: "https://mcp.example.test/native".to_string(),
1546            credential: "sentinel-opaque-native-mcp-credential".to_string(),
1547        };
1548
1549        let rendered = format!("{descriptor:?}");
1550        assert!(rendered.contains("NativeMcpDescriptor"));
1551        assert!(rendered.contains("version: \"1\""));
1552        assert!(rendered.contains("url: \"https://mcp.example.test/native\""));
1553        assert!(rendered.contains("credential: \"[REDACTED]\""));
1554        assert!(!rendered.contains("sentinel-opaque-native-mcp-credential"));
1555    }
1556}