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