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