1use crate::error::DispatchError;
4use crate::interactive::{
5 native_analysis_prompt_fragment, native_analysis_terminal_presentation_instructions,
6 native_answer_persistence_instructions, native_context_enrichment_prompt_fragment,
7 native_context_enrichment_terminal_presentation_instructions,
8 native_dashboard_save_instructions, setup_bootstrap_authority_instructions,
9 setup_recovery_instructions, NativeMcpDescriptor, NativePurpose, NativeSessionScope,
10};
11use crate::ir::{
12 reject_unsupported_component_composition, validate_ir_version, OutcomeKind, RealizationKind,
13 TriggerKind, WarbleIr,
14};
15use crate::resolve::resolve_capabilities;
16use crate::targets::TargetId;
17use std::fs;
18use std::path::Path;
19
20pub fn emit_codex_interactive(
21 ir: &WarbleIr,
22 out_dir: &Path,
23 purpose: Option<NativePurpose>,
24 native_scope: Option<NativeSessionScope>,
25 native_mcp: Option<NativeMcpDescriptor>,
26) -> Result<(), DispatchError> {
27 emit_codex_interactive_with_host(ir, out_dir, purpose, native_scope, native_mcp, None)
28}
29
30pub fn emit_codex_interactive_with_host(
32 ir: &WarbleIr,
33 out_dir: &Path,
34 purpose: Option<NativePurpose>,
35 native_scope: Option<NativeSessionScope>,
36 native_mcp: Option<NativeMcpDescriptor>,
37 native_host: Option<crate::native_host::NativeHost>,
38) -> Result<(), DispatchError> {
39 validate_ir_version(ir)?;
40 if ir.context_binding.absent {
41 return Err(DispatchError::new(
42 "context-free IR is not supported by codex:interactive",
43 ));
44 }
45 if let Some(host) = &native_host {
46 host.validate(
47 ir,
48 "codex:interactive",
49 purpose,
50 native_scope.as_ref(),
51 native_mcp.is_some(),
52 )?;
53 }
54 let mut entry_ir = WarbleIr {
55 components: ir
56 .components
57 .iter()
58 .filter(|node| node.entrypoint)
59 .cloned()
60 .collect(),
61 ..ir.clone()
62 };
63 if let (Some(purpose), Some(scope)) = (purpose, native_scope.as_ref()) {
65 purpose.validate_profile(&entry_ir, &scope.entry)?;
66 }
67 if let Some(verb) = native_scope
68 .as_ref()
69 .and_then(|scope| scope.entry.pinned_verb())
70 {
71 entry_ir.components.retain(|node| node.id == verb);
72 }
73 let ir = &entry_ir;
74 if native_host.is_none() {
75 reject_unsupported_component_composition(ir, "codex:interactive")?;
76 }
77 if let Some(purpose) = purpose {
78 let scope = native_scope.as_ref().ok_or_else(|| {
79 DispatchError(
80 "native Sessions purpose requires a server-derived native scope descriptor"
81 .to_string(),
82 )
83 })?;
84 if scope.entry.kind == crate::interactive::NativeEntryKind::Scope {
89 return Err(DispatchError(
90 "native Codex sessions enter through their purpose's skill and do not support \
91 scope entry"
92 .to_string(),
93 ));
94 }
95 purpose.validate_profile(ir, &scope.entry)?;
96 }
97 let materializable = ir
98 .components
99 .iter()
100 .filter(|node| {
101 !node
102 .required_capabilities
103 .iter()
104 .any(|capability| capability == "enrichment_apply:deterministic")
105 })
106 .cloned()
107 .collect::<Vec<_>>();
108 if materializable.is_empty() {
109 return Err(DispatchError("apply_enrichment cannot be dispatched on codex:interactive: native materialization has no enforceable human-approval apply channel (wall-hit)".to_string()));
110 }
111 let materialized = WarbleIr {
112 components: materializable,
113 ..ir.clone()
114 };
115 let ir = &materialized;
116 let target = TargetId::CodexInteractive.as_str();
117 let signature = ir
118 .components
119 .iter()
120 .map(|node| node.id.as_str())
121 .collect::<Vec<_>>()
122 .join(",");
123 let skill_name = purpose.map_or("genbi-enrich-context", NativePurpose::codex_skill);
124 let skill_relative = Path::new(".agents/skills")
125 .join(skill_name)
126 .join("SKILL.md");
127 let agents_relative = Path::new("AGENTS.md").to_path_buf();
128 let run_relative = Path::new("RUN.md").to_path_buf();
129 let mut owned_paths = vec![
130 skill_relative.clone(),
131 agents_relative.clone(),
132 run_relative.clone(),
133 ];
134 if purpose.is_some() {
135 owned_paths.push(Path::new(".codex/config.toml").to_path_buf());
136 owned_paths.push(Path::new(".codex/hooks.json").to_path_buf());
137 owned_paths.push(Path::new(".warble/capture-codex-thread.sh").to_path_buf());
138 }
139 if native_host.is_some() {
140 owned_paths.push(Path::new(".warble/component-plans.json").to_path_buf());
141 }
142 for node in &ir.components {
143 if !matches!(node.trigger.kind, TriggerKind::OneShot)
144 || !matches!(
145 node.realization_kind,
146 RealizationKind::Skill | RealizationKind::GatedTool
147 )
148 || !matches!(
149 node.effect.outcome.kind,
150 OutcomeKind::None | OutcomeKind::Mutation
151 )
152 {
153 return Err(DispatchError(format!(
154 "{} cannot materialize component '{}' shape on codex:interactive (wall-hit)",
155 target, node.id
156 )));
157 }
158 if native_host
159 .as_ref()
160 .is_none_or(|host| host.tool(&node.id).is_none())
161 {
162 resolve_capabilities(node, target, &TargetId::CodexInteractive.profile())?;
163 }
164 }
165 let codex_permission_profile = match (purpose, native_scope.as_ref()) {
166 (Some(_), Some(_)) if native_host.is_some() => String::from(
167 "default_permissions = \"warble-hosted\"\n[permissions.warble-hosted]\ndescription = \"Native conversation with fixed host root admission\"\n[permissions.warble-hosted.filesystem]\n\":minimal\" = \"read\"\n[permissions.warble-hosted.filesystem.\":workspace_roots\"]\n\".\" = \"read\"\n[permissions.warble-hosted.network]\nenabled = false\n"),
168 (Some(_), Some(scope)) => scope.codex_permission_profile()?,
169 _ => String::new(),
170 };
171 let output = crate::interactive::prepare_interactive_output_with_host(
172 out_dir,
173 target,
174 "codex",
175 &signature,
176 &ir.profile,
177 &owned_paths,
178 purpose,
179 native_scope.clone(),
180 native_mcp.clone(),
181 native_host.clone(),
182 None,
183 )?;
184
185 let include_setup_recovery_instructions =
186 purpose == Some(NativePurpose::Setup) && native_mcp.is_some();
187 let include_persist_answer_instructions = purpose == Some(NativePurpose::Analysis)
188 && native_mcp.is_some()
189 && ir.components.iter().any(is_persisted_answer_component);
190 let skill = if let Some(host) = &native_host {
191 let node = &ir.components[0];
192 format!(
193 "---\nname: {}\ndescription: {}\n---\n\n{}\n\n{}\n",
194 skill_name,
195 serde_json::to_string(node.description.as_deref().unwrap_or(&node.verb))
196 .expect("description"),
197 output.marker(),
198 host.instructions(node)
199 )
200 } else {
201 build_skill(
202 ir,
203 output.marker(),
204 purpose,
205 include_setup_recovery_instructions,
206 include_persist_answer_instructions,
207 native_mcp.is_some(),
208 )
209 };
210 let agents = build_agents(output.marker(), purpose);
211 let run = build_run(output.marker(), skill_name, purpose);
212 let skill_path = output.root.join(skill_relative);
213 fs::create_dir_all(skill_path.parent().expect("skill parent"))
214 .map_err(|e| DispatchError(format!("create Codex skill dir: {e}")))?;
215 fs::write(skill_path, skill).map_err(|e| DispatchError(format!("write Codex skill: {e}")))?;
216 fs::write(output.root.join(agents_relative), agents)
217 .map_err(|e| DispatchError(format!("write AGENTS.md: {e}")))?;
218 fs::write(output.root.join(run_relative), run)
219 .map_err(|e| DispatchError(format!("write RUN.md: {e}")))?;
220 if purpose.is_some() {
221 let codex_dir = output.root.join(".codex");
222 fs::create_dir_all(&codex_dir)
223 .map_err(|e| DispatchError(format!("create Codex discovery dir: {e}")))?;
224 let mcp_discovery = native_mcp.map_or_else(String::new, |descriptor| {
225 if let Some(host) = &native_host {
226 descriptor.codex_host_discovery_config(&host.tool_names())
227 } else {
228 descriptor.codex_discovery_config(
229 include_setup_recovery_instructions,
230 purpose == Some(NativePurpose::Analysis)
231 && ir.components.iter().any(|node| {
232 is_dashboard_component(node) || is_persisted_answer_component(node)
233 }),
234 include_persist_answer_instructions,
235 )
236 }
237 });
238 fs::write(
239 codex_dir.join("config.toml"),
240 format!("{codex_permission_profile}\n{mcp_discovery}"),
241 )
242 .map_err(|e| DispatchError(format!("write Codex native session config: {e}")))?;
243 fs::write(codex_dir.join("hooks.json"), codex_resume_capture_hooks())
244 .map_err(|e| DispatchError(format!("write Codex native session hooks: {e}")))?;
245 let capture = output.root.join(".warble/capture-codex-thread.sh");
246 fs::create_dir_all(capture.parent().expect("capture parent"))
247 .map_err(|e| DispatchError(format!("create Codex native resume capture dir: {e}")))?;
248 fs::write(&capture, CODEX_RESUME_CAPTURE_SCRIPT)
249 .map_err(|e| DispatchError(format!("write Codex native resume capture: {e}")))?;
250 #[cfg(unix)]
251 {
252 use std::os::unix::fs::PermissionsExt;
253 fs::set_permissions(&capture, fs::Permissions::from_mode(0o700))
254 .map_err(|e| DispatchError(format!("secure Codex native resume capture: {e}")))?;
255 }
256 }
257 output.write_ownership()?;
258 output.write_launch_spec()
259}
260
261const CODEX_RESUME_CAPTURE_SCRIPT: &str = r#"#!/bin/sh
262set -eu
263payload=$(cat)
264thread_id=$(printf '%s\n' "$payload" | sed -n 's/^[[:space:]]*{[[:space:]]*"session_id"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F-]*\)".*/\1/p')
265case "$thread_id" in
266 ????????-????-[12345678]???-[89abAB]???-????????????)
267 umask 077
268 printf '%s\n' "$thread_id" > .warble/codex-thread-id
269 ;;
270 *) exit 1 ;;
271esac
272"#;
273
274fn codex_resume_capture_hooks() -> String {
275 serde_json::to_string_pretty(&serde_json::json!({
276 "description": "Capture only this isolated native Codex thread identifier for host-owned resume.",
277 "hooks": {
278 "UserPromptSubmit": [{
279 "hooks": [{
280 "type": "command",
281 "command": "/bin/sh .warble/capture-codex-thread.sh",
282 "timeout": 3,
283 "statusMessage": "Securing native session resume"
284 }]
285 }]
286 }
287 })).expect("Codex native hooks serialize")
288}
289
290fn build_skill(
291 ir: &WarbleIr,
292 marker: &str,
293 purpose: Option<NativePurpose>,
294 include_setup_recovery_instructions: bool,
295 include_persist_answer_instructions: bool,
296 has_native_mcp: bool,
297) -> String {
298 let purpose = purpose.unwrap_or(NativePurpose::ContextEnrichment);
299 let sections = ir
300 .components
301 .iter()
302 .filter(|node| node.realization_kind == RealizationKind::Skill)
303 .map(|node| {
304 let fragment = match purpose {
305 NativePurpose::Analysis => native_analysis_prompt_fragment(&node.prompt_fragment),
306 NativePurpose::ContextEnrichment => {
307 native_context_enrichment_prompt_fragment(&node.prompt_fragment)
308 }
309 NativePurpose::Setup => node.prompt_fragment.clone(),
310 };
311 match &node.brief {
312 Some(brief) => format!("{brief}\n\n{fragment}"),
313 None => fragment,
314 }
315 })
316 .collect::<Vec<_>>()
317 .join("\n\n");
318 let scope = match purpose {
319 NativePurpose::Setup => "Operate only within the server-created bootstrap scope. Do not adopt, discover, or switch to an existing project.",
320 NativePurpose::Analysis | NativePurpose::ContextEnrichment => "Operate only within the server-bound project scope. Do not change cwd or follow a caller-supplied project path.",
321 };
322 let safety = match purpose {
323 NativePurpose::ContextEnrichment => "Do not write files, invoke a headless runner, start an app server, or use `codex exec`. Do not invoke or simulate `apply_enrichment`. An apply remains a native interactive human-approval workflow with dry-run, scoped authorization, validation/build, and rollback checks.",
324 NativePurpose::Analysis => "Do not read credentials or expose raw material. Do not invoke a headless runner, start an app server, or use `codex exec`.",
325 NativePurpose::Setup => "Do not read credentials into output, start an app server, or use `codex exec`. The host owns all session lifecycle and any subsequent project binding.",
326 };
327 let mut skill = format!(
328 "---\nname: {}\ndescription: {}\n---\n\n{}\n\n# GenBI {}\n\n{}\n\n{}\n\n{}\n",
329 purpose.codex_skill(),
330 purpose.codex_description(),
331 marker,
332 purpose.as_str(),
333 scope,
334 sections,
335 safety
336 );
337 if include_setup_recovery_instructions {
338 skill.push('\n');
339 skill.push_str(setup_recovery_instructions());
340 }
341 if purpose == NativePurpose::Setup {
342 skill.push('\n');
343 skill.push_str(&setup_bootstrap_authority_instructions());
344 }
345 if include_persist_answer_instructions {
346 skill.push('\n');
347 skill.push_str(native_answer_persistence_instructions());
348 }
349 if purpose == NativePurpose::Analysis {
350 skill.push('\n');
351 skill.push_str(native_analysis_terminal_presentation_instructions());
352 }
353 if purpose == NativePurpose::ContextEnrichment {
354 skill.push('\n');
355 skill.push_str(native_context_enrichment_terminal_presentation_instructions());
356 }
357 if purpose == NativePurpose::Analysis
358 && has_native_mcp
359 && ir
360 .components
361 .iter()
362 .any(|node| is_dashboard_component(node) || is_persisted_answer_component(node))
363 {
364 skill.push('\n');
365 skill.push_str(native_dashboard_save_instructions());
366 }
367 skill
368}
369
370pub(crate) fn is_dashboard_component(node: &crate::ir::ComponentNode) -> bool {
371 node.required_capabilities
372 .iter()
373 .any(|capability| capability == "genbi_build")
374 && node
375 .required_capabilities
376 .iter()
377 .any(|capability| capability == "artifact_write")
378}
379
380pub(crate) fn is_persisted_answer_component(node: &crate::ir::ComponentNode) -> bool {
383 let blocks = &node.effect.render_blocks;
384 let table = blocks
385 .iter()
386 .filter(|block| block.block_type == "table")
387 .count();
388 let definition = blocks
389 .iter()
390 .filter(|block| block.block_type == "definition")
391 .count();
392 table == 1
393 && definition <= 1
394 && blocks.len() == table + definition
395 && blocks
396 .iter()
397 .find(|block| block.block_type == "table")
398 .is_some_and(|block| {
399 block
400 .fields
401 .get("columns")
402 .is_some_and(|field| field == "string[]")
403 && block
404 .fields
405 .get("rows")
406 .is_some_and(|field| field == "row[]")
407 })
408 && blocks
409 .iter()
410 .filter(|block| block.block_type == "definition")
411 .all(|block| {
412 block
413 .fields
414 .get("sql")
415 .is_some_and(|field| field == "string")
416 && block
417 .fields
418 .get("source_tables")
419 .is_some_and(|field| field == "string[]")
420 && block
421 .fields
422 .get("filters")
423 .is_some_and(|field| field == "string[]")
424 })
425}
426
427fn build_agents(marker: &str, purpose: Option<NativePurpose>) -> String {
428 let purpose = purpose.unwrap_or(NativePurpose::ContextEnrichment);
429 format!("{}\n# Warble native {}\n\nUse the `${}` skill for this server-selected purpose. The caller owns the PTY, process, prompt injection, transcript, and session lifecycle.\n", marker, purpose.as_str(), purpose.codex_skill())
430}
431
432fn build_run(marker: &str, skill_name: &str, purpose: Option<NativePurpose>) -> String {
433 let purpose = purpose.unwrap_or(NativePurpose::ContextEnrichment);
434 format!("{}\n# Native Codex {} session\n\nRead `.warble/interactive-launch.json`, then start the native `codex` TUI in its canonical cwd. Codex discovers `AGENTS.md` and `.agents/skills/{}/SKILL.md` from that repository scope. The caller owns the PTY, prompt injection, process, transcript, and session lifecycle.\n", marker, purpose.as_str(), skill_name)
435}