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