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