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