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, Deserialize, Serialize)]
350#[serde(deny_unknown_fields)]
351pub struct NativeEntry {
352 pub verb: String,
353 pub prompt: String,
354}
355
356#[derive(Debug, Clone, Deserialize, Serialize)]
357#[serde(deny_unknown_fields)]
358pub struct NativeBinding {
359 pub project_identity: String,
360 pub generation: String,
361 pub revision: String,
362}
363
364#[derive(Debug, Clone, Deserialize, Serialize)]
370#[serde(deny_unknown_fields)]
371pub struct NativeWrenRuntime {
372 pub version: String,
373 pub shim: PathBuf,
374 pub launcher: PathBuf,
375 pub venv_python: PathBuf,
376 pub tool_root: PathBuf,
377 pub site_packages: PathBuf,
378 pub source_root: PathBuf,
379 pub interpreter: PathBuf,
380 pub interpreter_root: PathBuf,
381}
382
383struct CanonicalWrenRuntime {
384 shim: PathBuf,
385 shim_parent: PathBuf,
386 launcher: PathBuf,
387 venv_python: PathBuf,
388 tool_bin: PathBuf,
389 tool_pyvenv: PathBuf,
390 site_packages: PathBuf,
391 interpreter: PathBuf,
392 interpreter_bin: PathBuf,
393 interpreter_lib: PathBuf,
394 source_root: PathBuf,
395}
396
397#[derive(Clone, Deserialize)]
405#[serde(deny_unknown_fields)]
406pub struct NativeMcpDescriptor {
407 version: String,
408 url: String,
409 credential: String,
410}
411
412impl std::fmt::Debug for NativeMcpDescriptor {
413 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414 formatter
415 .debug_struct("NativeMcpDescriptor")
416 .field("version", &self.version)
417 .field("url", &self.url)
418 .field("credential", &"[REDACTED]")
419 .finish()
420 }
421}
422
423impl NativeMcpDescriptor {
424 pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
425 let raw = fs::read_to_string(path).map_err(|e| {
426 DispatchError(format!(
427 "read native MCP descriptor {}: {e}",
428 path.display()
429 ))
430 })?;
431 serde_json::from_str(&raw).map_err(|e| {
432 DispatchError(format!(
433 "parse native MCP descriptor {}: {e}",
434 path.display()
435 ))
436 })
437 }
438
439 fn validate(&self) -> Result<(), DispatchError> {
440 if self.version != NATIVE_MCP_DESCRIPTOR_VERSION {
441 return Err(DispatchError(format!(
442 "unsupported native MCP descriptor version '{}' (expected: {NATIVE_MCP_DESCRIPTOR_VERSION})",
443 self.version
444 )));
445 }
446 let url = Url::parse(&self.url).map_err(|_| {
451 DispatchError(
452 "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
453 )
454 })?;
455 let loopback = match url.host() {
456 Some(Host::Domain("localhost")) => true,
457 Some(Host::Ipv4(address)) => address.is_loopback(),
458 Some(Host::Ipv6(address)) => address.is_loopback(),
459 _ => false,
460 };
461 let allowed_scheme = url.scheme() == "https" || url.scheme() == "http" && loopback;
462 let has_network_authority = self
466 .url
467 .split_once(':')
468 .is_some_and(|(_, after_scheme)| after_scheme.starts_with("//"));
469 if self.url.len() > 2048
470 || url.host().is_none()
471 || !url.username().is_empty()
472 || url.password().is_some()
473 || url.query().is_some()
474 || url.fragment().is_some()
475 || self.url.chars().any(char::is_control)
476 || self.url.chars().any(char::is_whitespace)
477 || !has_network_authority
478 || !allowed_scheme
479 {
480 return Err(DispatchError(
481 "native MCP descriptor URL must be HTTPS or exact loopback HTTP".to_string(),
482 ));
483 }
484 if self.credential.is_empty()
485 || self.credential.len() > 4096
486 || self.credential.chars().any(char::is_control)
487 || self.credential.chars().any(char::is_whitespace)
488 {
489 return Err(DispatchError(
490 "native MCP descriptor requires a bounded opaque credential".to_string(),
491 ));
492 }
493 Ok(())
494 }
495
496 fn ownership_digest(&self) -> String {
497 let bytes = serde_json::to_vec(&json!({
498 "version": self.version,
499 "url": self.url,
500 "credential": self.credential,
501 }))
502 .expect("canonical native MCP descriptor serializes");
503 format!("sha256:{:x}", Sha256::digest(bytes))
504 }
505
506 pub fn claude_discovery_config(&self) -> Result<String, DispatchError> {
507 serde_json::to_string_pretty(&json!({
508 "mcpServers": {
509 NATIVE_MCP_SERVER_NAME: {
510 "type": "http",
511 "url": self.url,
512 "headers": { "Authorization": format!("Bearer {}", self.credential) },
513 }
514 }
515 }))
516 .map(|value| format!("{value}\n"))
517 .map_err(|e| DispatchError(e.to_string()))
518 }
519
520 pub fn codex_discovery_config(
521 &self,
522 enable_setup_recovery_tool: bool,
523 enable_dashboard_save_tool: bool,
524 enable_persist_answer_tool: bool,
525 ) -> String {
526 let mut config = format!(
529 "[mcp_servers.{NATIVE_MCP_SERVER_NAME}]\nurl = {}\nbearer_token_env_var = {}\n",
530 serde_json::to_string(&self.url).expect("MCP URL serializes"),
531 serde_json::to_string(NATIVE_MCP_CREDENTIAL_ENV_VAR)
532 .expect("credential env name serializes"),
533 );
534 let mut enabled_tools = Vec::new();
535 if enable_setup_recovery_tool {
536 enabled_tools.push("report_setup_recovery");
537 }
538 if enable_dashboard_save_tool {
539 enabled_tools.push(NATIVE_DASHBOARD_SAVE_TOOL);
540 }
541 if enable_persist_answer_tool {
542 enabled_tools.push(NATIVE_PERSIST_ANSWER_TOOL);
543 }
544 if !enabled_tools.is_empty() {
545 config.push_str(&format!(
546 "enabled_tools = {}\n",
547 serde_json::to_string(&enabled_tools).expect("MCP tool names serialize"),
548 ));
549 }
550 config
551 }
552}
553
554impl NativeSessionScope {
555 pub fn from_file(path: &Path) -> Result<Self, DispatchError> {
556 let raw = fs::read_to_string(path).map_err(|e| {
557 DispatchError(format!("read native session scope {}: {e}", path.display()))
558 })?;
559 serde_json::from_str(&raw).map_err(|e| {
560 DispatchError(format!(
561 "parse native session scope {}: {e}",
562 path.display()
563 ))
564 })
565 }
566
567 fn validate_preflight(&self, purpose: NativePurpose) -> Result<(), DispatchError> {
568 if self.version != NATIVE_SCOPE_VERSION {
569 return Err(DispatchError(format!(
570 "unsupported native session scope version '{}' (expected: {NATIVE_SCOPE_VERSION})",
571 self.version
572 )));
573 }
574 if self.kind != purpose.scope_kind() {
575 return Err(DispatchError(format!(
576 "native session scope kind '{}' does not match purpose '{}' ({})",
577 self.kind,
578 purpose.as_str(),
579 purpose.scope_kind()
580 )));
581 }
582 if self.scope_id.trim().is_empty() {
583 return Err(DispatchError(
584 "native session scope requires a non-empty opaque scope_id".to_string(),
585 ));
586 }
587 if self.entry.verb.trim().is_empty() {
588 return Err(DispatchError(
589 "native session scope requires a non-empty entry verb".to_string(),
590 ));
591 }
592 if self.entry.prompt.trim().is_empty() {
593 return Err(DispatchError(
594 "native session scope requires a non-empty entry prompt".to_string(),
595 ));
596 }
597 for (field, value) in [("verb", &self.entry.verb), ("prompt", &self.entry.prompt)] {
604 if value.trim_start().starts_with('-') {
605 return Err(DispatchError(format!(
606 "native session scope entry {field} must not begin with '-': a dash-leading \
607 value would be parsed as a vendor CLI option instead of argv content"
608 )));
609 }
610 }
611 if !self.cwd.is_absolute() {
612 return Err(DispatchError(
613 "native session scope cwd must be an absolute server-derived path".to_string(),
614 ));
615 }
616 match (&self.kind[..], &self.binding, &self.bootstrap_root) {
617 ("bootstrap", None, Some(bootstrap_root)) => {
618 if !bootstrap_root.is_absolute() {
619 return Err(DispatchError(
620 "bootstrap native session scope root must be an absolute server-derived path".to_string(),
621 ));
622 }
623 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
624 DispatchError(format!(
625 "canonicalize bootstrap native session scope root {}: {e}",
626 bootstrap_root.display()
627 ))
628 })?;
629 if *bootstrap_root != canonical_bootstrap_root {
630 return Err(DispatchError(
631 "bootstrap native session scope root must already be canonical".to_string(),
632 ));
633 }
634 Ok(())
635 }
636 ("bootstrap", None, None) => Err(DispatchError(
637 "bootstrap native session scope requires a separately authorized bootstrap_root"
638 .to_string(),
639 )),
640 ("bootstrap", Some(_), _) => Err(DispatchError(
641 "bootstrap native session scope must not carry a bound-project identity".to_string(),
642 )),
643 ("bound_project", Some(binding), None)
644 if !binding.project_identity.trim().is_empty()
645 && !binding.generation.trim().is_empty()
646 && !binding.revision.trim().is_empty() =>
647 {
648 Ok(())
649 }
650 ("bound_project", _, Some(_)) => Err(DispatchError(
651 "bound_project native session scope must not carry a bootstrap_root".to_string(),
652 )),
653 ("bound_project", _, None) => Err(DispatchError(
654 "bound_project native session scope requires non-empty project_identity, generation, and revision"
655 .to_string(),
656 )),
657 _ => unreachable!("purpose mapping has a closed scope vocabulary"),
658 }
659 }
660
661 fn validate(&self, purpose: NativePurpose, root: &Path) -> Result<(), DispatchError> {
662 self.validate_preflight(purpose)?;
663 let scope_cwd = match fs::canonicalize(&self.cwd) {
664 Ok(canonical) => {
665 if self.cwd != canonical {
666 return Err(DispatchError(
667 "native session scope cwd must already be canonical".to_string(),
668 ));
669 }
670 canonical
671 }
672 Err(error) if error.kind() == std::io::ErrorKind::NotFound && self.cwd == root => {
673 root.to_path_buf()
674 }
675 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
676 return Err(DispatchError(format!(
677 "native session scope cwd {} does not match canonical output root {}",
678 self.cwd.display(),
679 root.display()
680 )))
681 }
682 Err(error) => {
683 return Err(DispatchError(format!(
684 "canonicalize native session scope cwd {}: {error}",
685 self.cwd.display()
686 )))
687 }
688 };
689 if scope_cwd != root {
690 return Err(DispatchError(format!(
691 "native session scope cwd {} does not match canonical output root {}",
692 scope_cwd.display(),
693 root.display()
694 )));
695 }
696 if let Some(bootstrap_root) = &self.bootstrap_root {
697 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|e| {
698 DispatchError(format!(
699 "canonicalize bootstrap native session scope root {}: {e}",
700 bootstrap_root.display()
701 ))
702 })?;
703 if canonical_bootstrap_root == root {
704 return Err(DispatchError(
705 "bootstrap native session scope root must be distinct from the canonical output root"
706 .to_string(),
707 ));
708 }
709 }
710 Ok(())
711 }
712
713 fn ownership_digest(&self, canonical_cwd: &Path) -> String {
714 let canonical = json!({
718 "version": self.version,
719 "kind": self.kind,
720 "scope_id": self.scope_id,
721 "cwd": canonical_cwd,
722 "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
723 fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
724 }),
725 "wren_runtime": self.wren_runtime,
726 "binding": self.binding,
727 "entry": self.entry,
731 });
732 let bytes = serde_json::to_vec(&canonical).expect("canonical native scope serializes");
733 format!("sha256:{:x}", Sha256::digest(bytes))
734 }
735
736 fn launch_value(&self) -> serde_json::Value {
737 json!({
738 "kind": self.kind,
739 "scope_id": self.scope_id,
740 "bootstrap_root": self.bootstrap_root.as_ref().map(|root| {
741 fs::canonicalize(root).expect("validated bootstrap root remains canonicalizable")
742 }),
743 "binding": self.binding,
744 })
745 }
746
747 pub fn codex_permission_profile(&self) -> Result<String, DispatchError> {
750 let mut config = self
751 .wren_runtime
752 .as_ref()
753 .ok_or_else(|| {
754 DispatchError(
755 "native Codex purpose requires a server-derived wren_runtime closure"
756 .to_string(),
757 )
758 })?
759 .codex_permission_profile()?;
760 config.push_str(&format!(
761 "\n[permissions.{CODEX_WREN_PERMISSION_PROFILE}.filesystem.\":workspace_roots\"]\n"
762 ));
763 if self.kind == "bootstrap" {
764 let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
765 DispatchError(
766 "bootstrap native session scope requires a separately authorized bootstrap_root"
767 .to_string(),
768 )
769 })?;
770 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
771 DispatchError("bootstrap native session scope root is unavailable".to_string())
772 })?;
773 if *bootstrap_root != canonical_bootstrap_root {
774 return Err(DispatchError(
775 "bootstrap native session scope root must already be canonical".to_string(),
776 ));
777 }
778 config.push_str("\".\" = \"read\"\n");
781 config.push_str(&format!(
782 "{} = \"write\"\n",
783 serde_json::to_string(&canonical_bootstrap_root.to_string_lossy())
784 .expect("bootstrap root serializes")
785 ));
786 } else {
787 config.push_str("\".\" = \"write\"\n");
788 }
789 Ok(config)
790 }
791
792 pub fn claude_setup_write_permissions(&self) -> Result<[String; 2], DispatchError> {
796 let bootstrap_root = self.bootstrap_root.as_ref().ok_or_else(|| {
797 DispatchError(
798 "bootstrap native session scope requires a separately authorized bootstrap_root"
799 .to_string(),
800 )
801 })?;
802 let canonical_bootstrap_root = fs::canonicalize(bootstrap_root).map_err(|_| {
803 DispatchError("bootstrap native session scope root is unavailable".to_string())
804 })?;
805 if self.kind != "bootstrap" || *bootstrap_root != canonical_bootstrap_root {
806 return Err(DispatchError(
807 "bootstrap native session scope root is incompatible".to_string(),
808 ));
809 }
810 let recursive = canonical_bootstrap_root
811 .join("**")
812 .to_string_lossy()
813 .to_string();
814 Ok([format!("Edit({recursive})"), format!("Write({recursive})")])
815 }
816}
817
818impl NativeWrenRuntime {
819 fn validate(&self) -> Result<CanonicalWrenRuntime, DispatchError> {
820 if self.version != NATIVE_WREN_RUNTIME_VERSION {
821 return Err(DispatchError(format!(
822 "unsupported native Wren runtime version '{}' (expected: {NATIVE_WREN_RUNTIME_VERSION})",
823 self.version
824 )));
825 }
826
827 let tool_root = canonical_directory(&self.tool_root, "tool_root")?;
828 let interpreter_root = canonical_directory(&self.interpreter_root, "interpreter_root")?;
829 let site_packages = canonical_directory(&self.site_packages, "site_packages")?;
830 let source_root = canonical_directory(&self.source_root, "source_root")?;
831 let launcher = canonical_regular_file(&self.launcher, "launcher")?;
832 let interpreter = canonical_regular_file(&self.interpreter, "interpreter")?;
833 let shim = canonical_regular_file(&self.shim, "shim")?;
834 let venv_python = canonical_regular_file(&self.venv_python, "venv_python")?;
835 let shim_parent = canonical_directory(
836 self.shim.parent().ok_or_else(|| {
837 DispatchError("native Wren runtime shim must have a parent directory".to_string())
838 })?,
839 "shim parent",
840 )?;
841
842 let tool_bin = tool_root.join("bin");
843 let interpreter_bin = interpreter_root.join("bin");
844 let tool_pyvenv = tool_root.join("pyvenv.cfg");
845 let tool_lib = tool_root.join("lib");
846 let interpreter_lib = interpreter_root.join("lib");
847 for (path, label) in [
848 (&tool_bin, "tool_root/bin"),
849 (&interpreter_bin, "interpreter_root/bin"),
850 (&interpreter_lib, "interpreter_root/lib"),
851 ] {
852 if !path.is_dir() {
853 return Err(DispatchError(format!(
854 "native Wren runtime {label} must be an existing directory"
855 )));
856 }
857 }
858 if !tool_pyvenv.is_file() {
859 return Err(DispatchError(
860 "native Wren runtime tool_root/pyvenv.cfg must be an existing regular file"
861 .to_string(),
862 ));
863 }
864 if !site_packages.starts_with(&tool_lib) {
865 return Err(DispatchError(
866 "native Wren runtime site_packages must be inside tool_root/lib".to_string(),
867 ));
868 }
869 let editable_pth = site_packages.join("_editable_impl_wrenai.pth");
870 let editable_source = fs::read_to_string(&editable_pth)
871 .ok()
872 .and_then(|contents| {
873 let paths = contents
874 .lines()
875 .map(str::trim)
876 .filter(|line| !line.is_empty())
877 .collect::<Vec<_>>();
878 let [path] = paths.as_slice() else {
879 return None;
880 };
881 Path::new(path).is_absolute().then(|| PathBuf::from(path))
882 })
883 .and_then(|path| fs::canonicalize(path).ok());
884 if editable_source.as_deref() != Some(source_root.as_path())
885 || !source_root.join("wren/__init__.py").is_file()
886 {
887 return Err(DispatchError(
888 "native Wren runtime editable source must exactly match the server-approved .pth closure"
889 .to_string(),
890 ));
891 }
892
893 if self.launcher != tool_bin.join("wren") || self.venv_python != tool_bin.join("python") {
896 return Err(DispatchError(
897 "native Wren runtime launcher chain must use tool_root/bin/wren and tool_root/bin/python"
898 .to_string(),
899 ));
900 }
901 if !fs::symlink_metadata(&self.shim)
902 .map_err(|e| {
903 DispatchError(format!(
904 "inspect native Wren runtime shim {}: {e}",
905 self.shim.display()
906 ))
907 })?
908 .file_type()
909 .is_symlink()
910 {
911 return Err(DispatchError(
912 "native Wren runtime shim must be a symlink to the server-approved launcher"
913 .to_string(),
914 ));
915 }
916 if !fs::symlink_metadata(&self.venv_python)
917 .map_err(|e| {
918 DispatchError(format!(
919 "inspect native Wren runtime venv_python {}: {e}",
920 self.venv_python.display()
921 ))
922 })?
923 .file_type()
924 .is_symlink()
925 {
926 return Err(DispatchError(
927 "native Wren runtime tool_root/bin/python must be a symlink to the server-approved interpreter"
928 .to_string(),
929 ));
930 }
931 if shim != launcher
932 || venv_python != interpreter
933 || !interpreter.starts_with(&interpreter_bin)
934 {
935 return Err(DispatchError(
936 "native Wren runtime shim/interpreter chain does not resolve to the declared closure"
937 .to_string(),
938 ));
939 }
940 if !is_executable(&launcher) || !is_executable(&interpreter) {
941 return Err(DispatchError(
942 "native Wren runtime launcher and interpreter must be executable regular files"
943 .to_string(),
944 ));
945 }
946 let shebang = fs::read_to_string(&launcher)
947 .map_err(|e| {
948 DispatchError(format!(
949 "read native Wren launcher {}: {e}",
950 launcher.display()
951 ))
952 })?
953 .lines()
954 .next()
955 .and_then(|line| line.strip_prefix("#!"))
956 .filter(|path| !path.is_empty() && !path.contains(char::is_whitespace))
957 .map(PathBuf::from)
958 .ok_or_else(|| {
959 DispatchError(
960 "native Wren runtime launcher must start with an absolute interpreter shebang"
961 .to_string(),
962 )
963 })?;
964 if !shebang.is_absolute() || shebang != self.venv_python {
965 return Err(DispatchError(
966 "native Wren runtime launcher shebang must exactly name tool_root/bin/python"
967 .to_string(),
968 ));
969 }
970
971 Ok(CanonicalWrenRuntime {
972 shim: self.shim.clone(),
973 shim_parent,
974 launcher: self.launcher.clone(),
975 venv_python: self.venv_python.clone(),
976 tool_bin,
977 tool_pyvenv,
978 site_packages: self.site_packages.clone(),
979 interpreter: self.interpreter.clone(),
980 interpreter_bin,
981 interpreter_lib,
982 source_root: self.source_root.clone(),
983 })
984 }
985
986 fn codex_permission_profile(&self) -> Result<String, DispatchError> {
987 let paths = self.validate()?;
988 let entry = |path: &Path| {
989 format!(
990 "{} = \"read\"\n",
991 serde_json::to_string(&path.to_string_lossy()).expect("runtime path serializes")
992 )
993 };
994 let mut config = format!(
995 "# 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"
996 );
997 for path in [
998 &paths.shim,
999 &paths.shim_parent,
1000 &paths.tool_bin,
1001 &paths.launcher,
1002 &paths.venv_python,
1003 &paths.tool_pyvenv,
1004 &paths.site_packages,
1005 &paths.interpreter,
1006 &paths.interpreter_bin,
1007 &paths.interpreter_lib,
1008 &paths.source_root,
1009 ] {
1010 config.push_str(&entry(path));
1011 }
1012 Ok(config)
1013 }
1014}
1015
1016fn canonical_directory(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
1017 if !path.is_absolute() {
1018 return Err(DispatchError(format!(
1019 "native Wren runtime {label} must be an absolute server-derived path"
1020 )));
1021 }
1022 let canonical = fs::canonicalize(path).map_err(|e| {
1023 DispatchError(format!(
1024 "canonicalize native Wren runtime {label} {}: {e}",
1025 path.display()
1026 ))
1027 })?;
1028 canonical.is_dir().then_some(canonical).ok_or_else(|| {
1029 DispatchError(format!(
1030 "native Wren runtime {label} must be an existing directory"
1031 ))
1032 })
1033}
1034
1035fn canonical_regular_file(path: &Path, label: &str) -> Result<PathBuf, DispatchError> {
1036 if !path.is_absolute() {
1037 return Err(DispatchError(format!(
1038 "native Wren runtime {label} must be an absolute server-derived path"
1039 )));
1040 }
1041 let canonical = fs::canonicalize(path).map_err(|e| {
1042 DispatchError(format!(
1043 "canonicalize native Wren runtime {label} {}: {e}",
1044 path.display()
1045 ))
1046 })?;
1047 canonical.is_file().then_some(canonical).ok_or_else(|| {
1048 DispatchError(format!(
1049 "native Wren runtime {label} must be an existing regular file"
1050 ))
1051 })
1052}
1053
1054#[cfg(unix)]
1055fn is_executable(path: &Path) -> bool {
1056 use std::os::unix::fs::PermissionsExt;
1057
1058 fs::metadata(path).is_ok_and(|metadata| metadata.permissions().mode() & 0o111 != 0)
1059}
1060
1061#[cfg(not(unix))]
1062fn is_executable(_path: &Path) -> bool {
1063 false
1064}
1065
1066#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1072pub enum NativePurpose {
1073 Analysis,
1074 Setup,
1075 ContextEnrichment,
1076}
1077
1078impl NativePurpose {
1079 pub fn parse(value: &str) -> Option<Self> {
1080 match value {
1081 "analysis" => Some(Self::Analysis),
1082 "setup" => Some(Self::Setup),
1083 "context_enrichment" => Some(Self::ContextEnrichment),
1084 _ => None,
1085 }
1086 }
1087
1088 pub fn as_str(self) -> &'static str {
1089 match self {
1090 Self::Analysis => "analysis",
1091 Self::Setup => "setup",
1092 Self::ContextEnrichment => "context_enrichment",
1093 }
1094 }
1095
1096 fn scope_kind(self) -> &'static str {
1097 match self {
1098 Self::Setup => "bootstrap",
1099 Self::Analysis | Self::ContextEnrichment => "bound_project",
1100 }
1101 }
1102
1103 pub fn codex_skill(self) -> &'static str {
1104 match self {
1105 Self::Analysis => "genbi-analysis",
1106 Self::Setup => "genbi-setup",
1107 Self::ContextEnrichment => "genbi-enrich-context",
1108 }
1109 }
1110
1111 pub fn codex_description(self) -> &'static str {
1112 match self {
1113 Self::Analysis => "Analyze the server-bound semantic project using the GenBI analysis behavior.",
1114 Self::Setup => "Connect a source and build a semantic context only in the server-created bootstrap scope.",
1115 Self::ContextEnrichment => "Inspect a pinned project and draft read-only enrichment proposals; never apply an enrichment.",
1116 }
1117 }
1118
1119 pub fn validate_profile(self, ir: &WarbleIr, entry: &NativeEntry) -> Result<(), DispatchError> {
1137 let verb = entry.verb.as_str();
1138 let entries = ir
1139 .components
1140 .iter()
1141 .filter(|node| node.verb == verb)
1142 .collect::<Vec<_>>();
1143 let [component] = entries.as_slice() else {
1144 return Err(DispatchError(format!(
1145 "native purpose '{}' requires exactly one component with the declared entry verb '{}', found {}",
1146 self.as_str(),
1147 verb,
1148 entries.len()
1149 )));
1150 };
1151 if component.id != verb
1152 || component.realization_kind != crate::ir::RealizationKind::Skill
1153 || component.trigger.kind != crate::ir::TriggerKind::OneShot
1154 || component.effect.outcome.kind != crate::ir::OutcomeKind::None
1155 || component
1156 .required_capabilities
1157 .iter()
1158 .any(|capability| capability == "enrichment_apply:deterministic")
1159 {
1160 return Err(DispatchError(format!(
1161 "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",
1162 self.as_str(),
1163 verb
1164 )));
1165 }
1166 Ok(())
1167 }
1168}
1169
1170pub struct InteractiveOutput {
1171 pub root: PathBuf,
1172 pub launch_path: PathBuf,
1173 pub handoff_path: PathBuf,
1174 ownership_path: PathBuf,
1175 owned_paths: Vec<String>,
1176 marker: String,
1177 target: String,
1178 executable: String,
1179 purpose: Option<NativePurpose>,
1180 native_scope: Option<NativeSessionScope>,
1181 native_mcp: Option<NativeMcpDescriptor>,
1182}
1183
1184enum ResolvedInteractiveOutput {
1185 Existing(PathBuf),
1186 Missing(PathBuf),
1187}
1188
1189fn resolve_interactive_output(path: &Path) -> Result<ResolvedInteractiveOutput, DispatchError> {
1190 let mut resolved = if path.is_absolute() {
1191 PathBuf::new()
1192 } else {
1193 let current = std::env::current_dir()
1194 .map_err(|e| DispatchError(format!("resolve current directory: {e}")))?;
1195 fs::canonicalize(¤t).map_err(|e| {
1196 DispatchError(format!(
1197 "canonicalize current directory {}: {e}",
1198 current.display()
1199 ))
1200 })?
1201 };
1202 let mut missing = false;
1203 for component in path.components() {
1204 match component {
1205 Component::CurDir => {}
1206 Component::ParentDir => {
1207 if missing {
1208 return Err(DispatchError(format!(
1209 "interactive output cannot traverse '..' after a missing component: {}",
1210 path.display()
1211 )));
1212 }
1213 let parent = resolved.parent().unwrap_or(resolved.as_path());
1214 resolved = fs::canonicalize(parent).map_err(|e| {
1215 DispatchError(format!(
1216 "resolve interactive output parent {}: {e}",
1217 parent.display()
1218 ))
1219 })?;
1220 }
1221 Component::Prefix(_) | Component::RootDir => {
1222 resolved.push(component.as_os_str());
1223 }
1224 Component::Normal(name) if missing => resolved.push(name),
1225 Component::Normal(name) => {
1226 if !resolved.as_os_str().is_empty() {
1227 let metadata = fs::metadata(&resolved).map_err(|e| {
1228 DispatchError(format!(
1229 "inspect interactive output ancestor {}: {e}",
1230 resolved.display()
1231 ))
1232 })?;
1233 if !metadata.is_dir() {
1234 return Err(DispatchError(format!(
1235 "interactive dispatch output has a non-directory ancestor: {}",
1236 resolved.display()
1237 )));
1238 }
1239 }
1240 let next = resolved.join(name);
1241 match fs::symlink_metadata(&next) {
1242 Ok(_) => {
1243 resolved = fs::canonicalize(&next).map_err(|e| {
1244 DispatchError(format!(
1245 "resolve interactive output component {}: {e}",
1246 next.display()
1247 ))
1248 })?;
1249 }
1250 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1251 resolved = next;
1252 missing = true;
1253 }
1254 Err(error) => {
1255 return Err(DispatchError(format!(
1256 "inspect interactive output component {}: {error}",
1257 next.display()
1258 )))
1259 }
1260 }
1261 }
1262 }
1263 }
1264
1265 if missing {
1266 Ok(ResolvedInteractiveOutput::Missing(resolved))
1267 } else if fs::metadata(&resolved)
1268 .map_err(|e| {
1269 DispatchError(format!(
1270 "inspect interactive dispatch output {}: {e}",
1271 path.display()
1272 ))
1273 })?
1274 .is_dir()
1275 {
1276 Ok(ResolvedInteractiveOutput::Existing(resolved))
1277 } else {
1278 Err(DispatchError(format!(
1279 "interactive dispatch output exists but is not a directory: {}",
1280 path.display()
1281 )))
1282 }
1283}
1284
1285#[allow(clippy::too_many_arguments)]
1286pub fn prepare_interactive_output(
1287 out_dir: &Path,
1288 target: &str,
1289 executable: &str,
1290 profile_signature: &str,
1291 owned_paths: &[PathBuf],
1292 purpose: Option<NativePurpose>,
1293 native_scope: Option<NativeSessionScope>,
1294 native_mcp: Option<NativeMcpDescriptor>,
1295) -> Result<InteractiveOutput, DispatchError> {
1296 match (purpose, native_scope.as_ref()) {
1297 (Some(purpose), Some(scope)) => scope.validate_preflight(purpose)?,
1298 (Some(_), None) => {
1299 return Err(DispatchError(
1300 "native Sessions purpose requires a server-derived --native-scope descriptor"
1301 .to_string(),
1302 ))
1303 }
1304 (None, Some(_)) => {
1305 return Err(DispatchError(
1306 "--native-scope requires a native Sessions --purpose".to_string(),
1307 ))
1308 }
1309 (None, None) => {}
1310 }
1311 match (purpose, native_mcp.as_ref()) {
1312 (Some(_), Some(descriptor)) => descriptor.validate()?,
1313 (Some(_), None) | (None, None) => {}
1314 (None, Some(_)) => {
1315 return Err(DispatchError(
1316 "--native-mcp requires a native Sessions --purpose".to_string(),
1317 ))
1318 }
1319 }
1320 let (prospective_root, create_root) = match resolve_interactive_output(out_dir)? {
1321 ResolvedInteractiveOutput::Existing(root) => (root, false),
1322 ResolvedInteractiveOutput::Missing(root) => (root, true),
1323 };
1324 if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
1325 scope.validate(purpose, &prospective_root)?;
1326 }
1327 if create_root {
1328 fs::create_dir_all(out_dir).map_err(|e| {
1329 DispatchError(format!(
1330 "create interactive output directory {}: {e}",
1331 out_dir.display()
1332 ))
1333 })?;
1334 }
1335 let root = fs::canonicalize(out_dir).map_err(|e| {
1336 DispatchError(format!(
1337 "canonicalize interactive output {}: {e}",
1338 out_dir.display()
1339 ))
1340 })?;
1341 if root != prospective_root {
1342 return Err(DispatchError(format!(
1343 "interactive output canonical path changed during creation: expected {}, got {}",
1344 prospective_root.display(),
1345 root.display()
1346 )));
1347 }
1348 if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
1349 scope.validate(purpose, &root)?;
1350 }
1351 let handoff_path = root.join("RUN.md");
1352 let launch_path = root.join(".warble/interactive-launch.json");
1353 ensure_inside(&root, &handoff_path)?;
1354 ensure_inside(&root, &launch_path)?;
1355 ensure_safe_path(&root, Path::new(".warble/interactive-launch.json"))?;
1356 ensure_safe_path(&root, Path::new(".warble/interactive-ownership.json"))?;
1357 let marker = format!(
1358 "<!-- warble-interactive-artifact target={target} profile={profile_signature}{}{} -->",
1359 native_scope
1360 .as_ref()
1361 .map(|scope| format!(" scope_digest={}", scope.ownership_digest(&root)))
1362 .unwrap_or_default(),
1363 native_mcp
1364 .as_ref()
1365 .map(|descriptor| format!(" mcp_digest={}", descriptor.ownership_digest()))
1366 .unwrap_or_default(),
1367 );
1368
1369 let mut planned = owned_paths
1370 .iter()
1371 .map(|path| path.to_string_lossy().to_string())
1372 .collect::<Vec<_>>();
1373 planned.sort();
1374 planned.dedup();
1375 let ownership_path = root.join(".warble/interactive-ownership.json");
1376 ensure_inside(&root, &ownership_path)?;
1377 for relative in owned_paths {
1378 ensure_safe_path(&root, relative)?;
1379 }
1380 let any_existing = planned.iter().any(|relative| root.join(relative).exists())
1381 || launch_path.exists()
1382 || ownership_path.exists();
1383 if any_existing {
1384 let existing = fs::read_to_string(&ownership_path).map_err(|_| {
1385 DispatchError(format!(
1386 "refusing to overwrite existing interactive artifacts: {} is missing",
1387 ownership_path.display()
1388 ))
1389 })?;
1390 verify_ownership(&existing, &marker, &planned, &root)?;
1391 }
1392 for relative in owned_paths {
1393 let path = root.join(relative);
1394 if path.exists()
1395 && matches!(
1396 path.file_name().and_then(|name| name.to_str()),
1397 Some("RUN.md" | "AGENTS.md" | "SKILL.md" | "CLAUDE.md")
1398 )
1399 {
1400 let contents = fs::read_to_string(&path).map_err(|e| {
1401 DispatchError(format!(
1402 "read existing interactive artifact {}: {e}",
1403 path.display()
1404 ))
1405 })?;
1406 if !contents.contains(&marker) {
1407 return Err(DispatchError(format!(
1408 "refusing to overwrite user-owned interactive artifact {}; it lacks the expected Warble ownership marker",
1409 path.display()
1410 )));
1411 }
1412 }
1413 }
1414 if launch_path.exists() {
1415 let existing = fs::read_to_string(&launch_path).map_err(|e| {
1416 DispatchError(format!(
1417 "read existing launch spec {}: {e}",
1418 launch_path.display()
1419 ))
1420 })?;
1421 if existing
1422 != render_launch_spec(
1423 target,
1424 executable,
1425 &root,
1426 &handoff_path,
1427 purpose,
1428 native_scope.as_ref(),
1429 native_mcp.as_ref(),
1430 )?
1431 {
1432 return Err(DispatchError(format!(
1433 "refusing to overwrite user-owned or mismatched launch spec {}",
1434 launch_path.display()
1435 )));
1436 }
1437 }
1438 Ok(InteractiveOutput {
1439 root,
1440 launch_path,
1441 handoff_path,
1442 ownership_path,
1443 owned_paths: planned,
1444 marker,
1445 target: target.into(),
1446 executable: executable.into(),
1447 purpose,
1448 native_scope,
1449 native_mcp,
1450 })
1451}
1452
1453impl InteractiveOutput {
1454 pub fn marker(&self) -> &str {
1455 &self.marker
1456 }
1457 pub fn write_launch_spec(&self) -> Result<(), DispatchError> {
1458 let parent = self.launch_path.parent().expect(".warble parent");
1459 fs::create_dir_all(parent)
1460 .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1461 fs::write(
1462 &self.launch_path,
1463 render_launch_spec(
1464 &self.target,
1465 &self.executable,
1466 &self.root,
1467 &self.handoff_path,
1468 self.purpose,
1469 self.native_scope.as_ref(),
1470 self.native_mcp.as_ref(),
1471 )?,
1472 )
1473 .map_err(|e| {
1474 DispatchError(format!(
1475 "write launch spec {}: {e}",
1476 self.launch_path.display()
1477 ))
1478 })
1479 }
1480 pub fn write_ownership(&self) -> Result<(), DispatchError> {
1481 let parent = self.ownership_path.parent().expect(".warble parent");
1482 fs::create_dir_all(parent)
1483 .map_err(|e| DispatchError(format!("create {}: {e}", parent.display())))?;
1484 fs::write(
1485 &self.ownership_path,
1486 ownership_document(&self.marker, &self.owned_paths, &self.root)?,
1487 )
1488 .map_err(|e| {
1489 DispatchError(format!(
1490 "write ownership record {}: {e}",
1491 self.ownership_path.display()
1492 ))
1493 })
1494 }
1495}
1496
1497fn ensure_inside(root: &Path, path: &Path) -> Result<(), DispatchError> {
1498 if !path.starts_with(root) {
1499 return Err(DispatchError(format!(
1500 "interactive artifact path escapes canonical output root: {}",
1501 path.display()
1502 )));
1503 }
1504 Ok(())
1505}
1506
1507fn ensure_safe_path(root: &Path, relative: &Path) -> Result<(), DispatchError> {
1511 if relative.is_absolute()
1512 || relative
1513 .components()
1514 .any(|component| matches!(component, std::path::Component::ParentDir))
1515 {
1516 return Err(DispatchError(format!(
1517 "interactive artifact path escapes canonical output root: {}",
1518 relative.display()
1519 )));
1520 }
1521 let components = relative.components().collect::<Vec<_>>();
1522 let mut cursor = root.to_path_buf();
1523 for (index, component) in components.iter().enumerate() {
1524 cursor.push(component.as_os_str());
1525 if let Ok(metadata) = fs::symlink_metadata(&cursor) {
1526 if metadata.file_type().is_symlink() {
1527 return Err(DispatchError(format!(
1528 "refusing interactive artifact path with symlink component: {}",
1529 cursor.display()
1530 )));
1531 }
1532 if index + 1 < components.len() && !metadata.file_type().is_dir() {
1533 return Err(DispatchError(format!(
1534 "refusing interactive artifact path with non-directory ancestor component: {}",
1535 cursor.display()
1536 )));
1537 }
1538 }
1539 }
1540 Ok(())
1541}
1542
1543fn render_launch_spec(
1544 target: &str,
1545 executable: &str,
1546 root: &Path,
1547 handoff: &Path,
1548 purpose: Option<NativePurpose>,
1549 native_scope: Option<&NativeSessionScope>,
1550 native_mcp: Option<&NativeMcpDescriptor>,
1551) -> Result<String, DispatchError> {
1552 ensure_inside(root, handoff)?;
1553 let document = match (purpose, native_mcp) {
1556 (None, None) => json!({
1558 "version": LAUNCH_SPEC_VERSION,
1559 "target": target,
1560 "executable": executable,
1561 "argv": [],
1562 "cwd": root,
1563 "artifact_root": root,
1564 "handoff_path": handoff,
1565 }),
1566 (Some(purpose), None) => json!({
1567 "version": NATIVE_SESSION_LAUNCH_SPEC_VERSION,
1568 "target": target,
1569 "purpose": purpose.as_str(),
1570 "executable": executable,
1571 "argv": if target == "claude-code:interactive" {
1576 json!(["--agent", native_scope.expect("v3 native scope preflighted").entry.verb])
1577 } else {
1578 json!([])
1579 },
1580 "agent": if target == "claude-code:interactive" {
1581 json!({ "kind": "claude_agent", "name": native_scope.expect("v3 native scope preflighted").entry.verb })
1582 } else {
1583 json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1584 },
1585 "scope": native_scope.expect("v3 native scope preflighted").launch_value(),
1586 "cwd": root,
1587 "artifact_root": root,
1588 "handoff_path": handoff,
1589 }),
1590 (Some(purpose), Some(_)) => {
1591 let entry = &native_scope.expect("v3 native scope preflighted").entry;
1592 let mut document = json!({
1593 "version": NATIVE_SESSION_MCP_LAUNCH_SPEC_VERSION,
1594 "target": target,
1595 "purpose": purpose.as_str(),
1596 "executable": executable,
1597 "argv": if target == "claude-code:interactive" {
1601 json!(["--agent", entry.verb, entry.prompt])
1602 } else {
1603 json!([entry.prompt])
1604 },
1605 "agent": if target == "claude-code:interactive" {
1606 json!({ "kind": "claude_agent", "name": entry.verb })
1607 } else {
1608 json!({ "kind": "codex_skill", "name": purpose.codex_skill() })
1609 },
1610 "mcp": {
1611 "server_name": NATIVE_MCP_SERVER_NAME,
1612 "credential_env_var": NATIVE_MCP_CREDENTIAL_ENV_VAR,
1613 },
1614 "cwd": root,
1615 "artifact_root": root,
1616 "handoff_path": handoff,
1617 });
1618 if purpose == NativePurpose::Setup {
1619 document
1620 .as_object_mut()
1621 .expect("launch spec is an object")
1622 .insert(
1623 "bootstrap_root".to_string(),
1624 serde_json::to_value(
1625 native_scope
1626 .expect("v4 Setup native scope preflighted")
1627 .bootstrap_root
1628 .as_ref()
1629 .map(|root| {
1630 fs::canonicalize(root)
1631 .expect("validated bootstrap root remains canonicalizable")
1632 }),
1633 )
1634 .expect("bootstrap root serializes"),
1635 );
1636 }
1637 document
1638 }
1639 (None, Some(_)) => {
1640 return Err(DispatchError(
1641 "--native-mcp requires a native Sessions --purpose".to_string(),
1642 ))
1643 }
1644 };
1645 serde_json::to_string_pretty(&document)
1646 .map(|v| format!("{v}\n"))
1647 .map_err(|e| DispatchError(e.to_string()))
1648}
1649
1650fn ownership_document(
1651 marker: &str,
1652 paths: &[String],
1653 root: &Path,
1654) -> Result<String, DispatchError> {
1655 let artifacts = paths
1656 .iter()
1657 .map(|path| {
1658 let bytes = fs::read(root.join(path))
1659 .map_err(|e| DispatchError(format!("read owned artifact {path}: {e}")))?;
1660 Ok(json!({ "path": path, "sha256": format!("{:x}", Sha256::digest(bytes)) }))
1661 })
1662 .collect::<Result<Vec<_>, DispatchError>>()?;
1663 serde_json::to_string_pretty(&json!({ "marker": marker, "artifacts": artifacts }))
1664 .map(|v| format!("{v}\n"))
1665 .map_err(|e| DispatchError(e.to_string()))
1666}
1667
1668fn verify_ownership(
1669 existing: &str,
1670 marker: &str,
1671 paths: &[String],
1672 root: &Path,
1673) -> Result<(), DispatchError> {
1674 let value: serde_json::Value = serde_json::from_str(existing).map_err(|_| {
1675 DispatchError("refusing to overwrite malformed interactive ownership record".to_string())
1676 })?;
1677 if value.get("marker").and_then(serde_json::Value::as_str) != Some(marker) {
1678 return Err(DispatchError("refusing to overwrite artifacts not owned by this exact Warble interactive emission plan".to_string()));
1679 }
1680 let artifacts = value
1681 .get("artifacts")
1682 .and_then(serde_json::Value::as_array)
1683 .ok_or_else(|| {
1684 DispatchError(
1685 "refusing to overwrite ownership record without per-artifact digests".to_string(),
1686 )
1687 })?;
1688 if artifacts.len() != paths.len() {
1689 return Err(DispatchError(
1690 "refusing to overwrite ownership record with a different artifact plan".to_string(),
1691 ));
1692 }
1693 for (artifact, path) in artifacts.iter().zip(paths) {
1694 if artifact.get("path").and_then(serde_json::Value::as_str) != Some(path.as_str()) {
1695 return Err(DispatchError(
1696 "refusing to overwrite ownership record with a different artifact plan".to_string(),
1697 ));
1698 }
1699 let bytes = fs::read(root.join(path)).map_err(|_| {
1700 DispatchError(format!(
1701 "refusing to overwrite missing owned artifact {path}"
1702 ))
1703 })?;
1704 let digest = format!("{:x}", Sha256::digest(bytes));
1705 if artifact.get("sha256").and_then(serde_json::Value::as_str) != Some(digest.as_str()) {
1706 return Err(DispatchError(format!(
1707 "refusing to overwrite modified owned artifact {path}"
1708 )));
1709 }
1710 }
1711 Ok(())
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716 use super::NativeMcpDescriptor;
1717
1718 #[test]
1719 fn native_mcp_descriptor_debug_redacts_the_opaque_credential() {
1720 let descriptor = NativeMcpDescriptor {
1721 version: "1".to_string(),
1722 url: "https://mcp.example.test/native".to_string(),
1723 credential: "sentinel-opaque-native-mcp-credential".to_string(),
1724 };
1725
1726 let rendered = format!("{descriptor:?}");
1727 assert!(rendered.contains("NativeMcpDescriptor"));
1728 assert!(rendered.contains("version: \"1\""));
1729 assert!(rendered.contains("url: \"https://mcp.example.test/native\""));
1730 assert!(rendered.contains("credential: \"[REDACTED]\""));
1731 assert!(!rendered.contains("sentinel-opaque-native-mcp-credential"));
1732 }
1733}