Skip to main content

warble_claude_code/
interactive.rs

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