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