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 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    // Validate the declared verb against all entries before projection can hide ambiguity.
64    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        // Codex enters through the named skill for its purpose, so there is no scope form for
85        // it to start at. Refusing here keeps the rejection next to the vendor whose contract it
86        // belongs to, rather than letting a scope descriptor reach argv assembly and be silently
87        // pinned back to the skill anyway.
88        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
380/// The host persistence contract is derived from the component's declared
381/// render blocks, so this target cannot introduce a Codex-only answer shape.
382pub(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}