1use 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";
14pub 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";
25pub const NATIVE_QUERY_TOOL: &str = "query";
27pub 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
37pub 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
114pub 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
135pub 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
146pub 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
175pub 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
189pub 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
203pub 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
222pub 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
252pub 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
283pub 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#[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 pub entry: NativeEntry,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub bootstrap_root: Option<PathBuf>,
341 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
370#[serde(rename_all = "snake_case")]
371pub enum NativeEntryKind {
372 Agent,
373 Scope,
374}
375
376#[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 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#[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#[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 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 let has_network_authority = self
511 .url
512 .split_once(':')
513 .is_some_and(|(_, after_scheme)| after_scheme.starts_with("//"));
514 if self.url.len() > 2048
515 || url.host().is_none()
516 || !url.username().is_empty()
517 || url.password().is_some()
518 || url.query().is_some()
519 || url.fragment().is_some()
520 || self.url.chars().any(char::is_control)
521 || self.url.chars().any(char::is_whitespace)
522 || !has_network_authority
523 || !allowed_scheme
524 {
525 return Err(DispatchError(
526 "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
527 ));
528 }
529 if self.credential.is_empty()
530 || self.credential.len() > 4096
531 || self.credential.chars().any(char::is_control)
532 || self.credential.chars().any(char::is_whitespace)
533 {
534 return Err(DispatchError(
535 "native MCP descriptor requires a bounded opaque credential".to_string(),
536 ));
537 }
538 Ok(())
539 }
540
541 fn ownership_digest(&self) -> String {
542 let bytes = serde_json::to_vec(&json!({
543 "version": self.version,
544 "url": self.url,
545 "credential": self.credential,
546 }))
547 .expect("canonical native MCP descriptor serializes");
548 format!("sha256:{:x}", Sha256::digest(bytes))
549 }
550
551 pub fn claude_discovery_config(&self) -> Result<String, DispatchError> {
552 serde_json::to_string_pretty(&json!({
553 "mcpServers": {
554 NATIVE_MCP_SERVER_NAME: {
555 "type": "http",
556 "url": self.url,
557 "headers": { "Authorization": format!("Bearer {}", self.credential) },
558 }
559 }
560 }))
561 .map(|value| format!("{value}\n"))
562 .map_err(|e| DispatchError(e.to_string()))
563 }
564
565 pub(crate) fn codex_host_discovery_config(&self, tools: &[&str]) -> String {
566 format!("[mcp_servers.{NATIVE_MCP_SERVER_NAME}]\nurl = {}\nbearer_token_env_var = {}\nenabled_tools = {}\n",
567 serde_json::to_string(&self.url).expect("URL"), serde_json::to_string(NATIVE_MCP_CREDENTIAL_ENV_VAR).expect("env"),
568 serde_json::to_string(tools).expect("tools"))
569 }
570
571 pub fn codex_discovery_config(
572 &self,
573 enable_setup_recovery_tool: bool,
574 enable_dashboard_save_tool: bool,
575 enable_persist_answer_tool: bool,
576 ) -> String {
577 let mut config = format!(
580 "[mcp_servers.{NATIVE_MCP_SERVER_NAME}]\nurl = {}\nbearer_token_env_var = {}\n",
581 serde_json::to_string(&self.url).expect("MCP URL serializes"),
582 serde_json::to_string(NATIVE_MCP_CREDENTIAL_ENV_VAR)
583 .expect("credential env name serializes"),
584 );
585 let mut enabled_tools = Vec::new();
586 if enable_setup_recovery_tool {
587 enabled_tools.push("report_setup_recovery");
588 }
589 if enable_dashboard_save_tool {
590 enabled_tools.push(NATIVE_DASHBOARD_SAVE_TOOL);
591 }
592 if enable_persist_answer_tool {
593 enabled_tools.push(NATIVE_PERSIST_ANSWER_TOOL);
594 enabled_tools.push(NATIVE_QUERY_TOOL);
595 }
596 if !enabled_tools.is_empty() {
597 config.push_str(&format!(
598 "enabled_tools = {}\n",
599 serde_json::to_string(&enabled_tools).expect("MCP tool names serialize"),
600 ));
601 }
602 config
603 }
604}
605
606impl NativeSessionScope {
607 pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
608 let raw = fs::read_to_string(path).map_err(|e| {
609 DispatchError(format!("read native session scope {}: {e}", path.display()))
610 })?;
611 serde_json::from_str(&raw).map_err(|e| {
612 DispatchError(format!(
613 "parse native session scope {}: {e}",
614 path.display()
615 ))
616 })
617 }
618
619 fn validate_preflight(&self, purpose: NativePurpose) -> Result<(), DispatchError> {
620 if self.version != NATIVE_SCOPE_VERSION {
621 return Err(DispatchError(format!(
622 "unsupported native session scope version '{}' (expected: {NATIVE_SCOPE_VERSION})",
623 self.version
624 )));
625 }
626 if self.kind != purpose.scope_kind() {
627 return Err(DispatchError(format!(
628 "native session scope kind '{}' does not match purpose '{}' ({})",
629 self.kind,
630 purpose.as_str(),
631 purpose.scope_kind()
632 )));
633 }
634 if self.scope_id.trim().is_empty() {
635 return Err(DispatchError(
636 "native session scope requires a non-empty opaque scope_id".to_string(),
637 ));
638 }
639 match (self.entry.kind, self.entry.verb.as_deref()) {
640 (NativeEntryKind::Agent, None) => {
641 return Err(DispatchError(
642 "native session scope entry kind 'agent' requires the component verb it pins"
643 .to_string(),
644 ));
645 }
646 (NativeEntryKind::Agent, Some(verb)) if verb.trim().is_empty() => {
647 return Err(DispatchError(
648 "native session scope requires a non-empty entry verb".to_string(),
649 ));
650 }
651 (NativeEntryKind::Scope, Some(_)) => {
655 return Err(DispatchError(
656 "native session scope entry kind 'scope' must not name a verb: scope entry \
657 starts at the scope document and pins no component"
658 .to_string(),
659 ));
660 }
661 (NativeEntryKind::Agent, Some(_)) | (NativeEntryKind::Scope, None) => {}
662 }
663 if self.entry.prompt.trim().is_empty() {
664 return Err(DispatchError(
665 "native session scope requires a non-empty entry prompt".to_string(),
666 ));
667 }
668 for (field, value) in self
675 .entry
676 .verb
677 .iter()
678 .map(|verb| ("verb", verb))
679 .chain(std::iter::once(("prompt", &self.entry.prompt)))
680 {
681 if value.trim_start().starts_with('-') {
682 return Err(DispatchError(format!(
683 "native session scope entry {field} must not begin with '-': a dash-leading \
684 value would be parsed as a vendor CLI option instead of argv content"
685 )));
686 }
687 }
688 if !self.cwd.is_absolute() {
689 return Err(DispatchError(
690 "native session scope cwd must be an absolute server-derived path".to_string(),
691 ));
692 }
693 match (&self.kind[..], &self.binding, &self.bootstrap_root) {
694 ("bootstrap", None, Some(bootstrap_root)) => {
695 if !bootstrap_root.is_absolute() {
696 return Err(DispatchError(
697 "bootstrap native session scope root must be an absolute server-derived path".to_string(),
698 ));
699 }
700 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
701 DispatchError(format!(
702 "canonicalize bootstrap native session scope root {}: {e}",
703 bootstrap_root.display()
704 ))
705 })?;
706 if *bootstrap_root != canonical_bootstrap_root {
707 return Err(DispatchError(
708 "bootstrap native session scope root must already be canonical".to_string(),
709 ));
710 }
711 Ok(())
712 }
713 ("bootstrap", None, None) => Err(DispatchError(
714 "bootstrap native session scope requires a separately authorized bootstrap_root"
715 .to_string(),
716 )),
717 ("bootstrap", Some(_), _) => Err(DispatchError(
718 "bootstrap native session scope must not carry a bound-project identity".to_string(),
719 )),
720 ("bound_project", Some(binding), None)
721 if !binding.project_identity.trim().is_empty()
722 && !binding.generation.trim().is_empty()
723 && !binding.revision.trim().is_empty() =>
724 {
725 Ok(())
726 }
727 ("bound_project", _, Some(_)) => Err(DispatchError(
728 "bound_project native session scope must not carry a bootstrap_root".to_string(),
729 )),
730 ("bound_project", _, None) => Err(DispatchError(
731 "bound_project native session scope requires non-empty project_identity, generation, and revision"
732 .to_string(),
733 )),
734 _ => unreachable!("purpose mapping has a closed scope vocabulary"),
735 }
736 }
737
738 fn validate(&self, purpose: NativePurpose, root: &Path) -> Result<(), DispatchError> {
739 self.validate_preflight(purpose)?;
740 let scope_cwd = match fs::canonicalize(&self.cwd) {
741 Ok(canonical) => {
742 if self.cwd != canonical {
743 return Err(DispatchError(
744 "native session scope cwd must already be canonical".to_string(),
745 ));
746 }
747 canonical
748 }
749 Err(error) if error.kind() == std::io::ErrorKind::NotFound && self.cwd == root => {
750 root.to_path_buf()
751 }
752 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
753 return Err(DispatchError(format!(
754 "native session scope cwd {} does not match canonical output root {}",
755 self.cwd.display(),
756 root.display()
757 )))
758 }
759 Err(error) => {
760 return Err(DispatchError(format!(
761 "canonicalize native session scope cwd {}: {error}",
762 self.cwd.display()
763 )))
764 }
765 };
766 if scope_cwd != root {
767 return Err(DispatchError(format!(
768 "native session scope cwd {} does not match canonical output root {}",
769 scope_cwd.display(),
770 root.display()
771 )));
772 }
773 if let Some(bootstrap_root) = &self.bootstrap_root {
774 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
775 DispatchError(format!(
776 "canonicalize bootstrap native session scope root {}: {e}",
777 bootstrap_root.display()
778 ))
779 })?;
780 if canonical_bootstrap_root == root {
781 return Err(DispatchError(
782 "bootstrap native session scope root must be distinct from the canonical output root"
783 .to_string(),
784 ));
785 }
786 }
787 Ok(())
788 }
789
790 fn ownership_digest(&self, canonical_cwd: &Path) -> String {
791 let canonical = json!({
795 "version": self.version,
796 "kind": self.kind,
797 "scope_id": self.scope_id,
798 "cwd": canonical_cwd,
799 "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
800 fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
801 }),
802 "wren_runtime": self.wren_runtime,
803 "binding": self.binding,
804 "entry": self.entry,
808 });
809 let bytes = serde_json::to_vec(&canonical).expect("canonical native scope serializes");
810 format!("sha256:{:x}", Sha256::digest(bytes))
811 }
812
813 fn launch_value(&self) -> serde_json::Value {
814 json!({
815 "kind": self.kind,
816 "scope_id": self.scope_id,
817 "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
818 fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
819 }),
820 "binding": self.binding,
821 })
822 }
823
824 pub fn codex_permission_profile(&self) -> Result<String, DispatchError> {
827 let mut config = self
828 .wren_runtime
829 .as_ref()
830 .ok_or_else(|| {
831 DispatchError(
832 "native Codex purpose requires a server-derived wren_runtime closure"
833 .to_string(),
834 )
835 })?
836 .codex_permission_profile()?;
837 config.push_str(&format!(
838 "\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}.filesystem.\":workspace_roots\"]\n"
839 ));
840 if self.kind == "bootstrap" {
841 let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
842 DispatchError(
843 "bootstrap native session scope requires a separately authorized bootstrap_root"
844 .to_string(),
845 )
846 })?;
847 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
848 DispatchError("bootstrap native session scope root is unavailable".to_string())
849 })?;
850 if *bootstrap_root != canonical_bootstrap_root {
851 return Err(DispatchError(
852 "bootstrap native session scope root must already be canonical".to_string(),
853 ));
854 }
855 config.push_str("\".\" = \"read\"\n");
858 config.push_str(&format!(
859 "{} = \"write\"\n",
860 serde_json::to_string(&canonical_bootstrap_root.to_string_lossy())
861 .expect("bootstrap root serializes")
862 ));
863 } else {
864 config.push_str("\".\" = \"write\"\n");
865 }
866 Ok(config)
867 }
868
869 pub fn claude_setup_write_permissions(&self) -> Result<[String; 2], DispatchError> {
873 let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
874 DispatchError(
875 "bootstrap native session scope requires a separately authorized bootstrap_root"
876 .to_string(),
877 )
878 })?;
879 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
880 DispatchError("bootstrap native session scope root is unavailable".to_string())
881 })?;
882 if self.kind != "bootstrap" || *bootstrap_root != canonical_bootstrap_root {
883 return Err(DispatchError(
884 "bootstrap native session scope root is incompatible".to_string(),
885 ));
886 }
887 let recursive = canonical_bootstrap_root
888 .join("**")
889 .to_string_lossy()
890 .to_string();
891 Ok([format!("Edit({recursive})"), format!("Write({recursive})")])
892 }
893}
894
895impl NativeWrenRuntime {
896 fn validate(&self) -> Result<CanonicalWrenRuntime, DispatchError> {
897 if self.version != NATIVE_WREN_RUNTIME_VERSION {
898 return Err(DispatchError(format!(
899 "unsupported native Wren runtime version '{}' (expected: {NATIVE_WREN_RUNTIME_VERSION})",
900 self.version
901 )));
902 }
903
904 let tool_root = canonical_directory(&self.tool_root, "tool_root")?;
905 let interpreter_root = canonical_directory(&self.interpreter_root, "interpreter_root")?;
906 let site_packages = canonical_directory(&self.site_packages, "site_packages")?;
907 let source_root = canonical_directory(&self.source_root, "source_root")?;
908 let launcher = canonical_regular_file(&self.launcher, "launcher")?;
909 let interpreter = canonical_regular_file(&self.interpreter, "interpreter")?;
910 let shim = canonical_regular_file(&self.shim, "shim")?;
911 let venv_python = canonical_regular_file(&self.venv_python, "venv_python")?;
912 let shim_parent = canonical_directory(
913 self.shim.parent().ok_or_else(|| {
914 DispatchError("native Wren runtime shim must have a parent directory".to_string())
915 })?,
916 "shim parent",
917 )?;
918
919 let tool_bin = tool_root.join("bin");
920 let interpreter_bin = interpreter_root.join("bin");
921 let tool_pyvenv = tool_root.join("pyvenv.cfg");
922 let tool_lib = tool_root.join("lib");
923 let interpreter_lib = interpreter_root.join("lib");
924 for (path, label) in [
925 (&tool_bin, "tool_root/bin"),
926 (&interpreter_bin, "interpreter_root/bin"),
927 (&interpreter_lib, "interpreter_root/lib"),
928 ] {
929 if !path.is_dir() {
930 return Err(DispatchError(format!(
931 "native Wren runtime {label} must be an existing directory"
932 )));
933 }
934 }
935 if !tool_pyvenv.is_file() {
936 return Err(DispatchError(
937 "native Wren runtime tool_root/pyvenv.cfg must be an existing regular file"
938 .to_string(),
939 ));
940 }
941 if !site_packages.starts_with(&tool_lib) {
942 return Err(DispatchError(
943 "native Wren runtime site_packages must be inside tool_root/lib".to_string(),
944 ));
945 }
946 let editable_pth = site_packages.join("_editable_impl_wrenai.pth");
947 let editable_source = fs::read_to_string(&editable_pth)
948 .ok()
949 .and_then(|contents| {
950 let paths = contents
951 .lines()
952 .map(str::trim)
953 .filter(|line| !line.is_empty())
954 .collect::<Vec<_>>();
955 let [path] = paths.as_slice() else {
956 return None;
957 };
958 Path::new(path).is_absolute().then(|| PathBuf::from(path))
959 })
960 .and_then(|path| fs::canonicalize(path).ok());
961 if editable_source.as_deref() != Some(source_root.as_path())
962 || !source_root.join("wren/__init__.py").is_file()
963 {
964 return Err(DispatchError(
965 "native Wren runtime editable source must exactly match the server-approved .pth closure"
966 .to_string(),
967 ));
968 }
969
970 if self.launcher != tool_bin.join("wren") || self.venv_python != tool_bin.join("python") {
973 return Err(DispatchError(
974 "native Wren runtime launcher chain must use tool_root/bin/wren and tool_root/bin/python"
975 .to_string(),
976 ));
977 }
978 if !fs::symlink_metadata(&self.shim)
979 .map_err(|e| {
980 DispatchError(format!(
981 "inspect native Wren runtime shim {}: {e}",
982 self.shim.display()
983 ))
984 })?
985 .file_type()
986 .is_symlink()
987 {
988 return Err(DispatchError(
989 "native Wren runtime shim must be a symlink to the server-approved launcher"
990 .to_string(),
991 ));
992 }
993 if !fs::symlink_metadata(&self.venv_python)
994 .map_err(|e| {
995 DispatchError(format!(
996 "inspect native Wren runtime venv_python {}: {e}",
997 self.venv_python.display()
998 ))
999 })?
1000 .file_type()
1001 .is_symlink()
1002 {
1003 return Err(DispatchError(
1004 "native Wren runtime tool_root/bin/python must be a symlink to the server-approved interpreter"
1005 .to_string(),
1006 ));
1007 }
1008 if shim != launcher
1009 || venv_python != interpreter
1010 || !interpreter.starts_with(&interpreter_bin)
1011 {
1012 return Err(DispatchError(
1013 "native Wren runtime shim/interpreter chain does not resolve to the declared closure"
1014 .to_string(),
1015 ));
1016 }
1017 if !is_executable(&launcher) || !is_executable(&interpreter) {
1018 return Err(DispatchError(
1019 "native Wren runtime launcher and interpreter must be executable regular files"
1020 .to_string(),
1021 ));
1022 }
1023 let shebang = fs::read_to_string(&launcher)
1024 .map_err(|e| {
1025 DispatchError(format!(
1026 "read native Wren launcher {}: {e}",
1027 launcher.display()
1028 ))
1029 })?
1030 .lines()
1031 .next()
1032 .and_then(|line| line.strip_prefix("#!"))
1033 .filter(|path| !path.is_empty() && !path.contains(char::is_whitespace))
1034 .map(PathBuf::from)
1035 .ok_or_else(|| {
1036 DispatchError(
1037 "native Wren runtime launcher must start with an absolute interpreter shebang"
1038 .to_string(),
1039 )
1040 })?;
1041 if !shebang.is_absolute() || shebang != self.venv_python {
1042 return Err(DispatchError(
1043 "native Wren runtime launcher shebang must exactly name tool_root/bin/python"
1044 .to_string(),
1045 ));
1046 }
1047
1048 Ok(CanonicalWrenRuntime {
1049 shim: self.shim.clone(),
1050 shim_parent,
1051 launcher: self.launcher.clone(),
1052 venv_python: self.venv_python.clone(),
1053 tool_bin,
1054 tool_pyvenv,
1055 site_packages: self.site_packages.clone(),
1056 interpreter: self.interpreter.clone(),
1057 interpreter_bin,
1058 interpreter_lib,
1059 source_root: self.source_root.clone(),
1060 })
1061 }
1062
1063 fn codex_permission_profile(&self) -> Result<String, DispatchError> {
1064 let paths = self.validate()?;
1065 let entry = |path: &Path| {
1066 format!(
1067 "{} = \"read\"\n",
1068 serde_json::to_string(&path.to_string_lossy()).expect("runtime path serializes")
1069 )
1070 };
1071 let mut config = format!(
1072 "# Server-owned native Wren runtime closure. `read` is Codex's read-and-execute\n# filesystem grant; no PATH, browser, credential, or caller filesystem input is accepted.\ndefault_permissions = \"{CODEX_WREN_PERMISSION_PROFILE}\"\n\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}]\ndescription = \"Warble native session workspace plus exact Wren runtime closure\"\n\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}.filesystem]\n\":minimal\" = \"read\"\n"
1073 );
1074 for path in [
1075 &paths.shim,
1076 &paths.shim_parent,
1077 &paths.tool_bin,
1078 &paths.launcher,
1079 &paths.venv_python,
1080 &paths.tool_pyvenv,
1081 &paths.site_packages,
1082 &paths.interpreter,
1083 &paths.interpreter_bin,
1084 &paths.interpreter_lib,
1085 &paths.source_root,
1086 ] {
1087 config.push_str(&entry(path));
1088 }
1089 Ok(config)
1090 }
1091}
1092
1093fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
1094 if !path.is_absolute() {
1095 return Err(DispatchError(format!(
1096 "native Wren runtime {label} must be an absolute server-derived path"
1097 )));
1098 }
1099 let canonical = fs::canonicalize(path).map_err(|e| {
1100 DispatchError(format!(
1101 "canonicalize native Wren runtime {label} {}: {e}",
1102 path.display()
1103 ))
1104 })?;
1105 canonical.is_dir().then_some(canonical).ok_or_else(|| {
1106 DispatchError(format!(
1107 "native Wren runtime {label} must be an existing directory"
1108 ))
1109 })
1110}
1111
1112fn canonical_regular_file(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
1113 if !path.is_absolute() {
1114 return Err(DispatchError(format!(
1115 "native Wren runtime {label} must be an absolute server-derived path"
1116 )));
1117 }
1118 let canonical = fs::canonicalize(path).map_err(|e| {
1119 DispatchError(format!(
1120 "canonicalize native Wren runtime {label} {}: {e}",
1121 path.display()
1122 ))
1123 })?;
1124 canonical.is_file().then_some(canonical).ok_or_else(|| {
1125 DispatchError(format!(
1126 "native Wren runtime {label} must be an existing regular file"
1127 ))
1128 })
1129}
1130
1131#[cfg(unix)]
1132fn is_executable(path: &Path) -> bool {
1133 use std::os::unix::fs::PermissionsExt;
1134
1135 fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
1136}
1137
1138#[cfg(not(unix))]
1139fn is_executable(_path: &Path) -> bool {
1140 false
1141}
1142
1143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1149pub enum NativePurpose {
1150 Analysis,
1151 Setup,
1152 ContextEnrichment,
1153}
1154
1155impl NativePurpose {
1156 pub fn parse(value: &str) -> Option<Self> {
1157 match value {
1158 "analysis" => Some(Self::Analysis),
1159 "setup" => Some(Self::Setup),
1160 "context_enrichment" => Some(Self::ContextEnrichment),
1161 _ => None,
1162 }
1163 }
1164
1165 pub fn as_str(self) -> &'static str {
1166 match self {
1167 Self::Analysis => "analysis",
1168 Self::Setup => "setup",
1169 Self::ContextEnrichment => "context_enrichment",
1170 }
1171 }
1172
1173 fn scope_kind(self) -> &'static str {
1174 match self {
1175 Self::Setup => "bootstrap",
1176 Self::Analysis | Self::ContextEnrichment => "bound_project",
1177 }
1178 }
1179
1180 pub fn codex_skill(self) -> &'static str {
1181 match self {
1182 Self::Analysis => "genbi-analysis",
1183 Self::Setup => "genbi-setup",
1184 Self::ContextEnrichment => "genbi-enrich-context",
1185 }
1186 }
1187
1188 pub fn codex_description(self) -> &'static str {
1189 match self {
1190 Self::Analysis => "Analyze the server-bound semantic project using the GenBI analysis behavior.",
1191 Self::Setup => "Connect a source and build a semantic context only in the server-created bootstrap scope.",
1192 Self::ContextEnrichment => "Inspect a pinned project and draft read-only enrichment proposals; never apply an enrichment.",
1193 }
1194 }
1195
1196 pub fn validate_profile(self, ir: &WarbleIr, entry: &NativeEntry) -> Result<(), DispatchError> {
1214 match (entry.kind, entry.verb.as_deref()) {
1217 (NativeEntryKind::Agent, Some(verb)) => self.validate_pinned_entry(ir, verb),
1218 (NativeEntryKind::Agent, None) => Err(DispatchError(format!(
1219 "native purpose '{}' entry kind 'agent' requires the component verb it pins",
1220 self.as_str()
1221 ))),
1222 (NativeEntryKind::Scope, None) => self.validate_scope_entry(ir),
1223 (NativeEntryKind::Scope, Some(verb)) => Err(DispatchError(format!(
1224 "native purpose '{}' declared scope entry but also named the verb '{verb}': scope entry must not name a verb",
1225 self.as_str()
1226 ))),
1227 }
1228 }
1229
1230 fn is_native_entry_eligible(node: &crate::ir::ComponentNode) -> bool {
1236 node.id == node.verb
1237 && node.realization_kind == crate::ir::RealizationKind::Skill
1238 && node.trigger.kind == crate::ir::TriggerKind::OneShot
1239 && node.effect.outcome.kind == crate::ir::OutcomeKind::None
1240 && !node
1241 .required_capabilities
1242 .iter()
1243 .any(|capability| capability == "enrichment_apply:deterministic")
1244 }
1245
1246 fn validate_pinned_entry(self, ir: &WarbleIr, verb: &str) -> Result<(), DispatchError> {
1247 let entries = ir
1248 .components
1249 .iter()
1250 .filter(|node| node.verb == verb)
1251 .collect::<Vec<_>>();
1252 let [component] = entries.as_slice() else {
1253 return Err(DispatchError(format!(
1254 "native purpose '{}' requires exactly one component with the declared entry verb '{}', found {}",
1255 self.as_str(),
1256 verb,
1257 entries.len()
1258 )));
1259 };
1260 if !Self::is_native_entry_eligible(component) {
1261 return Err(DispatchError(format!(
1262 "native purpose '{}' entry '{}' is not materializable as a native interactive agent: it must be a one-shot skill with no outcome and must not require enrichment_apply:deterministic",
1263 self.as_str(),
1264 verb
1265 )));
1266 }
1267 Ok(())
1268 }
1269
1270 fn validate_scope_entry(self, ir: &WarbleIr) -> Result<(), DispatchError> {
1278 let eligible = ir
1279 .components
1280 .iter()
1281 .filter(|node| Self::is_native_entry_eligible(node))
1282 .count();
1283 if eligible < 2 {
1284 return Err(DispatchError(format!(
1285 "native purpose '{}' declared scope entry, but this profile materializes {} native-eligible component(s): scope entry needs at least 2 so the session has something to choose between; pin the entry instead",
1286 self.as_str(),
1287 eligible
1288 )));
1289 }
1290 Ok(())
1291 }
1292}
1293
1294pub struct InteractiveOutput {
1295 native_host: Option<crate::native_host::NativeHost>,
1296 pub root: PathBuf,
1297 pub launch_path: PathBuf,
1298 pub handoff_path: PathBuf,
1299 ownership_path: PathBuf,
1300 owned_paths: Vec<String>,
1301 marker: String,
1302 target: String,
1303 executable: String,
1304 purpose: Option<NativePurpose>,
1305 native_scope: Option<NativeSessionScope>,
1306 native_mcp: Option<NativeMcpDescriptor>,
1307 profile: String,
1310}
1311
1312enum ResolvedInteractiveOutput {
1313 Existing(PathBuf),
1314 Missing(PathBuf),
1315}
1316
1317fn resolve_interactive_output(path: &Path) -> Result<ResolvedInteractiveOutput, DispatchError> {
1318 let mut resolved = if path.is_absolute() {
1319 PathBuf::new()
1320 } else {
1321 let current = std::env::current_dir()
1322 .map_err(|e| DispatchError(format!("resolve current directory: {e}")))?;
1323 fs::canonicalize(¤t).map_err(|e| {
1324 DispatchError(format!(
1325 "canonicalize current directory {}: {e}",
1326 current.display()
1327 ))
1328 })?
1329 };
1330 let mut missing = false;
1331 for component in path.components() {
1332 match component {
1333 Component::CurDir => {}
1334 Component::ParentDir => {
1335 if missing {
1336 return Err(DispatchError(format!(
1337 "interactive output cannot traverse '..' after a missing component: {}",
1338 path.display()
1339 )));
1340 }
1341 let parent = resolved.parent().unwrap_or(resolved.as_path());
1342 resolved = fs::canonicalize(parent).map_err(|e| {
1343 DispatchError(format!(
1344 "resolve interactive output parent {}: {e}",
1345 parent.display()
1346 ))
1347 })?;
1348 }
1349 Component::Prefix(_) | Component::RootDir => {
1350 resolved.push(component.as_os_str());
1351 }
1352 Component::Normal(name) if missing => resolved.push(name),
1353 Component::Normal(name) => {
1354 if !resolved.as_os_str().is_empty() {
1355 let metadata = fs::metadata(&resolved).map_err(|e| {
1356 DispatchError(format!(
1357 "inspect interactive output ancestor {}: {e}",
1358 resolved.display()
1359 ))
1360 })?;
1361 if !metadata.is_dir() {
1362 return Err(DispatchError(format!(
1363 "interactive dispatch output has a non-directory ancestor: {}",
1364 resolved.display()
1365 )));
1366 }
1367 }
1368 let next = resolved.join(name);
1369 match fs::symlink_metadata(&next) {
1370 Ok(_) => {
1371 resolved = fs::canonicalize(&next).map_err(|e| {
1372 DispatchError(format!(
1373 "resolve interactive output component {}: {e}",
1374 next.display()
1375 ))
1376 })?;
1377 }
1378 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1379 resolved = next;
1380 missing = true;
1381 }
1382 Err(error) => {
1383 return Err(DispatchError(format!(
1384 "inspect interactive output component {}: {error}",
1385 next.display()
1386 )))
1387 }
1388 }
1389 }
1390 }
1391 }
1392
1393 if missing {
1394 Ok(ResolvedInteractiveOutput::Missing(resolved))
1395 } else if fs::metadata(&resolved)
1396 .map_err(|e| {
1397 DispatchError(format!(
1398 "inspect interactive dispatch output {}: {e}",
1399 path.display()
1400 ))
1401 })?
1402 .is_dir()
1403 {
1404 Ok(ResolvedInteractiveOutput::Existing(resolved))
1405 } else {
1406 Err(DispatchError(format!(
1407 "interactive dispatch output exists but is not a directory: {}",
1408 path.display()
1409 )))
1410 }
1411}
1412
1413#[allow(clippy::too_many_arguments)]
1414pub(crate) fn prepare_interactive_output_with_host(
1415 out_dir: &Path,
1416 target: &str,
1417 executable: &str,
1418 profile_signature: &str,
1419 profile: &str,
1420 owned_paths: &[PathBuf],
1421 purpose: Option<NativePurpose>,
1422 native_scope: Option<NativeSessionScope>,
1423 native_mcp: Option<NativeMcpDescriptor>,
1424 native_host: Option<crate::native_host::NativeHost>,
1425) -> Result<InteractiveOutput, DispatchError> {
1426 match (purpose, native_scope.as_ref()) {
1427 (Some(purpose), Some(scope)) => scope.validate_preflight(purpose)?,
1428 (Some(_), None) => {
1429 return Err(DispatchError(
1430 "native Sessions purpose requires a server-derived --native-scope descriptor"
1431 .to_string(),
1432 ))
1433 }
1434 (None, Some(_)) => {
1435 return Err(DispatchError(
1436 "--native-scope requires a native Sessions --purpose".to_string(),
1437 ))
1438 }
1439 (None, None) => {}
1440 }
1441 match (purpose, native_mcp.as_ref()) {
1442 (Some(_), Some(descriptor)) => descriptor.validate()?,
1443 (Some(_), None) | (None, None) => {}
1444 (None, Some(_)) => {
1445 return Err(DispatchError(
1446 "--native-mcp requires a native Sessions --purpose".to_string(),
1447 ))
1448 }
1449 }
1450 let (prospective_root, create_root) = match resolve_interactive_output(out_dir)? {
1451 ResolvedInteractiveOutput::Existing(root) => (root, false),
1452 ResolvedInteractiveOutput::Missing(root) => (root, true),
1453 };
1454 if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
1455 scope.validate(purpose, &prospective_root)?;
1456 }
1457 if create_root {
1458 fs::create_dir_all(out_dir).map_err(|e| {
1459 DispatchError(format!(
1460 "create interactive output directory {}: {e}",
1461 out_dir.display()
1462 ))
1463 })?;
1464 }
1465 let root = fs::canonicalize(out_dir).map_err(|e| {
1466 DispatchError(format!(
1467 "canonicalize interactive output {}: {e}",
1468 out_dir.display()
1469 ))
1470 })?;
1471 if root != prospective_root {
1472 return Err(DispatchError(format!(
1473 "interactive output canonical path changed during creation: expected {}, got {}",
1474 prospective_root.display(),
1475 root.display()
1476 )));
1477 }
1478 if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
1479 scope.validate(purpose, &root)?;
1480 }
1481 let handoff_path = root.join("RUN.md");
1482 let launch_path = root.join(".warble/interactive-launch.json");
1483 ensure_inside(&root, &handoff_path)?;
1484 ensure_inside(&root, &launch_path)?;
1485 ensure_safe_path(&root, Path::new(".warble/interactive-launch.json"))?;
1486 ensure_safe_path(&root, Path::new(".warble/interactive-ownership.json"))?;
1487 let marker = format!(
1488 "<!-- warble-interactive-artifact target={target} profile={profile_signature}{}{}{} -->",
1489 native_scope
1490 .as_ref()
1491 .map(|scope| format!(" scope_digest={}", scope.ownership_digest(&root)))
1492 .unwrap_or_default(),
1493 native_mcp
1494 .as_ref()
1495 .map(|descriptor| format!(" mcp_digest={}", descriptor.ownership_digest()))
1496 .unwrap_or_default(),
1497 native_host
1498 .as_ref()
1499 .map(|host| format!(
1500 " host_digest={}",
1501 host.document()["host_plan_sha256"]
1502 .as_str()
1503 .expect("digest")
1504 ))
1505 .unwrap_or_default(),
1506 );
1507
1508 let mut planned = owned_paths
1509 .iter()
1510 .map(|path| path.to_string_lossy().to_string())
1511 .collect::<Vec<_>>();
1512 planned.sort();
1513 planned.dedup();
1514 let ownership_path = root.join(".warble/interactive-ownership.json");
1515 ensure_inside(&root, &ownership_path)?;
1516 for relative in owned_paths {
1517 ensure_safe_path(&root, relative)?;
1518 }
1519 let any_existing = planned.iter().any(|relative| root.join(relative).exists())
1520 || launch_path.exists()
1521 || ownership_path.exists();
1522 if any_existing {
1523 let existing = fs::read_to_string(&ownership_path).map_err(|_| {
1524 DispatchError(format!(
1525 "refusing to overwrite existing interactive artifacts: {} is missing",
1526 ownership_path.display()
1527 ))
1528 })?;
1529 verify_ownership(&existing, &marker, &planned, &root)?;
1530 }
1531 for relative in owned_paths {
1532 let path = root.join(relative);
1533 if path.exists()
1534 && matches!(
1535 path.file_name().and_then(|name| name.to_str()),
1536 Some("RUN.md" | "AGENTS.md" | "SKILL.md" | "CLAUDE.md")
1537 )
1538 {
1539 let contents = fs::read_to_string(&path).map_err(|e| {
1540 DispatchError(format!(
1541 "read existing interactive artifact {}: {e}",
1542 path.display()
1543 ))
1544 })?;
1545 if !contents.contains(&marker) {
1546 return Err(DispatchError(format!(
1547 "refusing to overwrite user-owned interactive artifact {}; it lacks the expected Warble ownership marker",
1548 path.display()
1549 )));
1550 }
1551 }
1552 }
1553 if launch_path.exists() {
1554 let existing = fs::read_to_string(&launch_path).map_err(|e| {
1555 DispatchError(format!(
1556 "read existing launch spec {}: {e}",
1557 launch_path.display()
1558 ))
1559 })?;
1560 if existing
1561 != render_launch_spec(LaunchSpecInputs {
1562 target,
1563 executable,
1564 profile,
1565 root: &root,
1566 handoff: &handoff_path,
1567 purpose,
1568 native_scope: native_scope.as_ref(),
1569 native_mcp: native_mcp.as_ref(),
1570 native_host: native_host.as_ref(),
1571 })?
1572 {
1573 return Err(DispatchError(format!(
1574 "refusing to overwrite user-owned or mismatched launch spec {}",
1575 launch_path.display()
1576 )));
1577 }
1578 }
1579 Ok(InteractiveOutput {
1580 native_host,
1581 root,
1582 launch_path,
1583 handoff_path,
1584 ownership_path,
1585 owned_paths: planned,
1586 marker,
1587 target: target.into(),
1588 executable: executable.into(),
1589 purpose,
1590 native_scope,
1591 native_mcp,
1592 profile: profile.into(),
1593 })
1594}
1595
1596impl InteractiveOutput {
1597 pub fn marker(&self) -> &str {
1598 &self.marker
1599 }
1600 pub fn write_launch_spec(&self) -> Result<(), DispatchError> {
1601 let parent = self.launch_path.parent().expect(".warble parent");
1602 fs::create_dir_all(parent)
1603 .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1604 fs::write(
1605 &self.launch_path,
1606 render_launch_spec(LaunchSpecInputs {
1607 target: &self.target,
1608 executable: &self.executable,
1609 profile: &self.profile,
1610 root: &self.root,
1611 handoff: &self.handoff_path,
1612 purpose: self.purpose,
1613 native_scope: self.native_scope.as_ref(),
1614 native_mcp: self.native_mcp.as_ref(),
1615 native_host: self.native_host.as_ref(),
1616 })?,
1617 )
1618 .map_err(|e| {
1619 DispatchError(format!(
1620 "write launch spec {}: {e}",
1621 self.launch_path.display()
1622 ))
1623 })
1624 }
1625 pub fn write_ownership(&self) -> Result<(), DispatchError> {
1626 if let Some(host) = &self.native_host {
1627 use std::io::Write;
1628 fs::create_dir_all(self.root.join(".warble"))
1629 .map_err(|e| DispatchError(e.to_string()))?;
1630 let path = self.root.join(".warble/component-plans.json");
1631 let mut options = fs::OpenOptions::new();
1632 options.write(true).create(true).truncate(true);
1633 #[cfg(unix)]
1634 {
1635 use std::os::unix::fs::OpenOptionsExt;
1636 options.mode(0o600);
1637 }
1638 let mut file = options
1639 .open(&path)
1640 .map_err(|e| DispatchError(e.to_string()))?;
1641 #[cfg(unix)]
1642 {
1643 use std::os::unix::fs::PermissionsExt;
1644 file.set_permissions(fs::Permissions::from_mode(0o600))
1646 .map_err(|e| DispatchError(e.to_string()))?;
1647 }
1648 file.write_all(host.document().to_string().as_bytes())
1649 .and_then(|()| file.sync_all())
1650 .map_err(|e| DispatchError(e.to_string()))?;
1651 }
1652 let parent = self.ownership_path.parent().expect(".warble parent");
1653 fs::create_dir_all(parent)
1654 .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1655 fs::write(
1656 &self.ownership_path,
1657 ownership_document(&self.marker, &self.owned_paths, &self.root)?,
1658 )
1659 .map_err(|e| {
1660 DispatchError(format!(
1661 "write ownership record {}: {e}",
1662 self.ownership_path.display()
1663 ))
1664 })
1665 }
1666}
1667
1668fn ensure_inside(root: &Path, path: &Path) -> Result<(), DispatchError> {
1669 if !path.starts_with(root) {
1670 return Err(DispatchError(format!(
1671 "interactive artifact path escapes canonical output root: {}",
1672 path.display()
1673 )));
1674 }
1675 Ok(())
1676}
1677
1678fn ensure_safe_path(root: &Path, relative: &Path) -> Result<(), DispatchError> {
1682 if relative.is_absolute()
1683 || relative
1684 .components()
1685 .any(|component| matches!(component, std::path::Component::ParentDir))
1686 {
1687 return Err(DispatchError(format!(
1688 "interactive artifact path escapes canonical output root: {}",
1689 relative.display()
1690 )));
1691 }
1692 let components = relative.components().collect::<Vec<_>>();
1693 let mut cursor = root.to_path_buf();
1694 for (index, component) in components.iter().enumerate() {
1695 cursor.push(component.as_os_str());
1696 if let Ok(metadata) = fs::symlink_metadata(&cursor) {
1697 if metadata.file_type().is_symlink() {
1698 return Err(DispatchError(format!(
1699 "refusing interactive artifact path with symlink component: {}",
1700 cursor.display()
1701 )));
1702 }
1703 if index + 1 < components.len() && !metadata.file_type().is_dir() {
1704 return Err(DispatchError(format!(
1705 "refusing interactive artifact path with non-directory ancestor component: {}",
1706 cursor.display()
1707 )));
1708 }
1709 }
1710 }
1711 Ok(())
1712}
1713
1714struct LaunchSpecInputs<'a> {
1718 target: &'a str,
1719 executable: &'a str,
1720 profile: &'a str,
1721 root: &'a Path,
1722 handoff: &'a Path,
1723 purpose: Option<NativePurpose>,
1724 native_scope: Option<&'a NativeSessionScope>,
1725 native_mcp: Option<&'a NativeMcpDescriptor>,
1726 native_host: Option<&'a crate::native_host::NativeHost>,
1727}
1728
1729fn render_launch_spec(inputs: LaunchSpecInputs<'_>) -> Result<String, DispatchError> {
1730 let LaunchSpecInputs {
1731 target,
1732 executable,
1733 profile,
1734 root,
1735 handoff,
1736 purpose,
1737 native_scope,
1738 native_mcp,
1739 native_host,
1740 } = inputs;
1741 ensure_inside(root, handoff)?;
1742 let mut document = match (purpose, native_mcp) {
1745 (None, None) => json!({
1747 "version": LAUNCH_SPEC_VERSION,
1748 "target": target,
1749 "executable": executable,
1750 "argv": [],
1751 "cwd": root,
1752 "artifact_root": root,
1753 "handoff_path": handoff,
1754 }),
1755 (Some(purpose), None) => json!({
1756 "version": NATIVE_SESSION_LAUNCH_SPEC_VERSION,
1757 "target": target,
1758 "purpose": purpose.as_str(),
1759 "executable": executable,
1760 "argv": if target == "claude-code:interactive" {
1767 match native_scope.expect("v3 native scope preflighted").entry.pinned_verb() {
1768 Some(verb) => json!(["--agent", verb]),
1769 None => json!([]),
1770 }
1771 } else {
1772 json!([])
1773 },
1774 "agent": if target == "claude-code:interactive" {
1775 match native_scope.expect("v3 native scope preflighted").entry.pinned_verb() {
1776 Some(verb) => json!({ "kind": "claude_agent", "name": verb }),
1777 None => json!({ "kind": "claude_scope", "name": profile }),
1778 }
1779 } else {
1780 json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1781 },
1782 "scope": native_scope.expect("v3 native scope preflighted").launch_value(),
1783 "cwd": root,
1784 "artifact_root": root,
1785 "handoff_path": handoff,
1786 }),
1787 (Some(purpose), Some(_)) => {
1788 let entry = &native_scope.expect("v3 native scope preflighted").entry;
1789 let mut document = json!({
1790 "version": NATIVE_SESSION_MCP_LAUNCH_SPEC_VERSION,
1791 "target": target,
1792 "purpose": purpose.as_str(),
1793 "executable": executable,
1794 "argv": if target == "claude-code:interactive" {
1798 match entry.pinned_verb() {
1799 Some(verb) => json!(["--agent", verb, entry.prompt]),
1800 None => json!([entry.prompt]),
1803 }
1804 } else {
1805 json!([entry.prompt])
1806 },
1807 "agent": if target == "claude-code:interactive" {
1808 match entry.pinned_verb() {
1809 Some(verb) => json!({ "kind": "claude_agent", "name": verb }),
1810 None => json!({ "kind": "claude_scope", "name": profile }),
1811 }
1812 } else {
1813 json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1814 },
1815 "mcp": {
1816 "server_name": NATIVE_MCP_SERVER_NAME,
1817 "credential_env_var": NATIVE_MCP_CREDENTIAL_ENV_VAR,
1818 },
1819 "cwd": root,
1820 "artifact_root": root,
1821 "handoff_path": handoff,
1822 });
1823 if purpose == NativePurpose::Setup {
1824 document
1825 .as_object_mut()
1826 .expect("launch spec is an object")
1827 .insert(
1828 "bootstrap_root".to_string(),
1829 serde_json::to_value(
1830 native_scope
1831 .expect("v4 Setup native scope preflighted")
1832 .bootstrap_root
1833 .as_ref()
1834 .map(|root| {
1835 fs::canonicalize(root)
1836 .expect("validated bootstrap root remains canonicalizable")
1837 }),
1838 )
1839 .expect("bootstrap root serializes"),
1840 );
1841 }
1842 document
1843 }
1844 (None, Some(_)) => {
1845 return Err(DispatchError(
1846 "--native-mcp requires a native Sessions --purpose".to_string(),
1847 ))
1848 }
1849 };
1850 if let Some(host) = native_host {
1851 document["version"] = json!("5");
1852 document["component_host"] = host.launch_value();
1853 }
1854 serde_json::to_string_pretty(&document)
1855 .map(|v| format!("{v}\n"))
1856 .map_err(|e| DispatchError(e.to_string()))
1857}
1858
1859fn ownership_document(
1860 marker: &str,
1861 paths: &[String],
1862 root: &Path,
1863) -> Result<String, DispatchError> {
1864 let artifacts = paths
1865 .iter()
1866 .map(|path| {
1867 let bytes = fs::read(root.join(path))
1868 .map_err(|e| DispatchError(format!("read owned artifact {path}: {e}")))?;
1869 Ok(json!({ "path": path, "sha256": format!("{:x}", Sha256::digest(bytes)) }))
1870 })
1871 .collect::<Result<Vec<_>, DispatchError>>()?;
1872 serde_json::to_string_pretty(&json!({ "marker": marker, "artifacts": artifacts }))
1873 .map(|v| format!("{v}\n"))
1874 .map_err(|e| DispatchError(e.to_string()))
1875}
1876
1877fn verify_ownership(
1878 existing: &str,
1879 marker: &str,
1880 paths: &[String],
1881 root: &Path,
1882) -> Result<(), DispatchError> {
1883 let value: serde_json::Value = serde_json::from_str(existing).map_err(|_| {
1884 DispatchError("refusing to overwrite malformed interactive ownership record".to_string())
1885 })?;
1886 if value.get("marker").and_then(serde_json::Value::as_str) != Some(marker) {
1887 return Err(DispatchError("refusing to overwrite artifacts not owned by this exact Warble interactive emission plan".to_string()));
1888 }
1889 let artifacts = value
1890 .get("artifacts")
1891 .and_then(serde_json::Value::as_array)
1892 .ok_or_else(|| {
1893 DispatchError(
1894 "refusing to overwrite ownership record without per-artifact digests".to_string(),
1895 )
1896 })?;
1897 if artifacts.len() != paths.len() {
1898 return Err(DispatchError(
1899 "refusing to overwrite ownership record with a different artifact plan".to_string(),
1900 ));
1901 }
1902 for (artifact, path) in artifacts.iter().zip(paths) {
1903 if artifact.get("path").and_then(serde_json::Value::as_str) != Some(path.as_str()) {
1904 return Err(DispatchError(
1905 "refusing to overwrite ownership record with a different artifact plan".to_string(),
1906 ));
1907 }
1908 let bytes = fs::read(root.join(path)).map_err(|_| {
1909 DispatchError(format!(
1910 "refusing to overwrite missing owned artifact {path}"
1911 ))
1912 })?;
1913 let digest = format!("{:x}", Sha256::digest(bytes));
1914 if artifact.get("sha256").and_then(serde_json::Value::as_str) != Some(digest.as_str()) {
1915 return Err(DispatchError(format!(
1916 "refusing to overwrite modified owned artifact {path}"
1917 )));
1918 }
1919 }
1920 Ok(())
1921}
1922
1923#[cfg(test)]
1924mod tests {
1925 use super::NativeMcpDescriptor;
1926
1927 #[test]
1928 fn native_mcp_descriptor_debug_redacts_the_opaque_credential() {
1929 let descriptor = NativeMcpDescriptor {
1930 version: "1".to_string(),
1931 url: "https://mcp.example.test/native".to_string(),
1932 credential: "sentinel-opaque-native-mcp-credential".to_string(),
1933 };
1934
1935 let rendered = format!("{descriptor:?}");
1936 assert!(rendered.contains("NativeMcpDescriptor"));
1937 assert!(rendered.contains("version: \"1\""));
1938 assert!(rendered.contains("url: \"https://mcp.example.test/native\""));
1939 assert!(rendered.contains("credential: \"[REDACTED]\""));
1940 assert!(!rendered.contains("sentinel-opaque-native-mcp-credential"));
1941 }
1942}