Skip to main content

warble_claude_code/
codex.rs

1//! Native Codex TUI materialization. This emits discovery artifacts only; it never starts Codex.
2
3use 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
30/// Explicit host-owned composition, requiring a v5-aware consumer.
31pub 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    // Validate the declared verb against all entries before projection can hide ambiguity.
59    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        // Codex enters through the named skill for its purpose, so there is no scope form for
80        // it to start at. Refusing here keeps the rejection next to the vendor whose contract it
81        // belongs to, rather than letting a scope descriptor reach argv assembly and be silently
82        // pinned back to the skill anyway.
83        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
374/// The host persistence contract is derived from the component's declared
375/// render blocks, so this target cannot introduce a Codex-only answer shape.
376fn 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}