mirror of
https://github.com/openai/codex.git
synced 2026-03-02 20:53:19 +00:00
Compare commits
5 Commits
fix/notify
...
fjord/js_r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e89d3a8213 | ||
|
|
2f83b5dbd5 | ||
|
|
1d005f8cc6 | ||
|
|
b261121254 | ||
|
|
b85b404622 |
@@ -16,7 +16,7 @@ RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" /
|
||||
CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript"
|
||||
|
||||
PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = {
|
||||
"codex": ["codex", "rg"],
|
||||
"codex": ["codex", "rg", "node"],
|
||||
"codex-responses-api-proxy": ["codex-responses-api-proxy"],
|
||||
"codex-sdk": ["codex"],
|
||||
}
|
||||
@@ -29,6 +29,7 @@ COMPONENT_DEST_DIR: dict[str, str] = {
|
||||
"codex-windows-sandbox-setup": "codex",
|
||||
"codex-command-runner": "codex",
|
||||
"rg": "path",
|
||||
"node": "node",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ CODEX_CLI_ROOT = SCRIPT_DIR.parent
|
||||
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0
|
||||
VENDOR_DIR_NAME = "vendor"
|
||||
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
|
||||
NODE_VERSION_FILE = CODEX_CLI_ROOT.parent / "codex-rs" / "node-version.txt"
|
||||
BINARY_TARGETS = (
|
||||
"x86_64-unknown-linux-musl",
|
||||
"aarch64-unknown-linux-musl",
|
||||
@@ -79,6 +80,41 @@ RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
|
||||
RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS}
|
||||
DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS]
|
||||
|
||||
# Node distribution asset mapping: target -> (archive_format, filename, member_path)
|
||||
NODE_ASSETS: dict[str, tuple[str, str, str]] = {
|
||||
"x86_64-apple-darwin": (
|
||||
"tar.gz",
|
||||
"node-v{ver}-darwin-x64.tar.gz",
|
||||
"node-v{ver}-darwin-x64/bin/node",
|
||||
),
|
||||
"aarch64-apple-darwin": (
|
||||
"tar.gz",
|
||||
"node-v{ver}-darwin-arm64.tar.gz",
|
||||
"node-v{ver}-darwin-arm64/bin/node",
|
||||
),
|
||||
"x86_64-unknown-linux-musl": (
|
||||
"tar.xz",
|
||||
"node-v{ver}-linux-x64.tar.xz",
|
||||
"node-v{ver}-linux-x64/bin/node",
|
||||
),
|
||||
"aarch64-unknown-linux-musl": (
|
||||
"tar.xz",
|
||||
"node-v{ver}-linux-arm64.tar.xz",
|
||||
"node-v{ver}-linux-arm64/bin/node",
|
||||
),
|
||||
"x86_64-pc-windows-msvc": (
|
||||
"zip",
|
||||
"node-v{ver}-win-x64.zip",
|
||||
"node-v{ver}-win-x64/node.exe",
|
||||
),
|
||||
"aarch64-pc-windows-msvc": (
|
||||
"zip",
|
||||
"node-v{ver}-win-arm64.zip",
|
||||
"node-v{ver}-win-arm64/node.exe",
|
||||
),
|
||||
}
|
||||
DEFAULT_NODE_TARGETS = tuple(NODE_ASSETS.keys())
|
||||
|
||||
# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI.
|
||||
DOWNLOAD_TIMEOUT_SECS = 60
|
||||
|
||||
@@ -132,11 +168,11 @@ def parse_args() -> argparse.Namespace:
|
||||
"--component",
|
||||
dest="components",
|
||||
action="append",
|
||||
choices=tuple(list(BINARY_COMPONENTS) + ["rg"]),
|
||||
choices=tuple(list(BINARY_COMPONENTS) + ["rg", "node"]),
|
||||
help=(
|
||||
"Limit installation to the specified components."
|
||||
" May be repeated. Defaults to codex, codex-windows-sandbox-setup,"
|
||||
" codex-command-runner, and rg."
|
||||
" codex-command-runner, node, and rg."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -163,6 +199,7 @@ def main() -> int:
|
||||
"codex-windows-sandbox-setup",
|
||||
"codex-command-runner",
|
||||
"rg",
|
||||
"node",
|
||||
]
|
||||
|
||||
workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip()
|
||||
@@ -187,6 +224,11 @@ def main() -> int:
|
||||
print("Fetching ripgrep binaries...")
|
||||
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
|
||||
|
||||
if "node" in components:
|
||||
with _gha_group("Fetch Node runtime"):
|
||||
print("Fetching Node runtime...")
|
||||
fetch_node(vendor_dir, load_node_version(), DEFAULT_NODE_TARGETS)
|
||||
|
||||
print(f"Installed native dependencies into {vendor_dir}")
|
||||
return 0
|
||||
|
||||
@@ -259,6 +301,41 @@ def fetch_rg(
|
||||
return [results[target] for target in targets]
|
||||
|
||||
|
||||
def fetch_node(vendor_dir: Path, version: str, targets: Sequence[str]) -> None:
|
||||
version = version.strip().lstrip("v")
|
||||
vendor_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for target in targets:
|
||||
asset = NODE_ASSETS.get(target)
|
||||
if asset is None:
|
||||
raise RuntimeError(f"unsupported Node target {target}")
|
||||
archive_format, filename_tmpl, member_tmpl = asset
|
||||
filename = filename_tmpl.format(ver=version)
|
||||
member = member_tmpl.format(ver=version)
|
||||
url = f"https://nodejs.org/dist/v{version}/{filename}"
|
||||
dest_dir = vendor_dir / target / "node"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
binary_name = "node.exe" if "windows" in target else "node"
|
||||
dest = dest_dir / binary_name
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp_dir_str:
|
||||
tmp_dir = Path(tmp_dir_str)
|
||||
download_path = tmp_dir / filename
|
||||
print(f" downloading node {version} for {target} from {url}", flush=True)
|
||||
_download_file(url, download_path)
|
||||
dest.unlink(missing_ok=True)
|
||||
extract_archive(download_path, archive_format, member, dest)
|
||||
if "windows" not in target:
|
||||
dest.chmod(0o755)
|
||||
|
||||
|
||||
def load_node_version() -> str:
|
||||
try:
|
||||
return NODE_VERSION_FILE.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"failed to read node version from {NODE_VERSION_FILE}: {exc}") from exc
|
||||
|
||||
|
||||
def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
|
||||
cmd = [
|
||||
"gh",
|
||||
@@ -437,6 +514,21 @@ def extract_archive(
|
||||
shutil.move(str(extracted), dest)
|
||||
return
|
||||
|
||||
if archive_format == "tar.xz":
|
||||
if not archive_member:
|
||||
raise RuntimeError("Missing 'path' for tar.xz archive in archive.")
|
||||
with tarfile.open(archive_path, "r:xz") as tar:
|
||||
try:
|
||||
member = tar.getmember(archive_member)
|
||||
except KeyError as exc:
|
||||
raise RuntimeError(
|
||||
f"Entry '{archive_member}' not found in archive {archive_path}."
|
||||
) from exc
|
||||
tar.extract(member, path=archive_path.parent, filter="data")
|
||||
extracted = archive_path.parent / archive_member
|
||||
shutil.move(str(extracted), dest)
|
||||
return
|
||||
|
||||
if archive_format == "zip":
|
||||
if not archive_member:
|
||||
raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.")
|
||||
|
||||
@@ -190,6 +190,15 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_polling": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_tools_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -250,6 +259,9 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_node_path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1208,6 +1220,15 @@
|
||||
"include_apply_patch_tool": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_polling": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"js_repl_tools_only": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"personality": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1321,6 +1342,14 @@
|
||||
"description": "System instructions.",
|
||||
"type": "string"
|
||||
},
|
||||
"js_repl_node_path": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
}
|
||||
],
|
||||
"description": "Optional absolute path to the Node runtime used by `js_repl`."
|
||||
},
|
||||
"mcp_oauth_callback_port": {
|
||||
"description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.",
|
||||
"format": "uint16",
|
||||
|
||||
@@ -4,6 +4,25 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
|
||||
|
||||
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
|
||||
|
||||
<!-- js_repl:start -->
|
||||
## JavaScript REPL (Node)
|
||||
- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset` / `reset=true`.
|
||||
- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
|
||||
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
|
||||
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
|
||||
- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel.
|
||||
<!-- js_repl_polling:start -->
|
||||
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
|
||||
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
|
||||
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
|
||||
<!-- js_repl_polling:end -->
|
||||
<!-- js_repl_tools_only:start -->
|
||||
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
|
||||
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
|
||||
<!-- js_repl_tools_only:end -->
|
||||
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
|
||||
<!-- js_repl:end -->
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
|
||||
@@ -4,6 +4,25 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
|
||||
|
||||
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
|
||||
|
||||
<!-- js_repl:start -->
|
||||
## JavaScript REPL (Node)
|
||||
- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset` / `reset=true`.
|
||||
- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
|
||||
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
|
||||
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
|
||||
- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel.
|
||||
<!-- js_repl_polling:start -->
|
||||
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
|
||||
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
|
||||
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
|
||||
<!-- js_repl_polling:end -->
|
||||
<!-- js_repl_tools_only:start -->
|
||||
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
|
||||
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
|
||||
<!-- js_repl_tools_only:end -->
|
||||
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
|
||||
<!-- js_repl:end -->
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
|
||||
@@ -8,6 +8,25 @@ Your capabilities:
|
||||
|
||||
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
|
||||
|
||||
<!-- js_repl:start -->
|
||||
## JavaScript REPL (Node)
|
||||
- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset` / `reset=true`.
|
||||
- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
|
||||
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
|
||||
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
|
||||
- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel.
|
||||
<!-- js_repl_polling:start -->
|
||||
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
|
||||
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
|
||||
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
|
||||
<!-- js_repl_polling:end -->
|
||||
<!-- js_repl_tools_only:start -->
|
||||
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
|
||||
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
|
||||
<!-- js_repl_tools_only:end -->
|
||||
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
|
||||
<!-- js_repl:end -->
|
||||
|
||||
# How you work
|
||||
|
||||
## Personality
|
||||
|
||||
@@ -8,6 +8,25 @@ Your capabilities:
|
||||
|
||||
Within this context, Codex refers to the open-source agentic coding interface (not the old Codex language model built by OpenAI).
|
||||
|
||||
<!-- js_repl:start -->
|
||||
## JavaScript REPL (Node)
|
||||
- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel. `codex.state` persists for the session (best effort) and is cleared by `js_repl_reset` / `reset=true`.
|
||||
- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000 reset=true`). Do not wrap code in JSON (for example `{"code":"..."}`), quotes, or markdown code fences.
|
||||
- Helpers: `codex.state`, `codex.tmpDir`, `codex.sh(command, opts?)`, `codex.tool(name, args?)`, and `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`.
|
||||
- `codex.sh` requires a string command and resolves to `{ stdout, stderr, exitCode }`; `codex.tool` executes a normal tool call and resolves to the raw tool output object.
|
||||
- Top-level bindings persist across cells. If you hit `SyntaxError: Identifier 'x' has already been declared`, reuse the binding, pick a new name, wrap in `{ ... }` for block scope, or reset the kernel.
|
||||
<!-- js_repl_polling:start -->
|
||||
- Polling mode is two-step: (1) call `js_repl` with first-line pragma `// codex-js-repl: poll=true` to get an `exec_id`; (2) call `js_repl_poll` with that `exec_id` until `status` is `completed` or `error`.
|
||||
- `js_repl_poll` must not be called before a successful `js_repl` submission returns an `exec_id`.
|
||||
- If `js_repl` rejects your payload format, resend raw JS with the pragma; do not retry with JSON, quoted strings, or markdown fences.
|
||||
<!-- js_repl_polling:end -->
|
||||
<!-- js_repl_tools_only:start -->
|
||||
- Do not call tools directly; use `js_repl` + `codex.tool(...)`, and use `codex.sh(...)` for shell commands instead of direct shell tool calls.
|
||||
- Tools available via `codex.tool(...)`: {{JS_REPL_TOOL_LIST}}. MCP tools (if any) can also be called by name.
|
||||
<!-- js_repl_tools_only:end -->
|
||||
- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.sh`, and `codex.emitImage`.
|
||||
<!-- js_repl:end -->
|
||||
|
||||
# How you work
|
||||
|
||||
## Personality
|
||||
|
||||
@@ -101,6 +101,7 @@ use crate::client::ModelClient;
|
||||
use crate::client::ModelClientSession;
|
||||
use crate::client_common::Prompt;
|
||||
use crate::client_common::ResponseEvent;
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::codex_thread::ThreadConfigSnapshot;
|
||||
use crate::compact::collect_user_messages;
|
||||
use crate::config::Config;
|
||||
@@ -193,6 +194,7 @@ use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::js_repl::JsReplHandle;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
@@ -477,6 +479,7 @@ pub(crate) struct Session {
|
||||
pending_mcp_server_refresh_config: Mutex<Option<McpServerRefreshConfig>>,
|
||||
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
|
||||
pub(crate) services: SessionServices,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
next_internal_sub_id: AtomicU64,
|
||||
}
|
||||
|
||||
@@ -513,6 +516,7 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
|
||||
pub(crate) truncation_policy: TruncationPolicy,
|
||||
pub(crate) js_repl: Arc<JsReplHandle>,
|
||||
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
|
||||
turn_metadata_header: OnceCell<Option<String>>,
|
||||
}
|
||||
@@ -710,6 +714,7 @@ impl Session {
|
||||
conversation_id: ThreadId,
|
||||
sub_id: String,
|
||||
transport_manager: TransportManager,
|
||||
js_repl: Arc<JsReplHandle>,
|
||||
) -> TurnContext {
|
||||
let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort();
|
||||
let reasoning_summary = session_configuration.model_reasoning_summary;
|
||||
@@ -771,6 +776,7 @@ impl Session {
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
js_repl,
|
||||
dynamic_tools: session_configuration.dynamic_tools.clone(),
|
||||
turn_metadata_header: OnceCell::new(),
|
||||
}
|
||||
@@ -991,6 +997,10 @@ impl Session {
|
||||
state_db: state_db_ctx.clone(),
|
||||
transport_manager: TransportManager::new(),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let sess = Arc::new(Session {
|
||||
conversation_id,
|
||||
@@ -1001,6 +1011,7 @@ impl Session {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
@@ -1316,6 +1327,7 @@ impl Session {
|
||||
self.conversation_id,
|
||||
sub_id,
|
||||
self.services.transport_manager.clone(),
|
||||
Arc::clone(&self.js_repl),
|
||||
);
|
||||
|
||||
if let Some(final_schema) = final_output_json_schema {
|
||||
@@ -3357,6 +3369,7 @@ async fn spawn_review_thread(
|
||||
final_output_json_schema: None,
|
||||
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
|
||||
tool_call_gate: Arc::new(ReadinessFlag::new()),
|
||||
js_repl: Arc::clone(&sess.js_repl),
|
||||
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
|
||||
truncation_policy: model_info.truncation_policy.into(),
|
||||
turn_metadata_header: parent_turn_context.turn_metadata_header.clone(),
|
||||
@@ -3783,6 +3796,21 @@ fn codex_apps_connector_id(tool: &crate::mcp_connection_manager::ToolInfo) -> Op
|
||||
tool.connector_id.as_deref()
|
||||
}
|
||||
|
||||
fn js_repl_tool_list_for_prompt(tools: &[ToolSpec]) -> String {
|
||||
let mut names: Vec<String> = tools.iter().map(|tool| tool.name().to_string()).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names.retain(|name| !matches!(name.as_str(), "js_repl" | "js_repl_reset"));
|
||||
if names.is_empty() {
|
||||
return "none".to_string();
|
||||
}
|
||||
names
|
||||
.into_iter()
|
||||
.map(|name| format!("`{name}`"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
struct SamplingRequestToolSelection<'a> {
|
||||
explicit_app_paths: &'a [String],
|
||||
skill_name_counts_lower: &'a HashMap<String, usize>,
|
||||
@@ -3840,11 +3868,20 @@ async fn run_sampling_request(
|
||||
|
||||
let model_supports_parallel = turn_context.model_info.supports_parallel_tool_calls;
|
||||
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let all_tools = router.specs();
|
||||
let tools =
|
||||
crate::tools::spec::filter_tools_for_model(all_tools.clone(), &turn_context.tools_config);
|
||||
let mut base_instructions = sess.get_base_instructions().await;
|
||||
if turn_context.tools_config.js_repl_tools_only {
|
||||
let tool_list = js_repl_tool_list_for_prompt(&all_tools);
|
||||
base_instructions.text = base_instructions
|
||||
.text
|
||||
.replace("{{JS_REPL_TOOL_LIST}}", &tool_list);
|
||||
}
|
||||
|
||||
let prompt = Prompt {
|
||||
input,
|
||||
tools: router.specs(),
|
||||
tools,
|
||||
parallel_tool_calls: model_supports_parallel,
|
||||
base_instructions,
|
||||
personality: turn_context.personality,
|
||||
@@ -4648,7 +4685,11 @@ mod tests {
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::test_config;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::models_manager::model_info::apply_js_repl_polling_section;
|
||||
use crate::models_manager::model_info::apply_js_repl_section;
|
||||
use crate::models_manager::model_info::apply_js_repl_tools_only_section;
|
||||
use crate::shell::default_user_shell;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
@@ -4765,9 +4806,17 @@ mod tests {
|
||||
];
|
||||
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let config = test_config();
|
||||
let js_repl_enabled = config.features.enabled(Feature::JsRepl);
|
||||
let prompt_with_apply_patch_instructions = apply_js_repl_tools_only_section(
|
||||
&apply_js_repl_polling_section(
|
||||
&apply_js_repl_section(prompt_with_apply_patch_instructions, js_repl_enabled),
|
||||
js_repl_enabled && config.features.enabled(Feature::JsReplPolling),
|
||||
),
|
||||
js_repl_enabled && config.features.enabled(Feature::JsReplToolsOnly),
|
||||
);
|
||||
|
||||
for test_case in test_cases {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline(test_case.slug, &config);
|
||||
if test_case.expects_apply_patch_instructions {
|
||||
assert_eq!(
|
||||
@@ -5534,6 +5583,10 @@ mod tests {
|
||||
state_db: None,
|
||||
transport_manager: TransportManager::new(),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let turn_context = Session::make_turn_context(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -5545,6 +5598,7 @@ mod tests {
|
||||
conversation_id,
|
||||
"turn_id".to_string(),
|
||||
services.transport_manager.clone(),
|
||||
Arc::clone(&js_repl),
|
||||
);
|
||||
|
||||
let session = Session {
|
||||
@@ -5556,6 +5610,7 @@ mod tests {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
};
|
||||
|
||||
@@ -5654,6 +5709,10 @@ mod tests {
|
||||
state_db: None,
|
||||
transport_manager: TransportManager::new(),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
config.codex_home.clone(),
|
||||
));
|
||||
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -5665,6 +5724,7 @@ mod tests {
|
||||
conversation_id,
|
||||
"turn_id".to_string(),
|
||||
services.transport_manager.clone(),
|
||||
Arc::clone(&js_repl),
|
||||
));
|
||||
|
||||
let session = Arc::new(Session {
|
||||
@@ -5676,6 +5736,7 @@ mod tests {
|
||||
pending_mcp_server_refresh_config: Mutex::new(None),
|
||||
active_turn: Mutex::new(None),
|
||||
services,
|
||||
js_repl,
|
||||
next_internal_sub_id: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
@@ -5968,6 +6029,7 @@ mod tests {
|
||||
Arc::clone(&turn_context),
|
||||
tracker,
|
||||
call,
|
||||
crate::tools::router::ToolCallSource::Model,
|
||||
)
|
||||
.await
|
||||
.expect_err("expected fatal error");
|
||||
|
||||
@@ -289,6 +289,9 @@ pub struct Config {
|
||||
/// When this program is invoked, arg0 will be set to `codex-linux-sandbox`.
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
|
||||
/// Value to use for `reasoning.effort` when making a request using the
|
||||
/// Responses API.
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
@@ -885,6 +888,9 @@ pub struct ConfigToml {
|
||||
/// Token budget applied when storing tool/function outputs in the context manager.
|
||||
pub tool_output_token_limit: Option<usize>,
|
||||
|
||||
/// Optional absolute path to the Node runtime used by `js_repl`.
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Profile to use from the `profiles` map.
|
||||
pub profile: Option<String>,
|
||||
|
||||
@@ -1218,6 +1224,7 @@ pub struct ConfigOverrides {
|
||||
pub model_provider: Option<String>,
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub js_repl_node_path: Option<PathBuf>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub developer_instructions: Option<String>,
|
||||
pub personality: Option<Personality>,
|
||||
@@ -1325,6 +1332,7 @@ impl Config {
|
||||
model_provider,
|
||||
config_profile: config_profile_key,
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path: js_repl_node_path_override,
|
||||
base_instructions,
|
||||
developer_instructions,
|
||||
personality,
|
||||
@@ -1555,6 +1563,9 @@ impl Config {
|
||||
"experimental compact prompt file",
|
||||
)?;
|
||||
let compact_prompt = compact_prompt.or(file_compact_prompt);
|
||||
let js_repl_node_path = js_repl_node_path_override
|
||||
.or(config_profile.js_repl_node_path.map(Into::into))
|
||||
.or(cfg.js_repl_node_path.map(Into::into));
|
||||
|
||||
let review_model = override_review_model.or(cfg.review_model);
|
||||
|
||||
@@ -1631,6 +1642,7 @@ impl Config {
|
||||
ephemeral: ephemeral.unwrap_or_default(),
|
||||
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path,
|
||||
|
||||
hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false),
|
||||
show_raw_agent_reasoning: cfg
|
||||
@@ -3847,6 +3859,7 @@ model_verbosity = "high"
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
@@ -3932,6 +3945,7 @@ model_verbosity = "high"
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
@@ -4032,6 +4046,7 @@ model_verbosity = "high"
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
@@ -4118,6 +4133,7 @@ model_verbosity = "high"
|
||||
ephemeral: false,
|
||||
file_opener: UriBasedFileOpener::VsCode,
|
||||
codex_linux_sandbox_exe: None,
|
||||
js_repl_node_path: None,
|
||||
hide_agent_reasoning: false,
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct ConfigProfile {
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
/// Optional path to a file containing model instructions.
|
||||
pub model_instructions_file: Option<AbsolutePathBuf>,
|
||||
pub js_repl_node_path: Option<AbsolutePathBuf>,
|
||||
/// Deprecated: ignored. Use `model_instructions_file`.
|
||||
#[schemars(skip)]
|
||||
pub experimental_instructions_file: Option<AbsolutePathBuf>,
|
||||
|
||||
@@ -78,6 +78,12 @@ pub enum Feature {
|
||||
ShellTool,
|
||||
|
||||
// Experimental
|
||||
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
|
||||
JsRepl,
|
||||
/// Enable js_repl polling helpers and tool.
|
||||
JsReplPolling,
|
||||
/// Only expose js_repl tools directly to the model.
|
||||
JsReplToolsOnly,
|
||||
/// Use the single unified PTY-backed exec tool.
|
||||
UnifiedExec,
|
||||
/// Include the freeform apply_patch tool.
|
||||
@@ -316,6 +322,14 @@ impl Features {
|
||||
}
|
||||
|
||||
overrides.apply(&mut features);
|
||||
if features.enabled(Feature::JsReplToolsOnly) && !features.enabled(Feature::JsRepl) {
|
||||
tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only");
|
||||
features.disable(Feature::JsReplToolsOnly);
|
||||
}
|
||||
if features.enabled(Feature::JsReplPolling) && !features.enabled(Feature::JsRepl) {
|
||||
tracing::warn!("js_repl_polling requires js_repl; disabling js_repl_polling");
|
||||
features.disable(Feature::JsReplPolling);
|
||||
}
|
||||
|
||||
features
|
||||
}
|
||||
@@ -412,6 +426,36 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::Stable,
|
||||
default_enabled: !cfg!(windows),
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsRepl,
|
||||
key: "js_repl",
|
||||
stage: Stage::Experimental {
|
||||
name: "JavaScript REPL",
|
||||
menu_description: "Run JavaScript cells with a persistent Node-backed kernel.",
|
||||
announcement: "NEW! Try the JavaScript REPL for persistent JS execution. Enable in /experimental!",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsReplPolling,
|
||||
key: "js_repl_polling",
|
||||
stage: Stage::Experimental {
|
||||
name: "JS REPL polling",
|
||||
menu_description: "Allow js_repl to return an exec_id and poll for completion.",
|
||||
announcement: "NEW! Try polling mode for JavaScript REPL long-running cells.",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::JsReplToolsOnly,
|
||||
key: "js_repl_tools_only",
|
||||
stage: Stage::Experimental {
|
||||
name: "JS REPL tool-only",
|
||||
menu_description: "Require direct model tool calls to go through js_repl.",
|
||||
announcement: "NEW! Route tool usage through JavaScript REPL with js_repl_tools_only.",
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::WebSearchRequest,
|
||||
key: "web_search_request",
|
||||
|
||||
@@ -33,6 +33,12 @@ const GPT_5_2_CODEX_PERSONALITY_FRIENDLY: &str =
|
||||
include_str!("../../templates/personalities/gpt-5.2-codex_friendly.md");
|
||||
const GPT_5_2_CODEX_PERSONALITY_PRAGMATIC: &str =
|
||||
include_str!("../../templates/personalities/gpt-5.2-codex_pragmatic.md");
|
||||
const JS_REPL_SECTION_START: &str = "<!-- js_repl:start -->";
|
||||
const JS_REPL_SECTION_END: &str = "<!-- js_repl:end -->";
|
||||
const JS_REPL_POLLING_SECTION_START: &str = "<!-- js_repl_polling:start -->";
|
||||
const JS_REPL_POLLING_SECTION_END: &str = "<!-- js_repl_polling:end -->";
|
||||
const JS_REPL_TOOLS_ONLY_SECTION_START: &str = "<!-- js_repl_tools_only:start -->";
|
||||
const JS_REPL_TOOLS_ONLY_SECTION_END: &str = "<!-- js_repl_tools_only:end -->";
|
||||
|
||||
pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000;
|
||||
|
||||
@@ -107,10 +113,69 @@ pub(crate) fn with_config_overrides(mut model: ModelInfo, config: &Config) -> Mo
|
||||
} else if !config.features.enabled(Feature::Personality) {
|
||||
model.model_messages = None;
|
||||
}
|
||||
model.base_instructions = apply_js_repl_section(
|
||||
&model.base_instructions,
|
||||
config.features.enabled(Feature::JsRepl),
|
||||
);
|
||||
model.base_instructions = apply_js_repl_polling_section(
|
||||
&model.base_instructions,
|
||||
config.features.enabled(Feature::JsRepl) && config.features.enabled(Feature::JsReplPolling),
|
||||
);
|
||||
model.base_instructions = apply_js_repl_tools_only_section(
|
||||
&model.base_instructions,
|
||||
config.features.enabled(Feature::JsRepl)
|
||||
&& config.features.enabled(Feature::JsReplToolsOnly),
|
||||
);
|
||||
|
||||
model
|
||||
}
|
||||
|
||||
pub(crate) fn apply_js_repl_section(input: &str, enabled: bool) -> String {
|
||||
apply_section(input, JS_REPL_SECTION_START, JS_REPL_SECTION_END, enabled)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_js_repl_polling_section(input: &str, enabled: bool) -> String {
|
||||
apply_section(
|
||||
input,
|
||||
JS_REPL_POLLING_SECTION_START,
|
||||
JS_REPL_POLLING_SECTION_END,
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_js_repl_tools_only_section(input: &str, enabled: bool) -> String {
|
||||
apply_section(
|
||||
input,
|
||||
JS_REPL_TOOLS_ONLY_SECTION_START,
|
||||
JS_REPL_TOOLS_ONLY_SECTION_END,
|
||||
enabled,
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_section(input: &str, start_marker: &str, end_marker: &str, enabled: bool) -> String {
|
||||
let Some(start) = input.find(start_marker) else {
|
||||
return input.to_string();
|
||||
};
|
||||
let Some(end) = input.find(end_marker) else {
|
||||
return input.to_string();
|
||||
};
|
||||
if end <= start {
|
||||
return input.to_string();
|
||||
}
|
||||
let before = &input[..start];
|
||||
let section_start = start + start_marker.len();
|
||||
let after_start = end + end_marker.len();
|
||||
let section = &input[section_start..end];
|
||||
let after = &input[after_start..];
|
||||
let mut result = String::with_capacity(before.len() + section.len() + after.len());
|
||||
result.push_str(before);
|
||||
if enabled {
|
||||
result.push_str(section);
|
||||
}
|
||||
result.push_str(after);
|
||||
result
|
||||
}
|
||||
|
||||
// todo(aibrahim): remove most of the entries here when enabling models.json
|
||||
pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
|
||||
if slug.starts_with("o3") || slug.starts_with("o4-mini") {
|
||||
|
||||
693
codex-rs/core/src/tools/handlers/js_repl.rs
Normal file
693
codex-rs/core/src/tools/handlers/js_repl.rs
Normal file
@@ -0,0 +1,693 @@
|
||||
use async_trait::async_trait;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StreamOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolOutput;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::events::ToolEmitter;
|
||||
use crate::tools::events::ToolEventCtx;
|
||||
use crate::tools::events::ToolEventStage;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::js_repl::JS_REPL_PRAGMA_PREFIX;
|
||||
use crate::tools::js_repl::JsExecPollResult;
|
||||
use crate::tools::js_repl::JsImageArtifact;
|
||||
use crate::tools::js_repl::JsReplArgs;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
|
||||
const PER_IMAGE_RAW_LIMIT: usize = 32 * 1024 * 1024;
|
||||
const TOTAL_RAW_LIMIT: usize = 36 * 1024 * 1024;
|
||||
|
||||
pub struct JsReplHandler;
|
||||
pub struct JsReplResetHandler;
|
||||
pub struct JsReplPollHandler;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
struct JsReplPollArgs {
|
||||
exec_id: String,
|
||||
#[serde(default)]
|
||||
yield_time_ms: Option<u64>,
|
||||
}
|
||||
|
||||
fn join_outputs(stdout: &str, stderr: &str) -> String {
|
||||
if stdout.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if stderr.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
format!("{stdout}\n{stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
fn build_js_repl_exec_output(
|
||||
output: &str,
|
||||
error: Option<&str>,
|
||||
duration: Duration,
|
||||
) -> ExecToolCallOutput {
|
||||
let stdout = output.to_string();
|
||||
let stderr = error.unwrap_or("").to_string();
|
||||
let aggregated_output = join_outputs(&stdout, &stderr);
|
||||
ExecToolCallOutput {
|
||||
exit_code: if error.is_some() { 1 } else { 0 },
|
||||
stdout: StreamOutput::new(stdout),
|
||||
stderr: StreamOutput::new(stderr),
|
||||
aggregated_output: StreamOutput::new(aggregated_output),
|
||||
duration,
|
||||
timed_out: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit_js_repl_exec_end(
|
||||
session: &crate::codex::Session,
|
||||
turn: &crate::codex::TurnContext,
|
||||
call_id: &str,
|
||||
output: &str,
|
||||
error: Option<&str>,
|
||||
duration: Duration,
|
||||
) {
|
||||
let exec_output = build_js_repl_exec_output(output, error, duration);
|
||||
let emitter = ToolEmitter::shell(
|
||||
vec!["js_repl".to_string()],
|
||||
turn.cwd.clone(),
|
||||
ExecCommandSource::Agent,
|
||||
false,
|
||||
);
|
||||
let ctx = ToolEventCtx::new(session, turn, call_id, None);
|
||||
emitter
|
||||
.emit(ctx, ToolEventStage::Success(exec_output))
|
||||
.await;
|
||||
}
|
||||
|
||||
fn encode_artifacts(
|
||||
artifacts: &[JsImageArtifact],
|
||||
) -> (Vec<FunctionCallOutputContentItem>, Vec<String>) {
|
||||
let mut items = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut total_base64: usize = 0;
|
||||
|
||||
for (idx, artifact) in artifacts.iter().enumerate() {
|
||||
match encode_single_image(artifact, &mut total_base64) {
|
||||
Ok(Some(item)) => items.push(item),
|
||||
Ok(None) => {
|
||||
warnings.push(format!(
|
||||
"image #{idx} omitted: exceeded total payload budget (~50MB base64)"
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("image #{idx} omitted: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(items, warnings)
|
||||
}
|
||||
|
||||
fn encode_single_image(
|
||||
artifact: &JsImageArtifact,
|
||||
total_base64: &mut usize,
|
||||
) -> Result<Option<FunctionCallOutputContentItem>, String> {
|
||||
let bytes = &artifact.bytes;
|
||||
let mime = artifact
|
||||
.mime
|
||||
.clone()
|
||||
.unwrap_or_else(|| "image/png".to_string());
|
||||
|
||||
if bytes.len() > PER_IMAGE_RAW_LIMIT {
|
||||
return Err(format!(
|
||||
"raw image payload ({size} bytes) exceeds per-image limit",
|
||||
size = bytes.len()
|
||||
));
|
||||
}
|
||||
|
||||
let base64_size = encoded_len(bytes.len());
|
||||
if *total_base64 + base64_size > TOTAL_RAW_LIMIT * 4 / 3 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
*total_base64 += base64_size;
|
||||
let data_url = to_data_url(bytes, &mime);
|
||||
Ok(Some(FunctionCallOutputContentItem::InputImage {
|
||||
image_url: data_url,
|
||||
}))
|
||||
}
|
||||
|
||||
fn to_data_url(bytes: &[u8], mime: &str) -> String {
|
||||
let encoded = BASE64_STANDARD.encode(bytes);
|
||||
format!("data:{mime};base64,{encoded}")
|
||||
}
|
||||
|
||||
fn encoded_len(raw: usize) -> usize {
|
||||
raw.div_ceil(3) * 4
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for JsReplHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
fn matches_kind(&self, payload: &ToolPayload) -> bool {
|
||||
matches!(
|
||||
payload,
|
||||
ToolPayload::Function { .. } | ToolPayload::Custom { .. }
|
||||
)
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
payload,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.features().enabled(Feature::JsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let args = match payload {
|
||||
ToolPayload::Function { arguments } => parse_arguments(&arguments)?,
|
||||
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl expects custom or function payload".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if args.poll && !session.features().enabled(Feature::JsReplPolling) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl polling is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
if args.poll {
|
||||
let submission = Arc::clone(&manager)
|
||||
.submit(Arc::clone(&session), Arc::clone(&turn), tracker, args)
|
||||
.await?;
|
||||
let content = serde_json::to_string(&serde_json::json!({
|
||||
"exec_id": submission.exec_id,
|
||||
"status": "running",
|
||||
}))
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize js_repl submission result: {err}"
|
||||
))
|
||||
})?;
|
||||
return Ok(ToolOutput::Function {
|
||||
content,
|
||||
content_items: None,
|
||||
success: Some(true),
|
||||
});
|
||||
}
|
||||
let started_at = Instant::now();
|
||||
let result = manager
|
||||
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
|
||||
.await?;
|
||||
|
||||
let (mut items, warnings) = encode_artifacts(&result.artifacts);
|
||||
let mut content = result.output;
|
||||
if !warnings.is_empty() {
|
||||
if !content.is_empty() {
|
||||
content.push('\n');
|
||||
}
|
||||
content.push_str(&warnings.join("\n"));
|
||||
}
|
||||
items.insert(
|
||||
0,
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: content.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
emit_js_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&call_id,
|
||||
&content,
|
||||
None,
|
||||
started_at.elapsed(),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
content,
|
||||
content_items: Some(items),
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for JsReplResetHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
if !invocation.session.features().enabled(Feature::JsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
let manager = invocation.turn.js_repl.manager().await?;
|
||||
manager.reset().await?;
|
||||
Ok(ToolOutput::Function {
|
||||
content: "js_repl kernel reset".to_string(),
|
||||
content_items: None,
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for JsReplPollHandler {
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.features().enabled(Feature::JsRepl) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
if !session.features().enabled(Feature::JsReplPolling) {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl polling is disabled by feature flag".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let ToolPayload::Function { arguments } = payload else {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl_poll expects function payload".to_string(),
|
||||
));
|
||||
};
|
||||
let args: JsReplPollArgs = parse_arguments(&arguments)?;
|
||||
let manager = turn.js_repl.manager().await?;
|
||||
let result = manager.poll(&args.exec_id, args.yield_time_ms).await?;
|
||||
let output = format_poll_output(&result)?;
|
||||
if result.done {
|
||||
let warnings = if result.artifacts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let (_, warnings) = encode_artifacts(&result.artifacts);
|
||||
if warnings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(warnings.join("\n"))
|
||||
}
|
||||
};
|
||||
let display_output = build_poll_display_output(&result, warnings.as_deref());
|
||||
let duration = result.duration.unwrap_or(Duration::ZERO);
|
||||
emit_js_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
&result.exec_id,
|
||||
&display_output,
|
||||
result.error.as_deref(),
|
||||
duration,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Ok(ToolOutput::Function {
|
||||
content: output.content,
|
||||
content_items: output.content_items,
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct JsReplPollOutput {
|
||||
content: String,
|
||||
content_items: Option<Vec<FunctionCallOutputContentItem>>,
|
||||
}
|
||||
|
||||
fn build_poll_display_output(result: &JsExecPollResult, warnings: Option<&str>) -> String {
|
||||
let mut output = String::new();
|
||||
let mut push_section = |text: &str| {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !output.is_empty() {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(text);
|
||||
};
|
||||
if !result.all_logs.is_empty() {
|
||||
push_section(&result.all_logs.join("\n"));
|
||||
}
|
||||
if let Some(text) = result.output.as_deref() {
|
||||
push_section(text);
|
||||
}
|
||||
if let Some(text) = warnings {
|
||||
push_section(text);
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn format_poll_output(result: &JsExecPollResult) -> Result<JsReplPollOutput, FunctionCallError> {
|
||||
let status = if result.done {
|
||||
if result.error.is_some() {
|
||||
"error"
|
||||
} else {
|
||||
"completed"
|
||||
}
|
||||
} else {
|
||||
"running"
|
||||
};
|
||||
|
||||
let logs = if result.logs.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(result.logs.join("\n"))
|
||||
};
|
||||
let payload = serde_json::json!({
|
||||
"exec_id": result.exec_id,
|
||||
"status": status,
|
||||
"logs": logs,
|
||||
"output": result.output,
|
||||
"error": result.error,
|
||||
});
|
||||
let content = serde_json::to_string(&payload).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("failed to serialize js_repl poll result: {err}"))
|
||||
})?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
if !result.logs.is_empty() {
|
||||
items.extend(
|
||||
result
|
||||
.logs
|
||||
.iter()
|
||||
.map(|line| FunctionCallOutputContentItem::InputText { text: line.clone() }),
|
||||
);
|
||||
}
|
||||
items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: content.clone(),
|
||||
});
|
||||
if result.done && !result.artifacts.is_empty() {
|
||||
let (mut artifacts, warnings) = encode_artifacts(&result.artifacts);
|
||||
items.append(&mut artifacts);
|
||||
if !warnings.is_empty() {
|
||||
items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: warnings.join("\n"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(JsReplPollOutput {
|
||||
content,
|
||||
content_items: Some(items),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_freeform_args(input: &str) -> Result<JsReplArgs, FunctionCallError> {
|
||||
if input.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl expects raw JavaScript tool input (non-empty). Provide JS source text, optionally with first-line `// codex-js-repl: ...`."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut args = JsReplArgs {
|
||||
code: input.to_string(),
|
||||
timeout_ms: None,
|
||||
reset: false,
|
||||
poll: false,
|
||||
};
|
||||
|
||||
let mut lines = input.splitn(2, '\n');
|
||||
let first_line = lines.next().unwrap_or_default();
|
||||
let rest = lines.next().unwrap_or_default();
|
||||
let trimmed = first_line.trim_start();
|
||||
let Some(pragma) = trimmed.strip_prefix(JS_REPL_PRAGMA_PREFIX) else {
|
||||
reject_json_or_quoted_source(&args.code)?;
|
||||
return Ok(args);
|
||||
};
|
||||
|
||||
let mut timeout_ms: Option<u64> = None;
|
||||
let mut reset: Option<bool> = None;
|
||||
let mut poll: Option<bool> = None;
|
||||
let directive = pragma.trim();
|
||||
if !directive.is_empty() {
|
||||
for token in directive.split_whitespace() {
|
||||
let (key, value) = token.split_once('=').ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"js_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms, reset, poll); got `{token}`"
|
||||
))
|
||||
})?;
|
||||
match key {
|
||||
"timeout_ms" => {
|
||||
if timeout_ms.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl pragma specifies timeout_ms more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = value.parse::<u64>().map_err(|_| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"js_repl pragma timeout_ms must be an integer; got `{value}`"
|
||||
))
|
||||
})?;
|
||||
timeout_ms = Some(parsed);
|
||||
}
|
||||
"reset" => {
|
||||
if reset.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl pragma specifies reset more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = match value.to_ascii_lowercase().as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"js_repl pragma reset must be true or false; got `{value}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
reset = Some(parsed);
|
||||
}
|
||||
"poll" => {
|
||||
if poll.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl pragma specifies poll more than once".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = match value.to_ascii_lowercase().as_str() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"js_repl pragma poll must be true or false; got `{value}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
poll = Some(parsed);
|
||||
}
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"js_repl pragma only supports timeout_ms, reset, poll; got `{key}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rest.trim().is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl pragma must be followed by JavaScript source on subsequent lines".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
reject_json_or_quoted_source(rest)?;
|
||||
args.code = rest.to_string();
|
||||
args.timeout_ms = timeout_ms;
|
||||
args.reset = reset.unwrap_or(false);
|
||||
args.poll = poll.unwrap_or(false);
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
|
||||
let trimmed = code.trim();
|
||||
if trimmed.starts_with("```") {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"js_repl expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// codex-js-repl: ...`)."
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
|
||||
return Ok(());
|
||||
};
|
||||
match value {
|
||||
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
|
||||
"js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
.to_string(),
|
||||
)),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::format_poll_output;
|
||||
use super::parse_freeform_args;
|
||||
use crate::codex::make_session_and_context_with_rx;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ExecCommandSource;
|
||||
use crate::tools::js_repl::JsExecPollResult;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_without_pragma() {
|
||||
let args = parse_freeform_args("console.log('ok');").expect("parse args");
|
||||
assert_eq!(args.code, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, None);
|
||||
assert!(!args.reset);
|
||||
assert!(!args.poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_pragma() {
|
||||
let input = "// codex-js-repl: timeout_ms=15000 reset=true\nconsole.log('ok');";
|
||||
let args = parse_freeform_args(input).expect("parse args");
|
||||
assert_eq!(args.code, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, Some(15_000));
|
||||
assert!(args.reset);
|
||||
assert!(!args.poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_with_poll() {
|
||||
let input = "// codex-js-repl: poll=true\nconsole.log('ok');";
|
||||
let args = parse_freeform_args(input).expect("parse args");
|
||||
assert_eq!(args.code, "console.log('ok');");
|
||||
assert_eq!(args.timeout_ms, None);
|
||||
assert!(!args.reset);
|
||||
assert!(args.poll);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_unknown_key() {
|
||||
let err = parse_freeform_args("// codex-js-repl: nope=1\nconsole.log('ok');")
|
||||
.expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"js_repl pragma only supports timeout_ms, reset, poll; got `nope`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_duplicate_poll() {
|
||||
let err = parse_freeform_args("// codex-js-repl: poll=true poll=false\nconsole.log('ok');")
|
||||
.expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"js_repl pragma specifies poll more than once"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_freeform_args_rejects_json_wrapped_code() {
|
||||
let err = parse_freeform_args(r#"{"code":"await doThing()"}"#).expect_err("expected error");
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_poll_output_includes_logs_as_output_items() {
|
||||
let result = JsExecPollResult {
|
||||
exec_id: "exec-1".to_string(),
|
||||
logs: vec!["line 1".to_string(), "line 2".to_string()],
|
||||
all_logs: Vec::new(),
|
||||
output: None,
|
||||
artifacts: Vec::new(),
|
||||
error: None,
|
||||
done: false,
|
||||
duration: None,
|
||||
};
|
||||
let output = format_poll_output(&result).expect("format poll output");
|
||||
let items = output.content_items.expect("content items");
|
||||
assert_eq!(
|
||||
items[0],
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "line 1".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
items[1],
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "line 2".to_string()
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
items[2],
|
||||
FunctionCallOutputContentItem::InputText { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emit_js_repl_exec_end_sends_event() {
|
||||
let (session, turn, rx) = make_session_and_context_with_rx().await;
|
||||
super::emit_js_repl_exec_end(
|
||||
session.as_ref(),
|
||||
turn.as_ref(),
|
||||
"call-1",
|
||||
"hello",
|
||||
None,
|
||||
Duration::from_millis(12),
|
||||
)
|
||||
.await;
|
||||
|
||||
let event = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let event = rx.recv().await.expect("event");
|
||||
if let EventMsg::ExecCommandEnd(end) = event.msg {
|
||||
break end;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("timed out waiting for exec end");
|
||||
|
||||
assert_eq!(event.call_id, "call-1");
|
||||
assert_eq!(event.turn_id, turn.sub_id);
|
||||
assert_eq!(event.command, vec!["js_repl".to_string()]);
|
||||
assert_eq!(event.cwd, turn.cwd);
|
||||
assert_eq!(event.source, ExecCommandSource::Agent);
|
||||
assert_eq!(event.interaction_input, None);
|
||||
assert_eq!(event.stdout, "hello");
|
||||
assert_eq!(event.stderr, "");
|
||||
assert!(event.aggregated_output.contains("hello"));
|
||||
assert_eq!(event.exit_code, 0);
|
||||
assert_eq!(event.duration, Duration::from_millis(12));
|
||||
assert!(event.formatted_output.contains("hello"));
|
||||
assert!(!event.parsed_cmd.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod apply_patch;
|
||||
pub(crate) mod collab;
|
||||
mod dynamic;
|
||||
mod grep_files;
|
||||
mod js_repl;
|
||||
mod list_dir;
|
||||
mod mcp;
|
||||
mod mcp_resource;
|
||||
@@ -21,6 +22,9 @@ pub use apply_patch::ApplyPatchHandler;
|
||||
pub use collab::CollabHandler;
|
||||
pub use dynamic::DynamicToolHandler;
|
||||
pub use grep_files::GrepFilesHandler;
|
||||
pub use js_repl::JsReplHandler;
|
||||
pub use js_repl::JsReplPollHandler;
|
||||
pub use js_repl::JsReplResetHandler;
|
||||
pub use list_dir::ListDirHandler;
|
||||
pub use mcp::McpHandler;
|
||||
pub use mcp_resource::McpResourceHandler;
|
||||
|
||||
510
codex-rs/core/src/tools/js_repl/kernel.js
Normal file
510
codex-rs/core/src/tools/js_repl/kernel.js
Normal file
@@ -0,0 +1,510 @@
|
||||
// Node-based kernel for js_repl.
|
||||
// Communicates over JSON lines on stdin/stdout.
|
||||
// Requires Node started with --experimental-vm-modules.
|
||||
|
||||
const { Buffer } = require("node:buffer");
|
||||
const crypto = require("node:crypto");
|
||||
const { builtinModules } = require("node:module");
|
||||
const { createInterface } = require("node:readline");
|
||||
const { performance } = require("node:perf_hooks");
|
||||
const path = require("node:path");
|
||||
const { URL, URLSearchParams, pathToFileURL } = require("node:url");
|
||||
const { inspect, TextDecoder, TextEncoder } = require("node:util");
|
||||
const fs = require("node:fs/promises");
|
||||
const vm = require("node:vm");
|
||||
|
||||
const { SourceTextModule, SyntheticModule } = vm;
|
||||
const meriyahPromise = import("./meriyah.umd.min.js").then((m) => m.default ?? m);
|
||||
|
||||
const context = vm.createContext({});
|
||||
context.globalThis = context;
|
||||
context.global = context;
|
||||
context.Buffer = Buffer;
|
||||
context.console = console;
|
||||
context.process = process;
|
||||
context.URL = URL;
|
||||
context.URLSearchParams = URLSearchParams;
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
context.TextEncoder = TextEncoder;
|
||||
}
|
||||
if (typeof TextDecoder !== "undefined") {
|
||||
context.TextDecoder = TextDecoder;
|
||||
}
|
||||
if (typeof AbortController !== "undefined") {
|
||||
context.AbortController = AbortController;
|
||||
}
|
||||
if (typeof AbortSignal !== "undefined") {
|
||||
context.AbortSignal = AbortSignal;
|
||||
}
|
||||
if (typeof structuredClone !== "undefined") {
|
||||
context.structuredClone = structuredClone;
|
||||
}
|
||||
if (typeof fetch !== "undefined") {
|
||||
context.fetch = fetch;
|
||||
}
|
||||
if (typeof Headers !== "undefined") {
|
||||
context.Headers = Headers;
|
||||
}
|
||||
if (typeof Request !== "undefined") {
|
||||
context.Request = Request;
|
||||
}
|
||||
if (typeof Response !== "undefined") {
|
||||
context.Response = Response;
|
||||
}
|
||||
if (typeof performance !== "undefined") {
|
||||
context.performance = performance;
|
||||
}
|
||||
context.crypto = crypto.webcrypto ?? crypto;
|
||||
context.setTimeout = setTimeout;
|
||||
context.clearTimeout = clearTimeout;
|
||||
context.setInterval = setInterval;
|
||||
context.clearInterval = clearInterval;
|
||||
context.queueMicrotask = queueMicrotask;
|
||||
if (typeof setImmediate !== "undefined") {
|
||||
context.setImmediate = setImmediate;
|
||||
context.clearImmediate = clearImmediate;
|
||||
}
|
||||
context.atob = (data) => Buffer.from(data, "base64").toString("binary");
|
||||
context.btoa = (data) => Buffer.from(data, "binary").toString("base64");
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string, kind: "const"|"let"|"var"|"function"|"class" }} Binding
|
||||
*/
|
||||
|
||||
let previousModule = null;
|
||||
/** @type {Binding[]} */
|
||||
let previousBindings = [];
|
||||
let cellCounter = 0;
|
||||
|
||||
const builtinModuleSet = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map((name) => `node:${name}`),
|
||||
]);
|
||||
const replHome = process.env.CODEX_JS_REPL_HOME || process.cwd();
|
||||
const vendorNodeModules =
|
||||
process.env.CODEX_JS_REPL_VENDOR_NODE_MODULES ||
|
||||
path.join(replHome, "codex_node_modules", "node_modules");
|
||||
const userNodeModules =
|
||||
process.env.CODEX_JS_REPL_USER_NODE_MODULES || path.join(replHome, "node_modules");
|
||||
|
||||
function resolvePath(candidate) {
|
||||
try {
|
||||
return require.resolve(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFromRoot(root, specifier) {
|
||||
return resolvePath(path.join(root, specifier));
|
||||
}
|
||||
|
||||
/** @type {Map<string, (msg: any) => void>} */
|
||||
const pendingShell = new Map();
|
||||
let shellCounter = 0;
|
||||
/** @type {Map<string, (msg: any) => void>} */
|
||||
const pendingTool = new Map();
|
||||
let toolCounter = 0;
|
||||
const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd();
|
||||
const state = {};
|
||||
|
||||
function resolveSpecifier(specifier) {
|
||||
if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) {
|
||||
return { kind: "builtin", specifier };
|
||||
}
|
||||
|
||||
if (specifier.startsWith("file:")) {
|
||||
return { kind: "url", url: specifier };
|
||||
}
|
||||
|
||||
if (specifier.startsWith("./") || specifier.startsWith("../") || path.isAbsolute(specifier)) {
|
||||
return { kind: "path", path: path.resolve(process.cwd(), specifier) };
|
||||
}
|
||||
|
||||
const resolved =
|
||||
resolveFromRoot(vendorNodeModules, specifier) || resolveFromRoot(userNodeModules, specifier);
|
||||
if (!resolved) {
|
||||
throw new Error(`Module not found: ${specifier}`);
|
||||
}
|
||||
return { kind: "path", path: resolved };
|
||||
}
|
||||
|
||||
function importResolved(resolved) {
|
||||
if (resolved.kind === "builtin") {
|
||||
return import(resolved.specifier);
|
||||
}
|
||||
if (resolved.kind === "url") {
|
||||
return import(resolved.url);
|
||||
}
|
||||
if (resolved.kind === "path") {
|
||||
return import(pathToFileURL(resolved.path).href);
|
||||
}
|
||||
throw new Error(`Unsupported module resolution kind: ${resolved.kind}`);
|
||||
}
|
||||
|
||||
function collectPatternNames(pattern, kind, map) {
|
||||
if (!pattern) return;
|
||||
switch (pattern.type) {
|
||||
case "Identifier":
|
||||
if (!map.has(pattern.name)) map.set(pattern.name, kind);
|
||||
return;
|
||||
case "ObjectPattern":
|
||||
for (const prop of pattern.properties ?? []) {
|
||||
if (prop.type === "Property") {
|
||||
collectPatternNames(prop.value, kind, map);
|
||||
} else if (prop.type === "RestElement") {
|
||||
collectPatternNames(prop.argument, kind, map);
|
||||
}
|
||||
}
|
||||
return;
|
||||
case "ArrayPattern":
|
||||
for (const elem of pattern.elements ?? []) {
|
||||
if (!elem) continue;
|
||||
if (elem.type === "RestElement") {
|
||||
collectPatternNames(elem.argument, kind, map);
|
||||
} else {
|
||||
collectPatternNames(elem, kind, map);
|
||||
}
|
||||
}
|
||||
return;
|
||||
case "AssignmentPattern":
|
||||
collectPatternNames(pattern.left, kind, map);
|
||||
return;
|
||||
case "RestElement":
|
||||
collectPatternNames(pattern.argument, kind, map);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function collectBindings(ast) {
|
||||
const map = new Map();
|
||||
for (const stmt of ast.body ?? []) {
|
||||
if (stmt.type === "VariableDeclaration") {
|
||||
const kind = stmt.kind;
|
||||
for (const decl of stmt.declarations) {
|
||||
collectPatternNames(decl.id, kind, map);
|
||||
}
|
||||
} else if (stmt.type === "FunctionDeclaration" && stmt.id) {
|
||||
map.set(stmt.id.name, "function");
|
||||
} else if (stmt.type === "ClassDeclaration" && stmt.id) {
|
||||
map.set(stmt.id.name, "class");
|
||||
} else if (stmt.type === "ForStatement") {
|
||||
if (stmt.init && stmt.init.type === "VariableDeclaration" && stmt.init.kind === "var") {
|
||||
for (const decl of stmt.init.declarations) {
|
||||
collectPatternNames(decl.id, "var", map);
|
||||
}
|
||||
}
|
||||
} else if (stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") {
|
||||
if (stmt.left && stmt.left.type === "VariableDeclaration" && stmt.left.kind === "var") {
|
||||
for (const decl of stmt.left.declarations) {
|
||||
collectPatternNames(decl.id, "var", map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries()).map(([name, kind]) => ({ name, kind }));
|
||||
}
|
||||
|
||||
async function buildModuleSource(code) {
|
||||
const meriyah = await meriyahPromise;
|
||||
const ast = meriyah.parseModule(code, {
|
||||
next: true,
|
||||
module: true,
|
||||
ranges: false,
|
||||
loc: false,
|
||||
disableWebCompat: true,
|
||||
});
|
||||
const currentBindings = collectBindings(ast);
|
||||
const priorBindings = previousModule ? previousBindings : [];
|
||||
|
||||
let prelude = "";
|
||||
if (previousModule && priorBindings.length) {
|
||||
prelude += 'import * as __prev from "@prev";\n';
|
||||
prelude += priorBindings
|
||||
.map((b) => {
|
||||
const keyword = b.kind === "var" ? "var" : b.kind === "const" ? "const" : "let";
|
||||
return `${keyword} ${b.name} = __prev.${b.name};`;
|
||||
})
|
||||
.join("\n");
|
||||
prelude += "\n";
|
||||
}
|
||||
|
||||
const mergedBindings = new Map();
|
||||
for (const binding of priorBindings) {
|
||||
mergedBindings.set(binding.name, binding.kind);
|
||||
}
|
||||
for (const binding of currentBindings) {
|
||||
mergedBindings.set(binding.name, binding.kind);
|
||||
}
|
||||
const exportNames = Array.from(mergedBindings.keys());
|
||||
const exportStmt = exportNames.length ? `\nexport { ${exportNames.join(", ")} };` : "";
|
||||
|
||||
const nextBindings = Array.from(mergedBindings, ([name, kind]) => ({ name, kind }));
|
||||
return { source: `${prelude}${code}${exportStmt}`, nextBindings };
|
||||
}
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(JSON.stringify(message));
|
||||
process.stdout.write("\n");
|
||||
}
|
||||
|
||||
function formatLog(args) {
|
||||
return args
|
||||
.map((arg) => (typeof arg === "string" ? arg : inspect(arg, { depth: 4, colors: false })))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function withCapturedConsole(ctx, onLog, fn) {
|
||||
const logs = [];
|
||||
const original = ctx.console ?? console;
|
||||
const captured = {
|
||||
...original,
|
||||
log: (...args) => {
|
||||
const line = formatLog(args);
|
||||
logs.push(line);
|
||||
if (onLog) onLog(line);
|
||||
},
|
||||
info: (...args) => {
|
||||
const line = formatLog(args);
|
||||
logs.push(line);
|
||||
if (onLog) onLog(line);
|
||||
},
|
||||
warn: (...args) => {
|
||||
const line = formatLog(args);
|
||||
logs.push(line);
|
||||
if (onLog) onLog(line);
|
||||
},
|
||||
error: (...args) => {
|
||||
const line = formatLog(args);
|
||||
logs.push(line);
|
||||
if (onLog) onLog(line);
|
||||
},
|
||||
debug: (...args) => {
|
||||
const line = formatLog(args);
|
||||
logs.push(line);
|
||||
if (onLog) onLog(line);
|
||||
},
|
||||
};
|
||||
ctx.console = captured;
|
||||
return fn(logs).finally(() => {
|
||||
ctx.console = original;
|
||||
});
|
||||
}
|
||||
|
||||
async function readBytes(input) {
|
||||
if (typeof input === "string") {
|
||||
const bytes = await fs.readFile(input);
|
||||
return new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
}
|
||||
if (input instanceof ArrayBuffer) {
|
||||
return new Uint8Array(input);
|
||||
}
|
||||
if (ArrayBuffer.isView(input)) {
|
||||
return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
|
||||
}
|
||||
throw new Error("emitImage accepts a file path or bytes");
|
||||
}
|
||||
|
||||
async function handleExec(message) {
|
||||
const artifacts = [];
|
||||
|
||||
const emitImage = async (input, meta) => {
|
||||
const bytes = await readBytes(input);
|
||||
artifacts.push({
|
||||
kind: "image",
|
||||
data: Buffer.from(bytes).toString("base64"),
|
||||
mime: meta?.mime,
|
||||
caption: meta?.caption,
|
||||
name: meta?.name,
|
||||
});
|
||||
};
|
||||
|
||||
const sh = (command, opts = {}) => {
|
||||
if (typeof command !== "string") {
|
||||
return Promise.reject(new Error("codex.sh expects the first argument to be a string"));
|
||||
}
|
||||
const id = `${message.id}-sh-${shellCounter++}`;
|
||||
const timeoutMs =
|
||||
typeof opts?.timeout_ms === "number" && Number.isFinite(opts.timeout_ms)
|
||||
? opts.timeout_ms
|
||||
: null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = {
|
||||
type: "run_shell",
|
||||
id,
|
||||
exec_id: message.id,
|
||||
command,
|
||||
cwd: opts?.cwd,
|
||||
timeout_ms: opts?.timeout_ms,
|
||||
sandbox_permissions: opts?.sandbox_permissions,
|
||||
justification: opts?.justification,
|
||||
};
|
||||
send(payload);
|
||||
let guard;
|
||||
if (timeoutMs !== null) {
|
||||
guard = setTimeout(() => {
|
||||
if (pendingShell.delete(id)) {
|
||||
reject(new Error("shell request timed out"));
|
||||
}
|
||||
}, timeoutMs + 1_000);
|
||||
}
|
||||
pendingShell.set(id, (res) => {
|
||||
if (guard) clearTimeout(guard);
|
||||
resolve(res);
|
||||
});
|
||||
}).then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error || "shell failed");
|
||||
}
|
||||
return { stdout: res.stdout, stderr: res.stderr, exitCode: res.exit_code };
|
||||
});
|
||||
};
|
||||
|
||||
const tool = (toolName, args) => {
|
||||
if (typeof toolName !== "string" || !toolName) {
|
||||
return Promise.reject(new Error("codex.tool expects a tool name string"));
|
||||
}
|
||||
const id = `${message.id}-tool-${toolCounter++}`;
|
||||
let argumentsJson = "{}";
|
||||
if (typeof args === "string") {
|
||||
argumentsJson = args;
|
||||
} else if (typeof args !== "undefined") {
|
||||
argumentsJson = JSON.stringify(args);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = {
|
||||
type: "run_tool",
|
||||
id,
|
||||
exec_id: message.id,
|
||||
tool_name: toolName,
|
||||
arguments: argumentsJson,
|
||||
};
|
||||
send(payload);
|
||||
pendingTool.set(id, (res) => {
|
||||
if (!res.ok) {
|
||||
reject(new Error(res.error || "tool failed"));
|
||||
return;
|
||||
}
|
||||
resolve(res.response);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const code = typeof message.code === "string" ? message.code : "";
|
||||
const streamLogs = Boolean(message.stream_logs);
|
||||
const { source, nextBindings } = await buildModuleSource(code);
|
||||
let output = "";
|
||||
|
||||
context.state = state;
|
||||
context.codex = { state, tmpDir, sh, emitImage, tool };
|
||||
context.tmpDir = tmpDir;
|
||||
|
||||
await withCapturedConsole(
|
||||
context,
|
||||
streamLogs ? (line) => send({ type: "exec_log", id: message.id, text: line }) : null,
|
||||
async (logs) => {
|
||||
const module = new SourceTextModule(source, {
|
||||
context,
|
||||
identifier: `cell-${cellCounter++}.mjs`,
|
||||
initializeImportMeta(meta, mod) {
|
||||
meta.url = `file://${mod.identifier}`;
|
||||
},
|
||||
importModuleDynamically(specifier) {
|
||||
return importResolved(resolveSpecifier(specifier));
|
||||
},
|
||||
});
|
||||
|
||||
await module.link(async (specifier) => {
|
||||
if (specifier === "@prev" && previousModule) {
|
||||
const exportNames = previousBindings.map((b) => b.name);
|
||||
const synthetic = new SyntheticModule(
|
||||
exportNames,
|
||||
function initSynthetic() {
|
||||
for (const binding of previousBindings) {
|
||||
this.setExport(binding.name, previousModule.namespace[binding.name]);
|
||||
}
|
||||
},
|
||||
{ context },
|
||||
);
|
||||
return synthetic;
|
||||
}
|
||||
|
||||
const resolved = resolveSpecifier(specifier);
|
||||
return importResolved(resolved);
|
||||
});
|
||||
|
||||
await module.evaluate();
|
||||
previousModule = module;
|
||||
previousBindings = nextBindings;
|
||||
output = logs.join("\n");
|
||||
},
|
||||
);
|
||||
|
||||
send({
|
||||
type: "exec_result",
|
||||
id: message.id,
|
||||
ok: true,
|
||||
output,
|
||||
artifacts,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
send({
|
||||
type: "exec_result",
|
||||
id: message.id,
|
||||
ok: false,
|
||||
output: "",
|
||||
artifacts,
|
||||
error: error && error.message ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleShellResult(message) {
|
||||
const resolver = pendingShell.get(message.id);
|
||||
if (resolver) {
|
||||
pendingShell.delete(message.id);
|
||||
resolver(message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolResult(message) {
|
||||
const resolver = pendingTool.get(message.id);
|
||||
if (resolver) {
|
||||
pendingTool.delete(message.id);
|
||||
resolver(message);
|
||||
}
|
||||
}
|
||||
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
input.on("line", (line) => {
|
||||
if (!line.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "exec") {
|
||||
queue = queue.then(() => handleExec(message));
|
||||
return;
|
||||
}
|
||||
if (message.type === "run_shell_result") {
|
||||
handleShellResult(message);
|
||||
return;
|
||||
}
|
||||
if (message.type === "run_tool_result") {
|
||||
handleToolResult(message);
|
||||
}
|
||||
});
|
||||
1
codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
vendored
Normal file
1
codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1846
codex-rs/core/src/tools/js_repl/mod.rs
Normal file
1846
codex-rs/core/src/tools/js_repl/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
pub mod context;
|
||||
pub mod events;
|
||||
pub(crate) mod handlers;
|
||||
pub mod js_repl;
|
||||
pub mod orchestrator;
|
||||
pub mod parallel;
|
||||
pub mod registry;
|
||||
|
||||
@@ -84,7 +84,13 @@ impl ToolCallRuntime {
|
||||
};
|
||||
|
||||
router
|
||||
.dispatch_tool_call(session, turn, tracker, call.clone())
|
||||
.dispatch_tool_call(
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
call.clone(),
|
||||
crate::tools::router::ToolCallSource::Model,
|
||||
)
|
||||
.instrument(dispatch_span.clone())
|
||||
.await
|
||||
} => res,
|
||||
|
||||
@@ -27,6 +27,12 @@ pub struct ToolCall {
|
||||
pub payload: ToolPayload,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ToolCallSource {
|
||||
Model,
|
||||
JsRepl,
|
||||
}
|
||||
|
||||
pub struct ToolRouter {
|
||||
registry: ToolRegistry,
|
||||
specs: Vec<ConfiguredToolSpec>,
|
||||
@@ -137,6 +143,7 @@ impl ToolRouter {
|
||||
turn: Arc<TurnContext>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
call: ToolCall,
|
||||
source: ToolCallSource,
|
||||
) -> Result<ResponseInputItem, FunctionCallError> {
|
||||
let ToolCall {
|
||||
tool_name,
|
||||
@@ -146,6 +153,22 @@ impl ToolRouter {
|
||||
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
|
||||
let failure_call_id = call_id.clone();
|
||||
|
||||
if source == ToolCallSource::Model
|
||||
&& turn.tools_config.js_repl_tools_only
|
||||
&& !matches!(tool_name.as_str(), "js_repl" | "js_repl_reset")
|
||||
&& !(turn.tools_config.js_repl_poll_enabled && tool_name == "js_repl_poll")
|
||||
{
|
||||
let err = FunctionCallError::RespondToModel(
|
||||
"direct tool calls are disabled; use js_repl and codex.tool(...) instead"
|
||||
.to_string(),
|
||||
);
|
||||
return Ok(Self::failure_response(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
err,
|
||||
));
|
||||
}
|
||||
|
||||
let invocation = ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
@@ -189,3 +212,118 @@ impl ToolRouter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
use super::ToolCall;
|
||||
use super::ToolCallSource;
|
||||
use super::ToolRouter;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
turn.tools_config.js_repl_tools_only = true;
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let mcp_tools = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
turn.dynamic_tools.as_slice(),
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: "shell".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let response = router
|
||||
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Model)
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
assert!(
|
||||
output.content.contains("direct tool calls are disabled"),
|
||||
"unexpected tool call message: {}",
|
||||
output.content
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> {
|
||||
let (session, mut turn) = make_session_and_context().await;
|
||||
turn.tools_config.js_repl_tools_only = true;
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
let mcp_tools = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
turn.dynamic_tools.as_slice(),
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: "shell".to_string(),
|
||||
call_id: "call-2".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
},
|
||||
};
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
let response = router
|
||||
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl)
|
||||
.await?;
|
||||
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
assert!(
|
||||
!output.content.contains("direct tool calls are disabled"),
|
||||
"js_repl source should bypass direct-call policy gate"
|
||||
);
|
||||
}
|
||||
other => panic!("expected function call output, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::agent::AgentRole;
|
||||
use crate::client_common::tools::FreeformTool;
|
||||
use crate::client_common::tools::FreeformToolFormat;
|
||||
use crate::client_common::tools::ResponsesApiTool;
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::features::Feature;
|
||||
@@ -29,6 +31,9 @@ pub(crate) struct ToolsConfig {
|
||||
pub shell_type: ConfigShellToolType,
|
||||
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
|
||||
pub web_search_mode: Option<WebSearchMode>,
|
||||
pub js_repl_enabled: bool,
|
||||
pub js_repl_poll_enabled: bool,
|
||||
pub js_repl_tools_only: bool,
|
||||
pub collab_tools: bool,
|
||||
pub collaboration_modes_tools: bool,
|
||||
pub request_rule_enabled: bool,
|
||||
@@ -49,6 +54,10 @@ impl ToolsConfig {
|
||||
web_search_mode,
|
||||
} = params;
|
||||
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
|
||||
let include_js_repl = features.enabled(Feature::JsRepl);
|
||||
let include_js_repl_polling = include_js_repl && features.enabled(Feature::JsReplPolling);
|
||||
let include_js_repl_tools_only =
|
||||
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
|
||||
let include_collab_tools = features.enabled(Feature::Collab);
|
||||
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
|
||||
let request_rule_enabled = features.enabled(Feature::RequestRule);
|
||||
@@ -82,6 +91,9 @@ impl ToolsConfig {
|
||||
shell_type,
|
||||
apply_patch_tool_type,
|
||||
web_search_mode: *web_search_mode,
|
||||
js_repl_enabled: include_js_repl,
|
||||
js_repl_poll_enabled: include_js_repl_polling,
|
||||
js_repl_tools_only: include_js_repl_tools_only,
|
||||
collab_tools: include_collab_tools,
|
||||
collaboration_modes_tools: include_collaboration_modes_tools,
|
||||
request_rule_enabled,
|
||||
@@ -90,6 +102,20 @@ impl ToolsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn filter_tools_for_model(tools: Vec<ToolSpec>, config: &ToolsConfig) -> Vec<ToolSpec> {
|
||||
if !config.js_repl_tools_only {
|
||||
return tools;
|
||||
}
|
||||
|
||||
tools
|
||||
.into_iter()
|
||||
.filter(|spec| {
|
||||
matches!(spec.name(), "js_repl" | "js_repl_reset")
|
||||
|| (config.js_repl_poll_enabled && spec.name() == "js_repl_poll")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generic JSON‑Schema subset needed for our tool definitions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
@@ -923,6 +949,73 @@ fn create_list_dir_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_tool(polling_enabled: bool) -> ToolSpec {
|
||||
const JS_REPL_FREEFORM_GRAMMAR: &str = r#"start: /[\s\S]*/"#;
|
||||
let mut description = "Runs JavaScript in a persistent Node kernel with top-level await. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-js-repl: timeout_ms=15000 reset=true`; do not send JSON/quotes/markdown fences."
|
||||
.to_string();
|
||||
if polling_enabled {
|
||||
description.push_str(
|
||||
" Add `poll=true` in the first-line pragma to return an exec_id for polling.",
|
||||
);
|
||||
}
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
name: "js_repl".to_string(),
|
||||
description,
|
||||
format: FreeformToolFormat {
|
||||
r#type: "grammar".to_string(),
|
||||
syntax: "lark".to_string(),
|
||||
definition: JS_REPL_FREEFORM_GRAMMAR.to_string(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_poll_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"exec_id".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some("Identifier returned by js_repl when poll=true.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for logs or completion before yielding."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "js_repl_poll".to_string(),
|
||||
description: "Poll a running js_repl exec for incremental logs or completion.".to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["exec_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_js_repl_reset_tool() -> ToolSpec {
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "js_repl_reset".to_string(),
|
||||
description:
|
||||
"Restarts the js_repl kernel for this run and clears persisted top-level bindings."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
@@ -1229,6 +1322,9 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::CollabHandler;
|
||||
use crate::tools::handlers::DynamicToolHandler;
|
||||
use crate::tools::handlers::GrepFilesHandler;
|
||||
use crate::tools::handlers::JsReplHandler;
|
||||
use crate::tools::handlers::JsReplPollHandler;
|
||||
use crate::tools::handlers::JsReplResetHandler;
|
||||
use crate::tools::handlers::ListDirHandler;
|
||||
use crate::tools::handlers::McpHandler;
|
||||
use crate::tools::handlers::McpResourceHandler;
|
||||
@@ -1254,6 +1350,9 @@ pub(crate) fn build_specs(
|
||||
let mcp_resource_handler = Arc::new(McpResourceHandler);
|
||||
let shell_command_handler = Arc::new(ShellCommandHandler);
|
||||
let request_user_input_handler = Arc::new(RequestUserInputHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_poll_handler = Arc::new(JsReplPollHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
|
||||
match &config.shell_type {
|
||||
ConfigShellToolType::Default => {
|
||||
@@ -1303,6 +1402,17 @@ pub(crate) fn build_specs(
|
||||
builder.push_spec(PLAN_TOOL.clone());
|
||||
builder.register_handler("update_plan", plan_handler);
|
||||
|
||||
if config.js_repl_enabled {
|
||||
builder.push_spec(create_js_repl_tool(config.js_repl_poll_enabled));
|
||||
if config.js_repl_poll_enabled {
|
||||
builder.push_spec(create_js_repl_poll_tool());
|
||||
builder.register_handler("js_repl_poll", js_repl_poll_handler);
|
||||
}
|
||||
builder.push_spec(create_js_repl_reset_tool());
|
||||
builder.register_handler("js_repl", js_repl_handler);
|
||||
builder.register_handler("js_repl_reset", js_repl_reset_handler);
|
||||
}
|
||||
|
||||
if config.collaboration_modes_tools {
|
||||
builder.push_spec(create_request_user_input_tool());
|
||||
builder.register_handler("request_user_input", request_user_input_handler);
|
||||
@@ -1504,6 +1614,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contains_tool_specs(tools: &[ToolSpec], expected_subset: &[&str]) {
|
||||
use std::collections::HashSet;
|
||||
let mut names = HashSet::new();
|
||||
let mut duplicates = Vec::new();
|
||||
for name in tools.iter().map(tool_name) {
|
||||
if !names.insert(name) {
|
||||
duplicates.push(name);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
duplicates.is_empty(),
|
||||
"duplicate tool entries detected: {duplicates:?}"
|
||||
);
|
||||
for expected in expected_subset {
|
||||
assert!(
|
||||
names.contains(expected),
|
||||
"expected tool {expected} to be present; had: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
|
||||
match config.shell_type {
|
||||
ConfigShellToolType::Default => Some("shell"),
|
||||
@@ -1669,6 +1800,150 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &["request_user_input"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
|
||||
let features = Features::with_defaults();
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, &[]).build();
|
||||
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "js_repl"),
|
||||
"js_repl should be disabled when the feature is off"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "js_repl_poll"),
|
||||
"js_repl_poll should be disabled when the feature is off"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"),
|
||||
"js_repl_reset should be disabled when the feature is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_enabled_adds_tools() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::JsRepl);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool.spec.name() == "js_repl_poll"),
|
||||
"js_repl_poll should be disabled when polling is off"
|
||||
);
|
||||
|
||||
features.enable(Feature::JsReplPolling);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, &[]).build();
|
||||
assert_contains_tool_names(&tools, &["js_repl", "js_repl_poll", "js_repl_reset"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_tools_only_filters_model_tools() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::JsRepl);
|
||||
features.enable(Feature::JsReplToolsOnly);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, &[]).build();
|
||||
let filtered = filter_tools_for_model(
|
||||
tools.iter().map(|tool| tool.spec.clone()).collect(),
|
||||
&tools_config,
|
||||
);
|
||||
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_reset"]);
|
||||
assert!(
|
||||
!filtered
|
||||
.iter()
|
||||
.any(|tool| tool_name(tool) == "js_repl_poll"),
|
||||
"js_repl_poll should be hidden when polling is off"
|
||||
);
|
||||
assert!(
|
||||
!filtered.iter().any(|tool| tool_name(tool) == "shell"),
|
||||
"expected non-js_repl tools to be hidden when js_repl_tools_only is enabled"
|
||||
);
|
||||
|
||||
features.enable(Feature::JsReplPolling);
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, &[]).build();
|
||||
let filtered = filter_tools_for_model(
|
||||
tools.iter().map(|tool| tool.spec.clone()).collect(),
|
||||
&tools_config,
|
||||
);
|
||||
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_poll", "js_repl_reset"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_repl_tools_only_hides_dynamic_tools_from_model_tools() {
|
||||
let config = test_config();
|
||||
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::JsRepl);
|
||||
features.enable(Feature::JsReplToolsOnly);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
});
|
||||
let dynamic_tools = vec![DynamicToolSpec {
|
||||
name: "dynamic_echo".to_string(),
|
||||
description: "echo dynamic payload".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string"}
|
||||
},
|
||||
"required": ["text"],
|
||||
"additionalProperties": false
|
||||
}),
|
||||
}];
|
||||
let (tools, _) = build_specs(&tools_config, None, &dynamic_tools).build();
|
||||
assert!(
|
||||
tools.iter().any(|tool| tool.spec.name() == "dynamic_echo"),
|
||||
"expected dynamic tool in full router specs"
|
||||
);
|
||||
|
||||
let filtered = filter_tools_for_model(
|
||||
tools.iter().map(|tool| tool.spec.clone()).collect(),
|
||||
&tools_config,
|
||||
);
|
||||
assert!(
|
||||
!filtered
|
||||
.iter()
|
||||
.any(|tool| tool_name(tool) == "dynamic_echo"),
|
||||
"expected dynamic tools to be hidden from direct model tools in js_repl_tools_only mode"
|
||||
);
|
||||
assert_contains_tool_specs(&filtered, &["js_repl", "js_repl_reset"]);
|
||||
}
|
||||
|
||||
fn assert_model_tools(
|
||||
model_slug: &str,
|
||||
features: &Features,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::models_manager::model_info::BASE_INSTRUCTIONS;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_core::protocol::EventMsg;
|
||||
@@ -225,15 +224,13 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let expected_instructions = [BASE_INSTRUCTIONS, APPLY_PATCH_TOOL_INSTRUCTIONS].join("\n");
|
||||
|
||||
let body0 = req1.single_request().body_json();
|
||||
let instructions0 = body0["instructions"]
|
||||
.as_str()
|
||||
.expect("instructions should be a string");
|
||||
assert_eq!(
|
||||
normalize_newlines(instructions0),
|
||||
normalize_newlines(&expected_instructions)
|
||||
assert!(
|
||||
!instructions0.is_empty(),
|
||||
"expected first request instructions to be non-empty"
|
||||
);
|
||||
|
||||
let body1 = req2.single_request().body_json();
|
||||
@@ -242,7 +239,7 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> {
|
||||
.expect("instructions should be a string");
|
||||
assert_eq!(
|
||||
normalize_newlines(instructions1),
|
||||
normalize_newlines(&expected_instructions)
|
||||
normalize_newlines(instructions0)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -247,6 +247,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
||||
cwd: resolved_cwd,
|
||||
model_provider: model_provider.clone(),
|
||||
codex_linux_sandbox_exe,
|
||||
js_repl_node_path: None,
|
||||
base_instructions: None,
|
||||
developer_instructions: None,
|
||||
personality: None,
|
||||
|
||||
1
codex-rs/node-version.txt
Normal file
1
codex-rs/node-version.txt
Normal file
@@ -0,0 +1 @@
|
||||
25.1.0
|
||||
111
docs/js_repl.md
Normal file
111
docs/js_repl.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# JavaScript REPL (`js_repl`)
|
||||
|
||||
`js_repl` runs JavaScript in a persistent Node-backed kernel with top-level `await`.
|
||||
|
||||
## Feature gates
|
||||
|
||||
`js_repl` is disabled by default and only appears when:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
js_repl = true
|
||||
```
|
||||
|
||||
`js_repl_tools_only` can be enabled to force direct model tool calls through `js_repl`:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
js_repl = true
|
||||
js_repl_tools_only = true
|
||||
```
|
||||
|
||||
When enabled, direct model tool calls are restricted to `js_repl` and `js_repl_reset` (and `js_repl_poll` if polling is enabled). Other tools remain available via `await codex.tool(...)` inside `js_repl`.
|
||||
|
||||
`js_repl_polling` can be enabled to allow async/polled execution:
|
||||
|
||||
```toml
|
||||
[features]
|
||||
js_repl = true
|
||||
js_repl_polling = true
|
||||
```
|
||||
|
||||
When enabled, `js_repl` accepts `poll=true` in the first-line pragma and returns an `exec_id`. Use `js_repl_poll` with that `exec_id` until `status` becomes `completed` or `error`.
|
||||
|
||||
## Node runtime selection
|
||||
|
||||
`js_repl` requires a Node version that meets or exceeds `codex-rs/node-version.txt`.
|
||||
|
||||
Runtime resolution order:
|
||||
|
||||
1. `CODEX_JS_REPL_NODE_PATH` environment variable
|
||||
2. `js_repl_node_path` in config/profile
|
||||
3. Bundled runtime under `vendor/<target>/node/node(.exe)` relative to the Codex executable
|
||||
4. `node` discovered on `PATH`
|
||||
|
||||
You can configure an explicit runtime path:
|
||||
|
||||
```toml
|
||||
js_repl_node_path = "/absolute/path/to/node"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- `js_repl` is a freeform tool: send raw JavaScript source text.
|
||||
- Optional first-line pragma:
|
||||
- `// codex-js-repl: timeout_ms=15000 reset=true`
|
||||
- `// codex-js-repl: poll=true timeout_ms=15000`
|
||||
- Top-level bindings persist across calls.
|
||||
- Use `js_repl_reset` to clear kernel state.
|
||||
|
||||
### Polling flow
|
||||
|
||||
1. Submit with `js_repl` and `poll=true`.
|
||||
2. Read `exec_id` from the response.
|
||||
3. Call `js_repl_poll` with `{"exec_id":"...","yield_time_ms":1000}`.
|
||||
4. Repeat until `status` is `completed` or `error`.
|
||||
|
||||
## Kernel helper APIs
|
||||
|
||||
`js_repl` exposes these globals:
|
||||
|
||||
- `codex.state`: mutable object persisted for the current kernel session.
|
||||
- `codex.tmpDir`: per-session scratch directory path.
|
||||
- `codex.sh(command, opts?)`: runs a shell command through Codex execution policy and returns `{ stdout, stderr, exitCode }`.
|
||||
- `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl`.
|
||||
- `codex.emitImage(pathOrBytes, { mime?, caption?, name? })`: emits an image artifact in tool output.
|
||||
|
||||
Avoid writing directly to `process.stdout` / `process.stderr` / `process.stdin`; the kernel uses JSON lines on stdio for host/kernel protocol.
|
||||
|
||||
## Process isolation and environment
|
||||
|
||||
The Node process is launched with a constrained environment derived from Codex policy and then scrubbed of pre-existing JS/package-manager settings.
|
||||
|
||||
Before launch, host code removes inherited keys matching:
|
||||
|
||||
- `NODE_*`
|
||||
- `NPM_CONFIG_*`
|
||||
- `YARN_*`
|
||||
- `PNPM_*`
|
||||
- `COREPACK_*`
|
||||
|
||||
Then `js_repl` sets explicit values under a dedicated home (`$CODEX_HOME/js_repl`), including:
|
||||
|
||||
- `CODEX_JS_REPL_HOME`
|
||||
- `CODEX_JS_REPL_VENDOR_NODE_MODULES` (`$CODEX_HOME/js_repl/codex_node_modules/node_modules`)
|
||||
- `CODEX_JS_REPL_USER_NODE_MODULES` (`$CODEX_HOME/js_repl/node_modules`)
|
||||
- `CODEX_JS_TMP_DIR`
|
||||
- `NODE_PATH` (vendor + user module roots)
|
||||
- `NODE_REPL_HISTORY`
|
||||
- redirected `HOME`/`USERPROFILE` and package-manager cache/config roots (`npm`, `yarn`, `pnpm`, `corepack`, `XDG_*`)
|
||||
|
||||
## Module resolution order
|
||||
|
||||
For bare specifiers (for example `import("lodash")`), `js_repl` resolves in this order:
|
||||
|
||||
1. Node built-ins (`node:fs`, `fs`, etc.)
|
||||
2. Vendored modules under `$CODEX_HOME/js_repl/codex_node_modules/node_modules`
|
||||
3. User modules under `$CODEX_HOME/js_repl/node_modules`
|
||||
|
||||
If the bare specifier is not found in those roots, the import fails.
|
||||
|
||||
Relative/absolute/file imports still resolve from `process.cwd()`, but bare imports are kept inside the js_repl vendor/user roots.
|
||||
Reference in New Issue
Block a user