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