// Direct port of https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts
// allow: SIZE_OK — this file intentionally preserves the upstream exec.ts responsibility boundary.
///| Mirrors CodexExecArgs from the upstream implementation.
///| Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L10-L41
///|
/// MoonBit uses Path for filesystem values and task cancellation instead of an AbortSignal field.
priv struct CodexExecArgs {
input : String
base_url : String?
api_key : String?
thread_id : String?
images : Array[@path.Path]
model : String?
sandbox_mode : SandboxMode?
working_directory : @path.Path?
additional_directories : Array[@path.Path]
skip_git_repo_check : Bool?
output_schema_file : @path.Path?
model_reasoning_effort : ModelReasoningEffort?
network_access_enabled : Bool?
web_search_mode : WebSearchMode?
web_search_enabled : Bool?
approval_policy : ApprovalMode?
}
///| Mirrors the upstream internal originator variable.
///|
/// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L43-L45
const INTERNAL_ORIGINATOR_ENV : String = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"
// Type/language difference: the originator identifies this MoonBit port instead of the TypeScript SDK.
// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L43-L45
///|
const MOONBIT_SDK_ORIGINATOR : String = "codex_sdk_mbt"
///| Executes the Codex CLI and streams its JSONL output.
///| Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L63-L67
///| Runtime type difference: the upstream type stores `pathDirs` for directories bundled in the npm platform package; this MoonBit package has no npm optional-package resolver, so that property is intentionally absent.
///|
/// Upstream path type and bundled-directory resolution: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L58-L65 and https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L412-L430
struct CodexExec {
executable_path : @path.Path
env_override : Map[String, String]?
config_overrides : CodexConfigObject?
}
///| Constructs the process adapter used by a Codex client.
///| Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L69-L84
///|
/// Runtime difference: MoonBit packages have no Node optional-package resolution context, so an omitted override resolves `codex` through PATH.
fn CodexExec::CodexExec(
executable_path : @path.Path?,
env : Map[String, String]?,
config_overrides : CodexConfigObject?,
) -> CodexExec {
{
// Runtime default-resolution difference: the upstream implementation resolves its npm platform package and bundled executable, whereas MoonBit resolves `codex` through the process PATH when no override is supplied.
// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L69-L81 and https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L334-L410
executable_path: executable_path.unwrap_or(@path.Path("codex")),
env_override: env,
config_overrides,
}
}
///| Runs the CLI with the same argument ordering, environment behavior, and process lifecycle as the upstream generator.
///| Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L86-L243
///| Async type difference: MoonBit has no AsyncGenerator, so each decoded event is delivered to an async callback returning whether iteration should continue.
///|
/// Cancellation type difference: cancelling the owning MoonBit task replaces the upstream AbortSignal.
async fn CodexExec::run(
self : CodexExec,
args : CodexExecArgs,
on_event : async (ThreadEvent) -> Bool,
) -> Unit {
let command_args = ["exec", "--experimental-json"]
if self.config_overrides is Some(config) {
for item in serialize_config_overrides(config) {
command_args.push("--config")
command_args.push(item)
}
}
if args.base_url is Some(base_url) {
command_args.push("--config")
command_args.push(
"openai_base_url=\{to_toml_value(ConfigString(base_url), "openai_base_url")}",
)
}
if args.model is Some(model) {
command_args.push("--model")
command_args.push(model)
}
if args.sandbox_mode is Some(mode) {
command_args.push("--sandbox")
command_args.push(mode.to_string())
}
if args.working_directory is Some(directory) {
command_args.push("--cd")
command_args.push("\{directory}")
}
for directory in args.additional_directories {
command_args.push("--add-dir")
command_args.push("\{directory}")
}
if args.skip_git_repo_check == Some(true) {
command_args.push("--skip-git-repo-check")
}
if args.output_schema_file is Some(schema_path) {
command_args.push("--output-schema")
command_args.push("\{schema_path}")
}
if args.model_reasoning_effort is Some(effort) {
command_args.push("--config")
command_args.push("model_reasoning_effort=\"\{effort.to_string()}\"")
}
if args.network_access_enabled is Some(enabled) {
command_args.push("--config")
command_args.push(
"sandbox_workspace_write.network_access=\{if enabled { "true" } else { "false" }}",
)
}
match (args.web_search_mode, args.web_search_enabled) {
(Some(mode), _) => {
command_args.push("--config")
command_args.push("web_search=\"\{mode.to_string()}\"")
}
(None, Some(true)) => {
command_args.push("--config")
command_args.push("web_search=\"live\"")
}
(None, Some(false)) => {
command_args.push("--config")
command_args.push("web_search=\"disabled\"")
}
(None, None) => ()
}
if args.approval_policy is Some(policy) {
command_args.push("--config")
command_args.push("approval_policy=\"\{policy.to_string()}\"")
}
if args.thread_id is Some(thread_id) {
command_args.push("resume")
command_args.push(thread_id)
}
for image in args.images {
command_args.push("--image")
command_args.push("\{image}")
}
let environment = match self.env_override {
Some(environment_override) => @copy.Copy::copy(environment_override)
None => Map([])
}
let inherited_originator = if self.env_override is None {
@env.get_env_var(INTERNAL_ORIGINATOR_ENV)
} else {
None
}
if environment.get(INTERNAL_ORIGINATOR_ENV) is None &&
inherited_originator is None {
environment.set(INTERNAL_ORIGINATOR_ENV, MOONBIT_SDK_ORIGINATOR)
}
if args.api_key is Some(value) {
environment.set("CODEX_API_KEY", value)
}
// Runtime PATH difference: the upstream implementation prepends its bundled `pathDirs` here; MoonBit performs no equivalent mutation because it neither resolves nor stores npm-bundled directories.
// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L177-L179
let inherit_environment = self.env_override is None
let result = @agent_cli.run(
@agent_cli.Invocation::new(
command=self.executable_path.to_string(),
arguments=command_args,
environment~,
inherit_environment~,
input=args.input,
),
on_event,
) catch {
@agent_cli.AgentCliError::InvalidJson(line~, cause~) =>
raise InvalidEvent(
message="Failed to parse item: \{line}; cause: \{cause}",
)
error => raise error
}
match result {
@agent_cli.RunResult::Completed | @agent_cli.RunResult::Stopped => ()
@agent_cli.RunResult::Failed(code~, stderr~) =>
raise ExecFailed(code~, stderr~)
}
}
///| Serializes global config overrides before per-thread arguments.
///|
/// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L246-L250
fn serialize_config_overrides(
config : CodexConfigObject,
) -> Array[String] raise CodexSdkError {
let overrides : Array[String] = []
flatten_config_overrides(ConfigObject(config), "", overrides)
overrides
}
///| Flattens nested config objects into dotted CLI override paths.
///|
/// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L252-L290
fn flatten_config_overrides(
value : CodexConfigValue,
prefix : String,
overrides : Array[String],
) -> Unit raise CodexSdkError {
match value {
ConfigObject(object) => {
if prefix == "" && object.is_empty() {
return
}
if prefix != "" && object.is_empty() {
overrides.push("\{prefix}={}")
return
}
for key, child in object {
guard key != "" else {
raise InvalidConfig(
path=prefix,
message="Codex config override keys must be non-empty strings",
)
}
let path = if prefix == "" { key } else { "\{prefix}.\{key}" }
match child {
ConfigObject(_) => flatten_config_overrides(child, path, overrides)
_ => overrides.push("\{path}=\{to_toml_value(child, path)}")
}
}
}
_ =>
if prefix == "" {
raise InvalidConfig(
path="",
message="Codex config overrides must be a plain object",
)
} else {
overrides.push("\{prefix}=\{to_toml_value(value, prefix)}")
}
}
}
///| Converts one config value to the TOML literal accepted by `--config`.
///|
/// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L292-L323
fn to_toml_value(
value : CodexConfigValue,
path : String,
) -> String raise CodexSdkError {
match value {
ConfigString(text) => Json::string(text).stringify()
ConfigInt(number) => number.to_string()
ConfigDouble(number) => {
guard !number.is_nan() && !number.is_inf() else {
raise InvalidConfig(
path~,
message="Codex config override must be a finite number",
)
}
number.to_string()
}
ConfigBool(value) => if value { "true" } else { "false" }
ConfigArray(values) =>
"[\{values.mapi((index, item) => to_toml_value(item, "\{path}[\{index}]")).join(", ")}]"
ConfigObject(object) => {
let parts : Array[String] = []
for key, child in object {
guard key != "" else {
raise InvalidConfig(
path~,
message="Codex config override keys must be non-empty strings",
)
}
parts.push(
"\{format_toml_key(key)} = \{to_toml_value(child, "\{path}.\{key}")}",
)
}
"{\{parts.join(", ")}}"
}
}
}
///| Formats a TOML key exactly as the upstream TOML_BARE_KEY branch.
///|
/// Upstream: https://github.com/openai/codex/blob/f201c30c52a35f819262865a53df94b6f4ea7a50/sdk/typescript/src/exec.ts#L325-L328
fn format_toml_key(key : String) -> String {
let is_bare = key
.iter()
.all(char => {
char.is_ascii_alphabetic() ||
char.is_ascii_digit() ||
char == '-' ||
char == '_'
})
if is_bare {
key
} else {
Json::string(key).stringify()
}
}