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