///|
/// A PROCESS SHIM: Codex as a workflow engine. Speaks the child contract
/// (`docs/child-contract.md`): one request line on stdin,
/// one result file at `--result-file`. It drives `codex exec --json`
/// underneath and reads that stream for the answer, the usage, and the
/// steps. Point a `LaunchSpec` at the PUBLISHED shim — no build step, no
/// path to hard-code:
///
/// moonx moonbitlang/workflow/shim/codex --result-file {result_file} [--model m] [--cwd d] [--writable] [--schema f] [-- codex args...]
///
/// The runner substitutes a fresh path for `{result_file}`. `moonx` runs the wasm build, which this shim fully
/// supports: it spawns the CLI, keeps the parent's stdin-EOF cancel channel
/// live, and writes the result exactly as the native build does. Building
/// locally (`just shims`) is for developing THIS shim, where the published
/// version is not what you want to run.
///
/// Kind policy: read-only runs (the default) use the `read-only` sandbox;
/// `worker`, or `--writable`, uses `workspace-write`. The request's
/// `limits.max_steps` IS enforced here — each completed non-reasoning item
/// is a step, and the CLI is torn down the moment the ceiling is exceeded —
/// because the CLI has no turn cap of its own. Known limit (verified live
/// on codex-cli 0.151): Codex reports usage only on `turn.completed` and
/// does NOT flush it on SIGINT, so a capped or cancelled run's result
/// carries no usage — the parent records the spend as unaccounted, never
/// as an invented zero.
async fn main {
@shim.serve(default_command="codex", run~)
}
///|
/// One request, start to finish: the result file's content.
async fn run(
options : @shim.Options,
request : @shim.Request,
) -> @shim.ShimResult {
// Codex takes its schema from a FILE: the call's own schema (request) is
// written to a scratch directory for the run; `--schema ` is the
// deployment default for scripts that pass none.
let scratch = match request.schema {
Some(_) => Some(@fs.tmpdir(prefix="workflow-codex-schema-"))
None => None
}
defer (if scratch is Some(dir) {
@fs.rmdir(dir, recursive=true) catch {
_ => ()
}
})
let schema_path = match (request.schema, scratch) {
(Some(schema), Some(dir)) => {
let path = "\{dir}/schema.json"
@fs.write_file(path, schema.stringify())
Some(path)
}
_ => options.schema
}
let state = Codex()
let exit = @shim.run_cli(
command=options.command,
args=codex_args(request, options, schema_path),
cwd?=options.cwd,
observe=line => {
// Count first, then judge the ceiling: the over-limit step DID happen
// (the CLI already made the call), so it is reported with the stop.
state.observe(line)
if request.max_steps is Some(limit) && state.steps > limit {
return @shim.Stop
}
@shim.Continue
},
)
state.result(exit, structured=schema_path is Some(_))
}
///|
/// The CLI argv for one request: the prompt is the last positional. Codex
/// reads stdin as ADDITIONAL input even with a prompt on argv ("Reading
/// additional input from stdin…"), so the EOF `run_cli` puts on the CLI's
/// stdin is what keeps it from blocking.
fn codex_args(
request : @shim.Request,
options : @shim.Options,
schema_path : String?,
) -> Array[String] {
let sandbox = if options.writable || request.kind == "worker" {
"workspace-write"
} else {
"read-only"
}
let args = ["exec", "--json", "--skip-git-repo-check", "-s", sandbox]
if options.model is Some(model) {
args.push("-m")
args.push(model)
}
if schema_path is Some(path) {
args.push("--output-schema")
args.push(path)
}
args.push_iter(options.extra.iter())
args.push(request.prompt)
args
}
///|
test "argv: read-only sandbox by default, workspace-write for worker" {
let request = @shim.Request(
request_id="r",
kind="explore",
input=Json::null(),
prompt="why?",
)
let options = @shim.Options(
command="codex",
result_file="/r",
model="gpt-5",
schema="out.json",
extra=["--ephemeral"],
)
assert_eq(codex_args(request, options, options.schema), [
"exec", "--json", "--skip-git-repo-check", "-s", "read-only", "-m", "gpt-5",
"--output-schema", "out.json", "--ephemeral", "why?",
])
let worker = @shim.Request(
request_id="r",
kind="worker",
input=Json::null(),
prompt="why?",
)
let plain = @shim.Options(command="codex", result_file="/r")
assert_eq(codex_args(worker, plain, None), [
"exec", "--json", "--skip-git-repo-check", "-s", "workspace-write", "why?",
])
}