Merge d4740400cd into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-21 08:21:43 -07:00
committed by GitHub
126 changed files with 1706 additions and 1275 deletions
+2 -9
View File
@@ -42,9 +42,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# stage_npm_packages.py requires DotSlash when staging releases.
- uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Stage npm package
id: stage_npm_package
env:
@@ -55,17 +52,13 @@ jobs:
# cross-platform native payload required by the npm package layout.
# Passing the workflow URL directly avoids relying on old rust-v*
# branches remaining discoverable via `gh run list --branch ...`.
CODEX_VERSION=0.125.0
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26131514935"
CODEX_VERSION=0.133.0-alpha.4
WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26201494185"
OUTPUT_DIR="${RUNNER_TEMP}"
# This reused workflow predates codex-package archive artifacts, so
# CI synthesizes the package layout from the older per-binary
# artifacts. Release staging must use real package archives.
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \
--workflow-url "$WORKFLOW_URL" \
--package codex \
--allow-legacy-codex-package \
--output-dir "$OUTPUT_DIR"
PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz"
echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT"
+2 -1
View File
@@ -1,6 +1,7 @@
{
"name": "@openai/codex",
"version": "0.0.0-dev",
"description": "Codex CLI is a coding agent from OpenAI that runs locally on your computer.",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
@@ -10,7 +11,7 @@
"node": ">=16"
},
"files": [
"bin",
"bin/codex.js",
"vendor"
],
"repository": {
+1 -4
View File
@@ -255,9 +255,6 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None:
bin_dir = staging_dir / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js")
rg_manifest = CODEX_CLI_ROOT / "bin" / "rg"
if rg_manifest.exists():
shutil.copy2(rg_manifest, bin_dir / "rg")
readme_src = REPO_ROOT / "README.md"
if readme_src.exists():
@@ -316,7 +313,7 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None:
package_json["version"] = version
if package == "codex":
package_json["files"] = ["bin"]
package_json["files"] = ["bin/codex.js"]
package_json["optionalDependencies"] = {
CODEX_PLATFORM_PACKAGES[platform_package]["npm_name"]: (
f"npm:{CODEX_NPM_NAME}@"
+12 -365
View File
@@ -3,26 +3,20 @@
import argparse
from contextlib import contextmanager
import json
import os
import shutil
import subprocess
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import sys
from typing import Iterable, Sequence
from urllib.parse import urlparse
from urllib.request import urlopen
from typing import Sequence
SCRIPT_DIR = Path(__file__).resolve().parent
CODEX_CLI_ROOT = SCRIPT_DIR.parent
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/26131514935" # rust-v0.132.0
DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/26201494185" # rust-v0.133.0-alpha.4
VENDOR_DIR_NAME = "vendor"
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
BINARY_TARGETS = (
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
@@ -39,57 +33,16 @@ class BinaryComponent:
artifact_prefix: str # matches the artifact filename prefix (e.g. codex-<target>.zst)
dest_dir: str # directory under vendor/<target>/ where the binary is installed
binary_basename: str # executable name inside dest_dir (before optional .exe)
targets: tuple[str, ...] | None = None # limit installation to specific targets
WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target)
LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target)
BINARY_COMPONENTS = {
"bwrap": BinaryComponent(
artifact_prefix="bwrap",
dest_dir="codex-resources",
binary_basename="bwrap",
targets=LINUX_TARGETS,
),
"codex": BinaryComponent(
artifact_prefix="codex",
dest_dir="codex",
binary_basename="codex",
),
"codex-responses-api-proxy": BinaryComponent(
artifact_prefix="codex-responses-api-proxy",
dest_dir="codex-responses-api-proxy",
binary_basename="codex-responses-api-proxy",
),
"codex-windows-sandbox-setup": BinaryComponent(
artifact_prefix="codex-windows-sandbox-setup",
dest_dir="codex",
binary_basename="codex-windows-sandbox-setup",
targets=WINDOWS_TARGETS,
),
"codex-command-runner": BinaryComponent(
artifact_prefix="codex-command-runner",
dest_dir="codex",
binary_basename="codex-command-runner",
targets=WINDOWS_TARGETS,
),
}
RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [
("x86_64-unknown-linux-musl", "linux-x86_64"),
("aarch64-unknown-linux-musl", "linux-aarch64"),
("x86_64-apple-darwin", "macos-x86_64"),
("aarch64-apple-darwin", "macos-aarch64"),
("x86_64-pc-windows-msvc", "windows-x86_64"),
("aarch64-pc-windows-msvc", "windows-aarch64"),
]
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]
# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI.
DOWNLOAD_TIMEOUT_SECS = 60
def _gha_enabled() -> bool:
# GitHub Actions supports "workflow commands" (e.g. ::group:: / ::error::) that make logs
@@ -140,22 +93,12 @@ def parse_args() -> argparse.Namespace:
"--component",
dest="components",
action="append",
choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS, "rg"]),
choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS]),
help=(
"Limit installation to the specified components."
" May be repeated. Defaults to codex-package and codex-responses-api-proxy."
),
)
parser.add_argument(
"--allow-legacy-codex-package",
action="store_true",
help=(
"Allow codex-package to be synthesized from legacy per-binary artifacts "
"when package archives are missing. Intended for CI compatibility only; "
"release staging should not use this. Automatically enabled for the "
"built-in default workflow."
),
)
parser.add_argument(
"root",
nargs="?",
@@ -178,7 +121,6 @@ def main() -> int:
components = args.components or [CODEX_PACKAGE_COMPONENT, "codex-responses-api-proxy"]
workflow_override = (args.workflow_url or "").strip()
use_default_workflow = not workflow_override
workflow_url = workflow_override or DEFAULT_WORKFLOW_URL
workflow_id = workflow_url.rstrip("/").split("/")[-1]
@@ -189,28 +131,13 @@ def main() -> int:
artifacts_dir = Path(artifacts_dir_str)
_download_artifacts(workflow_id, artifacts_dir)
if CODEX_PACKAGE_COMPONENT in components:
try:
install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS)
except FileNotFoundError:
if not (args.allow_legacy_codex_package or use_default_workflow):
raise
install_legacy_codex_package_layouts(
artifacts_dir,
vendor_dir,
BINARY_TARGETS,
manifest_path=RG_MANIFEST,
)
install_codex_package_archives(artifacts_dir, vendor_dir, BINARY_TARGETS)
install_binary_components(
artifacts_dir,
vendor_dir,
[BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS],
)
if "rg" in components:
with _gha_group("Fetch ripgrep binaries"):
print("Fetching ripgrep binaries...")
fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST)
print(f"Installed native dependencies into {vendor_dir}")
return 0
@@ -262,156 +189,6 @@ def _install_single_codex_package_archive(
return dest_dir
def install_legacy_codex_package_layouts(
artifacts_dir: Path,
vendor_dir: Path,
targets: Sequence[str],
*,
manifest_path: Path,
) -> None:
targets = list(targets)
print(
"Synthesizing Codex package layouts from legacy artifacts for targets: "
+ ", ".join(targets)
)
with tempfile.TemporaryDirectory(prefix="codex-legacy-package-") as legacy_vendor_dir_str:
legacy_vendor_dir = Path(legacy_vendor_dir_str)
install_binary_components(
artifacts_dir,
legacy_vendor_dir,
[
BINARY_COMPONENTS["codex"],
BINARY_COMPONENTS["bwrap"],
BINARY_COMPONENTS["codex-windows-sandbox-setup"],
BINARY_COMPONENTS["codex-command-runner"],
],
)
fetch_rg(legacy_vendor_dir, targets, manifest_path=manifest_path)
for target in targets:
dest_dir = vendor_dir / target
if dest_dir.exists():
shutil.rmtree(dest_dir)
_build_legacy_codex_package_layout(legacy_vendor_dir / target, dest_dir, target)
print(f" synthesized {dest_dir}")
def _build_legacy_codex_package_layout(
legacy_target_dir: Path,
package_dir: Path,
target: str,
) -> None:
is_windows = "windows" in target
exe_suffix = ".exe" if is_windows else ""
package_dir.mkdir(parents=True)
bin_dir = package_dir / "bin"
resources_dir = package_dir / "codex-resources"
path_dir = package_dir / "codex-path"
bin_dir.mkdir()
resources_dir.mkdir()
path_dir.mkdir()
shutil.copy2(
legacy_target_dir / "codex" / f"codex{exe_suffix}",
bin_dir / f"codex{exe_suffix}",
)
shutil.copy2(
legacy_target_dir / "path" / f"rg{exe_suffix}",
path_dir / f"rg{exe_suffix}",
)
if is_windows:
for helper in [
"codex-command-runner.exe",
"codex-windows-sandbox-setup.exe",
]:
shutil.copy2(legacy_target_dir / "codex" / helper, resources_dir / helper)
elif "linux" in target:
shutil.copy2(legacy_target_dir / "codex-resources" / "bwrap", resources_dir / "bwrap")
write_json(
package_dir / "codex-package.json",
{
"layoutVersion": 1,
"version": "unknown",
"target": target,
"variant": "codex",
"entrypoint": f"bin/codex{exe_suffix}",
"resourcesDir": "codex-resources",
"pathDir": "codex-path",
},
)
def fetch_rg(
vendor_dir: Path,
targets: Sequence[str] | None = None,
*,
manifest_path: Path,
) -> list[Path]:
"""Download ripgrep binaries described by the DotSlash manifest."""
if targets is None:
targets = DEFAULT_RG_TARGETS
if not manifest_path.exists():
raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}")
manifest = _load_manifest(manifest_path)
platforms = manifest.get("platforms", {})
vendor_dir.mkdir(parents=True, exist_ok=True)
targets = list(targets)
if not targets:
return []
task_configs: list[tuple[str, str, dict]] = []
for target in targets:
platform_key = RG_TARGET_TO_PLATFORM.get(target)
if platform_key is None:
raise ValueError(f"Unsupported ripgrep target '{target}'.")
platform_info = platforms.get(platform_key)
if platform_info is None:
raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.")
task_configs.append((target, platform_key, platform_info))
results: dict[str, Path] = {}
max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1)))
print("Installing ripgrep binaries for targets: " + ", ".join(targets))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {
executor.submit(
_fetch_single_rg,
vendor_dir,
target,
platform_key,
platform_info,
manifest_path,
): target
for target, platform_key, platform_info in task_configs
}
for future in as_completed(future_map):
target = future_map[future]
try:
results[target] = future.result()
except Exception as exc:
_gha_error(
title="ripgrep install failed",
message=f"target={target} error={exc!r}",
)
raise RuntimeError(f"Failed to install ripgrep for target {target}.") from exc
print(f" installed ripgrep for {target}")
return [results[target] for target in targets]
def _download_artifacts(workflow_id: str, dest_dir: Path) -> None:
cmd = [
"gh",
@@ -435,7 +212,7 @@ def install_binary_components(
return
for component in selected_components:
component_targets = list(component.targets or BINARY_TARGETS)
component_targets = list(BINARY_TARGETS)
print(
f"Installing {component.binary_basename} binaries for targets: "
@@ -465,7 +242,7 @@ def _install_single_binary(
component: BinaryComponent,
) -> Path:
artifact_subdir = artifact_dir_for_target(artifacts_dir, target)
archive_path = legacy_binary_archive_path(artifact_subdir, component.artifact_prefix, target)
archive_path = binary_archive_path(artifact_subdir, component.artifact_prefix, target)
dest_dir = vendor_dir / target / component.dest_dir
dest_dir.mkdir(parents=True, exist_ok=True)
@@ -475,7 +252,7 @@ def _install_single_binary(
)
dest = dest_dir / binary_name
dest.unlink(missing_ok=True)
extract_archive(archive_path, "zst", None, dest)
extract_zstd_archive(archive_path, dest)
if "windows" not in target:
dest.chmod(0o755)
return dest
@@ -487,7 +264,7 @@ def _archive_name_for_target(artifact_prefix: str, target: str) -> str:
return f"{artifact_prefix}-{target}.zst"
def legacy_binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path:
def binary_archive_path(artifact_dir: Path, artifact_prefix: str, target: str) -> Path:
archive_names = [_archive_name_for_target(artifact_prefix, target)]
if artifact_dir.name == f"{target}-unsigned":
archive_names.append(_archive_name_for_target(artifact_prefix, f"{target}-unsigned"))
@@ -509,142 +286,12 @@ def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path:
return artifacts_dir / target
def _fetch_single_rg(
vendor_dir: Path,
target: str,
platform_key: str,
platform_info: dict,
manifest_path: Path,
) -> Path:
providers = platform_info.get("providers", [])
if not providers:
raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.")
url = providers[0]["url"]
archive_format = platform_info.get("format", "zst")
archive_member = platform_info.get("path")
digest = platform_info.get("digest")
expected_size = platform_info.get("size")
dest_dir = vendor_dir / target / "path"
dest_dir.mkdir(parents=True, exist_ok=True)
is_windows = platform_key.startswith("win")
binary_name = "rg.exe" if is_windows else "rg"
dest = dest_dir / binary_name
with tempfile.TemporaryDirectory() as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
archive_filename = os.path.basename(urlparse(url).path)
download_path = tmp_dir / archive_filename
print(
f" downloading ripgrep for {target} ({platform_key}) from {url}",
flush=True,
)
try:
_download_file(url, download_path)
except Exception as exc:
_gha_error(
title="ripgrep download failed",
message=f"target={target} platform={platform_key} url={url} error={exc!r}",
)
raise RuntimeError(
"Failed to download ripgrep "
f"(target={target}, platform={platform_key}, format={archive_format}, "
f"expected_size={expected_size!r}, digest={digest!r}, url={url}, dest={download_path})."
) from exc
dest.unlink(missing_ok=True)
try:
extract_archive(download_path, archive_format, archive_member, dest)
except Exception as exc:
raise RuntimeError(
"Failed to extract ripgrep "
f"(target={target}, platform={platform_key}, format={archive_format}, "
f"member={archive_member!r}, url={url}, archive={download_path})."
) from exc
if not is_windows:
dest.chmod(0o755)
return dest
def _download_file(url: str, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.unlink(missing_ok=True)
with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response, open(dest, "wb") as out:
shutil.copyfileobj(response, out)
def extract_archive(
archive_path: Path,
archive_format: str,
archive_member: str | None,
dest: Path,
) -> None:
def extract_zstd_archive(archive_path: Path, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
if archive_format == "zst":
output_path = archive_path.parent / dest.name
subprocess.check_call(
["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)]
)
shutil.move(str(output_path), dest)
return
if archive_format == "tar.gz":
if not archive_member:
raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.")
with tarfile.open(archive_path, "r:gz") 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.")
with zipfile.ZipFile(archive_path) as archive:
try:
with archive.open(archive_member) as src, open(dest, "wb") as out:
shutil.copyfileobj(src, out)
except KeyError as exc:
raise RuntimeError(
f"Entry '{archive_member}' not found in archive {archive_path}."
) from exc
return
raise RuntimeError(f"Unsupported archive format '{archive_format}'.")
def _load_manifest(manifest_path: Path) -> dict:
cmd = ["dotslash", "--", "parse", str(manifest_path)]
stdout = subprocess.check_output(cmd, text=True)
try:
manifest = json.loads(stdout)
except json.JSONDecodeError as exc:
raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc
if not isinstance(manifest, dict):
raise RuntimeError(
f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}"
)
return manifest
def write_json(path: Path, value: object) -> None:
with open(path, "w", encoding="utf-8") as out:
json.dump(value, out, indent=2)
out.write("\n")
output_path = archive_path.parent / dest.name
subprocess.check_call(["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)])
shutil.move(str(output_path), dest)
if __name__ == "__main__":
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
import importlib.util
import json
from pathlib import Path
import tempfile
import unittest
BUILD_SCRIPT = Path(__file__).resolve().parent / "build_npm_package.py"
SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"Unable to load module from {BUILD_SCRIPT}")
build_npm_package = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(build_npm_package)
class StageSourcesTest(unittest.TestCase):
def test_codex_meta_package_omits_ripgrep_manifest(self) -> None:
with tempfile.TemporaryDirectory() as temp_dir:
staging_dir = Path(temp_dir)
build_npm_package.stage_sources(staging_dir, "0.1.0", "codex")
with open(staging_dir / "package.json", encoding="utf-8") as fh:
package_json = json.load(fh)
self.assertTrue((staging_dir / "bin" / "codex.js").is_file())
self.assertFalse((staging_dir / "bin" / "rg").exists())
self.assertEqual(package_json["files"], ["bin/codex.js"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
from contextlib import redirect_stdout
import importlib.util
import io
from pathlib import Path
import tarfile
import tempfile
import unittest
INSTALL_SCRIPT = Path(__file__).resolve().parent / "install_native_deps.py"
SPEC = importlib.util.spec_from_file_location("install_native_deps", INSTALL_SCRIPT)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"Unable to load module from {INSTALL_SCRIPT}")
install_native_deps = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(install_native_deps)
class InstallCodexPackageArchivesTest(unittest.TestCase):
def test_installs_codex_package_archive(self) -> None:
target = "x86_64-unknown-linux-musl"
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
artifact_dir = root / "artifacts" / target
package_src = root / "package-src"
vendor_dir = root / "vendor"
artifact_dir.mkdir(parents=True)
(package_src / "bin").mkdir(parents=True)
(package_src / "bin" / "codex").write_text("codex\n", encoding="utf-8")
(package_src / "codex-package.json").write_text("{}\n", encoding="utf-8")
archive_path = artifact_dir / f"codex-package-{target}.tar.gz"
with tarfile.open(archive_path, "w:gz") as archive:
archive.add(package_src / "bin", arcname="bin")
archive.add(package_src / "codex-package.json", arcname="codex-package.json")
with redirect_stdout(io.StringIO()):
install_native_deps.install_codex_package_archives(
root / "artifacts",
vendor_dir,
[target],
)
self.assertEqual(
sorted(
path.relative_to(vendor_dir / target)
for path in (vendor_dir / target).rglob("*")
),
[
Path("bin"),
Path("bin/codex"),
Path("codex-package.json"),
],
)
def test_missing_codex_package_archive_errors(self) -> None:
target = "x86_64-unknown-linux-musl"
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
with redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(
FileNotFoundError,
"Expected package archive not found",
):
install_native_deps.install_codex_package_archives(
root / "artifacts",
root / "vendor",
[target],
)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -2957,6 +2957,7 @@ dependencies = [
"anyhow",
"async-trait",
"chrono",
"codex-core",
"codex-extension-api",
"codex-protocol",
"codex-state",
@@ -340,12 +340,11 @@ use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::request_device_code;
use codex_login::run_login_server;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::McpServerStatusSnapshot;
use codex_mcp::McpSnapshotDetail;
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
use codex_mcp::discover_supported_scopes;
use codex_mcp::effective_mcp_servers;
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
use codex_mcp::resolve_oauth_scopes;
use codex_memories_write::clear_memory_roots_contents;
@@ -171,7 +171,7 @@ impl AppsRequestProcessor {
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&accessible_config,
force_refetch,
&environment_manager,
Arc::clone(&environment_manager),
)
.await
.map_err(|err| format!("failed to load accessible apps: {err}"));
@@ -220,7 +220,7 @@ impl ConfigRequestProcessor {
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
/*force_refetch*/ true,
&environment_manager,
Arc::clone(&environment_manager),
),
);
let all_connectors = match all_connectors_result {
@@ -205,24 +205,20 @@ impl McpRequestProcessor {
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
// Status listing has no turn cwd. Prefer the configured default env,
// then configured local if present; do not manufacture a hidden local
// env in no-local modes.
let runtime_environment = McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
);
// This threadless status path has no turn cwd or turn-selected
// environment. Use config cwd only as the local stdio fallback; named
// environment stdio MCPs must declare their own absolute cwd.
let runtime_context =
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
tokio::spawn(async move {
Self::list_mcp_server_status_task(
outgoing,
request,
params,
config,
mcp_config,
auth,
runtime_environment,
runtime_context,
)
.await;
});
@@ -233,18 +229,16 @@ impl McpRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
) {
let result = Self::list_mcp_server_status_response(
request_id.request_id.to_string(),
params,
config,
mcp_config,
auth,
runtime_environment,
runtime_context,
)
.await;
outgoing.send_result(request_id, result).await;
@@ -253,10 +247,9 @@ impl McpRequestProcessor {
async fn list_mcp_server_status_response(
request_id: String,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
) -> Result<ListMcpServerStatusResponse, JSONRPCErrorError> {
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
@@ -267,31 +260,25 @@ impl McpRequestProcessor {
&mcp_config,
auth.as_ref(),
request_id,
runtime_environment,
runtime_context,
detail,
)
.await;
let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
let McpServerStatusSnapshot {
tools_by_server,
resources,
resource_templates,
auth_statuses,
mut server_names,
} = snapshot;
let mut server_names: Vec<String> = config
.mcp_servers
.keys()
.cloned()
// Include runtime-added/plugin MCP servers that are present in the
// effective runtime config even when they are not user-declared in
// `config.mcp_servers`.
.chain(effective_servers.keys().cloned())
.chain(auth_statuses.keys().cloned())
.chain(resources.keys().cloned())
.chain(resource_templates.keys().cloned())
.collect();
server_names.extend(
auth_statuses
.keys()
.cloned()
.chain(resources.keys().cloned())
.chain(resource_templates.keys().cloned()),
);
server_names.sort();
server_names.dedup();
@@ -367,21 +354,18 @@ impl McpRequestProcessor {
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
// Resource reads without a thread have no turn cwd. Prefer the
// configured default env, then configured local if present; do not
// manufacture a hidden local env in no-local modes.
let runtime_environment = McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
);
// This threadless resource-read path has no turn cwd or turn-selected
// environment. Use config cwd only as the local stdio fallback; named
// environment stdio MCPs must declare their own absolute cwd.
let runtime_context =
McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf());
let request_id = request_id.clone();
tokio::spawn(async move {
let result = read_mcp_resource_without_thread(
&mcp_config,
auth.as_ref(),
runtime_environment,
runtime_context,
&server,
&uri,
)
@@ -959,9 +959,12 @@ impl PluginRequestProcessor {
None => None,
};
let environment_manager = self.thread_manager.environment_manager();
let app_summaries =
load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager)
.await;
let app_summaries = load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
Arc::clone(&environment_manager),
)
.await;
let visible_skills = outcome
.plugin
.skills
@@ -1037,8 +1040,12 @@ impl PluginRequestProcessor {
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries =
load_plugin_app_summaries(&config, &plugin_apps, &environment_manager).await;
let app_summaries = load_plugin_app_summaries(
&config,
&plugin_apps,
Arc::clone(&environment_manager),
)
.await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
@@ -1506,7 +1513,7 @@ impl PluginRequestProcessor {
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
/*force_refetch*/ true,
&environment_manager
Arc::clone(&environment_manager)
),
);
@@ -1772,7 +1779,7 @@ impl PluginRequestProcessor {
async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: &EnvironmentManager,
environment_manager: Arc<EnvironmentManager>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
+6 -11
View File
@@ -1477,7 +1477,7 @@ fn profile_v2_for_subcommand<'a>(
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile-v2 only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`."
"--profile only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`."
),
}
}
@@ -2286,21 +2286,19 @@ mod tests {
#[test]
fn profile_v2_is_rejected_for_config_management_subcommands() {
assert!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "features", "list"]).is_err()
);
assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err());
}
#[test]
fn profile_v2_is_allowed_for_runtime_subcommands() {
assert_eq!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "resume"])
profile_v2_for_args(&["codex", "--profile", "work", "resume"])
.expect("resume supports profile-v2")
.as_deref(),
Some("work")
);
assert_eq!(
profile_v2_for_args(&["codex", "--profile-v2", "work", "debug", "prompt-input"])
profile_v2_for_args(&["codex", "--profile", "work", "debug", "prompt-input"])
.expect("debug prompt-input supports profile-v2")
.as_deref(),
Some("work")
@@ -2310,8 +2308,7 @@ mod tests {
#[test]
fn profile_v2_rejects_non_plain_names_at_parse_time() {
assert!(
MultitoolCli::try_parse_from(["codex", "--profile-v2", "nested/work", "resume"])
.is_err()
MultitoolCli::try_parse_from(["codex", "--profile", "nested/work", "resume"]).is_err()
);
}
@@ -2762,8 +2759,6 @@ mod tests {
"-m",
"gpt-5.1-test",
"-p",
"my-profile",
"--profile-v2",
"my-config",
"-C",
"/tmp",
@@ -2776,7 +2771,7 @@ mod tests {
assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test"));
assert!(interactive.oss);
assert_eq!(interactive.config_profile.as_deref(), Some("my-profile"));
assert_eq!(interactive.config_profile.as_deref(), None);
assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config"));
assert_matches!(
interactive.sandbox_mode,
+1 -1
View File
@@ -301,7 +301,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
let new_entry = McpServerConfig {
transport: transport.clone(),
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+3 -3
View File
@@ -27,7 +27,7 @@ use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC;
use crate::rmcp_client::ManagedClient;
use crate::rmcp_client::StartupOutcomeError;
use crate::rmcp_client::list_tools_for_client_uncached;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::McpRuntimeContext;
use crate::runtime::emit_duration;
use crate::server::EffectiveMcpServer;
use crate::server::McpServerMetadata;
@@ -176,7 +176,7 @@ impl McpConnectionManager {
submit_id: String,
tx_event: Sender<Event>,
initial_permission_profile: PermissionProfile,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
codex_home: PathBuf,
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
host_owned_codex_apps_enabled: bool,
@@ -248,7 +248,7 @@ impl McpConnectionManager {
elicitation_requests.clone(),
codex_apps_tools_cache_context,
Arc::clone(&tool_plugin_provenance),
runtime_environment.clone(),
runtime_context.clone(),
runtime_auth_provider,
client_elicitation_capability.clone(),
);
@@ -18,6 +18,7 @@ use crate::tools::normalize_tools_for_model;
use crate::tools::tool_with_model_visible_input_schema;
use codex_config::Constrained;
use codex_config::McpServerConfig;
use codex_exec_server::EnvironmentManager;
use codex_protocol::ToolName;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::GranularApprovalConfig;
@@ -864,7 +865,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -889,7 +890,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -915,9 +916,8 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() {
String::new(),
tx_event,
PermissionProfile::default(),
McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
),
codex_home.path().to_path_buf(),
@@ -988,7 +988,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1041,7 +1041,7 @@ fn mcp_init_error_display_reports_generic_errors() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+1 -1
View File
@@ -3,7 +3,7 @@ pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
pub use elicitation::ElicitationReviewerHandle;
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
pub use runtime::McpRuntimeEnvironment;
pub use runtime::McpRuntimeContext;
pub use runtime::SandboxState;
pub use tools::ToolInfo;
+13 -6
View File
@@ -38,7 +38,7 @@ use serde_json::Value;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::McpRuntimeContext;
use crate::server::EffectiveMcpServer;
pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps";
@@ -261,7 +261,7 @@ pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance {
pub async fn read_mcp_resource(
config: &McpConfig,
auth: Option<&CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
server: &str,
uri: &str,
) -> anyhow::Result<ReadResourceResult> {
@@ -284,7 +284,7 @@ pub async fn read_mcp_resource(
String::new(),
tx_event,
PermissionProfile::default(),
runtime_environment,
runtime_context,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
@@ -314,13 +314,14 @@ pub struct McpServerStatusSnapshot {
pub resources: HashMap<String, Vec<Resource>>,
pub resource_templates: HashMap<String, Vec<ResourceTemplate>>,
pub auth_statuses: HashMap<String, McpAuthStatus>,
pub server_names: Vec<String>,
}
pub async fn collect_mcp_server_status_snapshot_with_detail(
config: &McpConfig,
auth: Option<&CodexAuth>,
submit_id: String,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot {
let mcp_servers = effective_mcp_servers(config, auth);
@@ -332,6 +333,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
resources: HashMap::new(),
resource_templates: HashMap::new(),
auth_statuses: HashMap::new(),
server_names: Vec::new(),
};
}
@@ -342,6 +344,8 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
)
.await;
let server_names = mcp_servers.keys().cloned().collect();
let (tx_event, rx_event) = unbounded();
drop(rx_event);
@@ -353,7 +357,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
submit_id,
tx_event,
PermissionProfile::default(),
runtime_environment,
runtime_context,
config.codex_home.clone(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
@@ -367,6 +371,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
let snapshot = collect_mcp_server_status_snapshot_from_manager(
&mcp_connection_manager,
auth_status_entries,
server_names,
detail,
)
.await;
@@ -451,7 +456,7 @@ fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig {
http_headers,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -575,6 +580,7 @@ fn convert_mcp_resource_templates(
async fn collect_mcp_server_status_snapshot_from_manager(
mcp_connection_manager: &McpConnectionManager,
auth_status_entries: HashMap<String, crate::mcp::auth::McpAuthStatusEntry>,
server_names: Vec<String>,
detail: McpSnapshotDetail,
) -> McpServerStatusSnapshot {
let (tools, resources, resource_templates) = tokio::join!(
@@ -613,6 +619,7 @@ async fn collect_mcp_server_status_snapshot_from_manager(
resources: convert_mcp_resources(resources),
resource_templates: convert_mcp_resource_templates(resource_templates),
auth_statuses: auth_statuses_from_entries(&auth_status_entries),
server_names,
}
}
+2 -2
View File
@@ -316,7 +316,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -341,7 +341,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+25 -46
View File
@@ -28,7 +28,7 @@ use crate::codex_apps::write_cached_codex_apps_tools_if_needed;
use crate::elicitation::ElicitationRequestManager;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp::ToolPluginProvenance;
use crate::runtime::McpRuntimeEnvironment;
use crate::runtime::McpRuntimeContext;
use crate::runtime::emit_duration;
use crate::server::EffectiveMcpServer;
use crate::server::McpServerLaunch;
@@ -142,7 +142,7 @@ impl AsyncManagedClient {
elicitation_requests: ElicitationRequestManager,
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
tool_plugin_provenance: Arc<ToolPluginProvenance>,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
client_elicitation_capability: ElicitationCapability,
) -> Self {
@@ -170,7 +170,7 @@ impl AsyncManagedClient {
&server_name,
server.clone(),
store_mode,
runtime_environment,
runtime_context,
runtime_auth_provider,
)
.await?,
@@ -560,29 +560,17 @@ async fn make_rmcp_client(
server_name: &str,
server: EffectiveMcpServer,
store_mode: OAuthCredentialsStoreMode,
runtime_environment: McpRuntimeEnvironment,
runtime_context: McpRuntimeContext,
runtime_auth_provider: Option<SharedAuthProvider>,
) -> Result<RmcpClient, StartupOutcomeError> {
let config = match server.launch() {
McpServerLaunch::Configured(config) => config.as_ref().clone(),
};
if let Some(reason) = runtime_environment.startup_unavailable_reason(server_name, &config) {
return Err(StartupOutcomeError::from(anyhow!(reason)));
}
let McpServerConfig {
transport,
experimental_environment,
..
} = config;
let remote_environment = match experimental_environment.as_deref() {
None | Some("local") => false,
Some("remote") => true,
Some(environment) => {
return Err(StartupOutcomeError::from(anyhow!(
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
)));
}
};
let resolved_environment = runtime_context
.resolve_server_environment(server_name, &config)
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))?;
let is_local_environment = config.is_local_environment();
let McpServerConfig { transport, .. } = config;
match transport {
McpServerTransportConfig::Stdio {
@@ -599,27 +587,24 @@ async fn make_rmcp_client(
.map(|(key, value)| (key.into(), value.into()))
.collect::<HashMap<_, _>>()
});
let launcher = if remote_environment {
// Preflight should reject this first, but keep client startup
// defensive if optional runtime placement is mis-threaded.
let Some(environment) = runtime_environment.environment() else {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server requires a runtime environment"
)));
let launcher = if is_local_environment {
// TODO(starr): Unify local stdio MCP launch with
// `ExecutorStdioServerLauncher` once the executor-backed path
// preserves `LocalStdioServerLauncher` semantics.
Arc::new(LocalStdioServerLauncher::new(
runtime_context.local_stdio_fallback_cwd(),
)) as Arc<dyn StdioServerLauncher>
} else {
let Some(environment) = resolved_environment.as_ref() else {
unreachable!(
"non-local stdio MCP servers resolve an environment before launch"
);
};
Arc::new(ExecutorStdioServerLauncher::new(
environment.get_exec_backend(),
runtime_environment.fallback_cwd(),
))
} else {
Arc::new(LocalStdioServerLauncher::new(
runtime_environment.fallback_cwd(),
)) as Arc<dyn StdioServerLauncher>
};
// `RmcpClient` always sees a launched MCP stdio server. The
// launcher hides whether that means a local child process or an
// executor process whose stdin/stdout bytes cross the process API.
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
.await
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
@@ -630,16 +615,10 @@ async fn make_rmcp_client(
env_http_headers,
bearer_token_env_var,
} => {
let http_client: Arc<dyn HttpClient> = if remote_environment {
let Some(environment) = runtime_environment.environment() else {
return Err(StartupOutcomeError::from(anyhow!(
"remote MCP server requires a runtime environment"
)));
};
environment.get_http_client()
} else {
Arc::new(ReqwestHttpClient)
};
let http_client = resolved_environment.as_ref().map_or_else(
|| Arc::new(ReqwestHttpClient) as Arc<dyn HttpClient>,
|environment| environment.get_http_client(),
);
let resolved_bearer_token =
match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) {
Ok(token) => token,
+164 -133
View File
@@ -10,6 +10,7 @@ use std::sync::Arc;
use std::time::Duration;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SandboxPolicy;
@@ -28,83 +29,87 @@ pub struct SandboxState {
pub use_legacy_landlock: bool,
}
/// Runtime placement information used when starting MCP server transports.
/// Runtime context used when resolving per-server MCP environments.
///
/// `McpConfig` describes what servers exist. This value describes which
/// selected/default environment MCP servers should use for the current caller.
/// Keep it explicit at manager construction time so status/snapshot paths and
/// real sessions make the same placement decision. `fallback_cwd` is not a
/// per-server override; it is used when a stdio server omits `cwd` and the
/// launcher needs a concrete process working directory. `local_environment`
/// is separate because a remote selected/default environment can coexist with
/// an explicitly configured local environment that may launch local stdio MCPs.
/// `McpConfig` describes what servers exist. This value carries the canonical
/// environment registry plus the local stdio fallback cwd used when a local
/// stdio server omits its own working directory.
#[derive(Clone)]
pub struct McpRuntimeEnvironment {
environment: Option<Arc<Environment>>,
local_environment: Option<Arc<Environment>>,
fallback_cwd: PathBuf,
pub struct McpRuntimeContext {
environment_manager: Arc<EnvironmentManager>,
local_stdio_fallback_cwd: PathBuf,
}
impl McpRuntimeEnvironment {
impl McpRuntimeContext {
pub fn new(
environment: Option<Arc<Environment>>,
local_environment: Option<Arc<Environment>>,
fallback_cwd: PathBuf,
environment_manager: Arc<EnvironmentManager>,
local_stdio_fallback_cwd: PathBuf,
) -> Self {
Self {
environment,
local_environment,
fallback_cwd,
environment_manager,
local_stdio_fallback_cwd,
}
}
pub(crate) fn environment(&self) -> Option<Arc<Environment>> {
self.environment.as_ref().map(Arc::clone)
pub(crate) fn local_stdio_fallback_cwd(&self) -> PathBuf {
self.local_stdio_fallback_cwd.clone()
}
pub(crate) fn fallback_cwd(&self) -> PathBuf {
self.fallback_cwd.clone()
}
pub(crate) fn startup_unavailable_reason(
pub(crate) fn resolve_server_environment(
&self,
server_name: &str,
config: &codex_config::McpServerConfig,
) -> Option<String> {
// This is intentionally narrower than "no env means no MCP": local
// stdio needs a local process launcher, while local HTTP can still use
// the ambient HTTP client with no local environment configured.
match config.experimental_environment.as_deref() {
None | Some("local") => {
// Local stdio only needs an explicitly configured local
// launcher. The selected/default MCP environment can be remote
// when both local and remote environments are configured.
if self.local_environment.is_none()
&& matches!(
config.transport,
codex_config::McpServerTransportConfig::Stdio { .. }
)
{
Some(format!(
"local stdio MCP server `{server_name}` requires a local environment"
))
} else {
None
}
) -> Result<Option<Arc<Environment>>, String> {
// Resolve `"local"` through the shared registry when available. Local
// HTTP is the one current exception: it can use the ambient HTTP client
// even when no local Environment is configured.
if let Some(environment) = self
.environment_manager
.get_environment(&config.environment_id)
{
if !config.is_local_environment() {
ensure_remote_stdio_cwd(server_name, config)?;
}
Some("remote") => match self.environment.as_ref() {
Some(environment) if environment.is_remote() => None,
_ => Some(format!(
"remote MCP server `{server_name}` requires a remote environment"
)),
},
Some(environment) => Some(format!(
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
)),
return Ok(Some(environment));
}
if config.is_local_environment() {
return match config.transport {
codex_config::McpServerTransportConfig::Stdio { .. } => Err(format!(
"local stdio MCP server `{server_name}` requires a local environment"
)),
codex_config::McpServerTransportConfig::StreamableHttp { .. } => Ok(None),
};
}
Err(format!(
"MCP server `{server_name}` references unknown environment id `{}`",
config.environment_id
))
}
}
fn ensure_remote_stdio_cwd(
server_name: &str,
config: &codex_config::McpServerConfig,
) -> Result<(), String> {
let codex_config::McpServerTransportConfig::Stdio { cwd, .. } = &config.transport else {
return Ok(());
};
let Some(cwd) = cwd else {
return Err(format!(
"remote stdio MCP server `{server_name}` requires an absolute cwd"
));
};
if cwd.is_absolute() {
return Ok(());
}
Err(format!(
"remote stdio MCP server `{server_name}` requires an absolute cwd, got `{}`",
cwd.display()
))
}
pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) {
if let Some(metrics) = codex_otel::global() {
let _ = metrics.record_duration(metric, duration, tags);
@@ -115,13 +120,15 @@ pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &st
mod tests {
use std::collections::HashMap;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
use codex_config::McpServerConfig;
use codex_config::McpServerTransportConfig;
use codex_exec_server::EnvironmentManager;
use pretty_assertions::assert_eq;
use super::*;
fn stdio_server(experimental_environment: Option<&str>) -> McpServerConfig {
fn stdio_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "echo".to_string(),
@@ -130,7 +137,7 @@ mod tests {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: experimental_environment.map(str::to_string),
environment_id: environment_id.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -147,7 +154,7 @@ mod tests {
}
}
fn http_server(experimental_environment: Option<&str>) -> McpServerConfig {
fn http_server(environment_id: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "http://127.0.0.1:1".to_string(),
@@ -155,111 +162,135 @@ mod tests {
http_headers: None,
env_http_headers: None,
},
experimental_environment: experimental_environment.map(str::to_string),
..stdio_server(/*experimental_environment*/ None)
environment_id: environment_id.to_string(),
..stdio_server(environment_id)
}
}
#[test]
fn local_stdio_requires_local_stdio_availability() {
let runtime_environment = McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let error = match runtime_context
.resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(_) => panic!("local stdio MCP should require a local environment"),
Err(error) => error,
};
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ None)
),
Some("local stdio MCP server `stdio` requires a local environment".to_string())
error,
"local stdio MCP server `stdio` requires a local environment"
);
}
#[test]
fn local_http_does_not_require_local_stdio_availability() {
let runtime_environment = McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"http",
&http_server(/*experimental_environment*/ None)
),
None
);
let resolved_runtime = match runtime_context
.resolve_server_environment("http", &http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("local HTTP MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_none());
}
#[test]
fn remote_stdio_requires_remote_environment() {
let runtime_environment = McpRuntimeEnvironment::new(
/*environment*/ None,
/*local_environment*/ None,
fn unknown_explicit_environment_is_rejected() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::without_environments()),
PathBuf::from("/tmp"),
);
let error =
match runtime_context.resolve_server_environment("stdio", &stdio_server("remote")) {
Ok(_) => panic!("unknown MCP environment should fail"),
Err(error) => error,
};
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ Some("remote")),
),
Some("remote MCP server `stdio` requires a remote environment".to_string())
);
}
#[test]
fn remote_stdio_and_http_accept_remote_environment() {
let environment = Arc::new(
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
);
let runtime_environment = McpRuntimeEnvironment::new(
Some(environment),
/*local_environment*/ None,
PathBuf::from("/tmp"),
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ Some("remote")),
),
None
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"http",
&http_server(/*experimental_environment*/ Some("remote")),
),
None
error,
"MCP server `stdio` references unknown environment id `remote`"
);
}
#[tokio::test]
async fn local_stdio_accepts_remote_runtime_when_local_environment_exists() {
let remote_environment = Arc::new(
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
);
let local_environment = Arc::new(
Environment::create_for_tests(/*exec_server_url*/ None).expect("local environment"),
);
let runtime_environment = McpRuntimeEnvironment::new(
Some(remote_environment),
Some(local_environment),
async fn explicit_remote_stdio_and_http_accept_named_environment() {
let runtime_context = McpRuntimeContext::new(
Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
/*local_runtime_paths*/ None,
)
.await,
),
PathBuf::from("/tmp"),
);
assert_eq!(
runtime_environment.startup_unavailable_reason(
"stdio",
&stdio_server(/*experimental_environment*/ None),
let mut remote_stdio = stdio_server("remote");
let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
unreachable!("stdio helper should build stdio transport");
};
*cwd = Some(std::env::temp_dir());
for resolved_runtime in [
runtime_context.resolve_server_environment("stdio", &remote_stdio),
runtime_context.resolve_server_environment("http", &http_server("remote")),
] {
let resolved_runtime = match resolved_runtime {
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("remote MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
}
#[tokio::test]
async fn local_stdio_accepts_local_environment_when_available() {
let runtime_context = McpRuntimeContext::new(
Arc::new(EnvironmentManager::default_for_tests()),
PathBuf::from("/tmp"),
);
let resolved_runtime = match runtime_context
.resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID))
{
Ok(resolved_runtime) => resolved_runtime,
Err(error) => panic!("local stdio MCP should resolve: {error}"),
};
assert!(resolved_runtime.is_some());
}
#[tokio::test]
async fn remote_stdio_requires_absolute_cwd() {
let runtime_context = McpRuntimeContext::new(
Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
/*local_runtime_paths*/ None,
)
.await,
),
None
PathBuf::from("/tmp"),
);
let mut remote_stdio = stdio_server("remote");
let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else {
unreachable!("stdio helper should build stdio transport");
};
*cwd = Some(PathBuf::from("relative"));
let error = match runtime_context.resolve_server_environment("stdio", &remote_stdio) {
Ok(_) => panic!("remote stdio MCP should require absolute cwd"),
Err(error) => error,
};
assert_eq!(
error,
"remote stdio MCP server `stdio` requires an absolute cwd, got `relative`"
);
}
}
+1
View File
@@ -94,6 +94,7 @@ pub use marketplace_edit::remove_user_marketplace_config;
pub use mcp_edit::ConfigEditsBuilder;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_types::AppToolApproval;
pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
pub use mcp_types::McpServerConfig;
pub use mcp_types::McpServerDisabledReason;
pub use mcp_types::McpServerEnvVar;
+1 -1
View File
@@ -235,7 +235,7 @@ pub async fn load_config_layers_state(
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"--profile-v2 `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`",
"--profile `{active_user_profile}` cannot be used while {} contains legacy `[profiles.{active_user_profile}]` config; move those settings into {} or remove `[profiles.{active_user_profile}]`. See https://developers.openai.com/codex/config-advanced#profiles for more information.",
base_user_file.as_path().display(),
active_user_file.as_path().display()
),
+5 -1
View File
@@ -120,7 +120,7 @@ model = "gpt-work"
);
let message = err.to_string();
assert!(
message.contains("--profile-v2 `work` cannot be used"),
message.contains("--profile `work` cannot be used"),
"unexpected error message: {message}"
);
assert!(
@@ -131,6 +131,10 @@ model = "gpt-work"
message.contains("[profiles.work]"),
"unexpected error message: {message}"
);
assert!(
message.contains("https://developers.openai.com/codex/config-advanced#profiles"),
"unexpected error message: {message}"
);
}
#[tokio::test]
+2 -2
View File
@@ -175,8 +175,8 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
if !config.enabled {
entry["enabled"] = value(false);
}
if let Some(environment) = &config.experimental_environment {
entry["experimental_environment"] = value(environment.clone());
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
+2 -2
View File
@@ -23,7 +23,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: true,
@@ -101,7 +101,7 @@ async fn replace_mcp_servers_serializes_oauth_client_id() -> anyhow::Result<()>
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+40 -6
View File
@@ -13,6 +13,9 @@ use serde::de::Error as SerdeError;
use crate::RequirementSource;
/// Effective MCP environment id when config omits `environment_id`.
pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local";
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppToolApproval {
@@ -128,9 +131,8 @@ pub struct McpServerConfig {
#[serde(flatten)]
pub transport: McpServerTransportConfig,
/// Experimental environment selector for where Codex should start this MCP server.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub experimental_environment: Option<String>,
/// Effective environment id for where Codex should start this MCP server.
pub environment_id: String,
/// When `false`, Codex skips initializing this MCP server.
#[serde(default = "default_enabled")]
@@ -190,6 +192,10 @@ pub struct McpServerConfig {
}
impl McpServerConfig {
pub fn is_local_environment(&self) -> bool {
self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID
}
pub fn oauth_client_id(&self) -> Option<&str> {
self.oauth
.as_ref()
@@ -231,7 +237,7 @@ pub struct RawMcpServerConfig {
// shared
#[serde(default)]
pub experimental_environment: Option<String>,
pub environment_id: Option<String>,
#[serde(default)]
pub startup_timeout_sec: Option<f64>,
#[serde(default)]
@@ -279,7 +285,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
url,
bearer_token,
bearer_token_env_var,
experimental_environment,
environment_id,
startup_timeout_sec,
startup_timeout_ms,
tool_timeout_sec,
@@ -350,9 +356,13 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
return Err("invalid transport".to_string());
};
let environment_id =
environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string());
validate_remote_stdio_cwd(&transport, &environment_id)?;
Ok(Self {
transport,
experimental_environment,
environment_id,
startup_timeout_sec,
tool_timeout_sec,
enabled: enabled.unwrap_or_else(default_enabled),
@@ -385,6 +395,30 @@ const fn default_enabled() -> bool {
true
}
fn validate_remote_stdio_cwd(
transport: &McpServerTransportConfig,
environment_id: &str,
) -> Result<(), String> {
if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID {
return Ok(());
}
let McpServerTransportConfig::Stdio { cwd, .. } = transport else {
return Ok(());
};
let Some(cwd) = cwd else {
return Err(format!(
"remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`"
));
};
if cwd.is_absolute() {
return Ok(());
}
Err(format!(
"remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`",
cwd.display()
))
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
#[serde(untagged, deny_unknown_fields, rename_all = "snake_case")]
pub enum McpServerTransportConfig {
+57 -1
View File
@@ -51,6 +51,62 @@ fn deserialize_stdio_command_server_config_with_args() {
assert!(cfg.enabled);
}
#[test]
fn deserialize_remote_stdio_server_requires_absolute_cwd() {
let missing_cwd = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
environment_id = "remote"
"#,
)
.expect_err("remote stdio MCP should require cwd");
assert!(
missing_cwd
.to_string()
.contains("remote stdio MCP servers require an absolute cwd"),
"unexpected error: {missing_cwd}"
);
let relative_cwd = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
environment_id = "remote"
cwd = "relative"
"#,
)
.expect_err("remote stdio MCP should require absolute cwd");
assert!(
relative_cwd.to_string().contains("got `relative`"),
"unexpected error: {relative_cwd}"
);
}
#[test]
fn deserialize_remote_stdio_server_accepts_absolute_cwd() {
let cwd = std::env::temp_dir();
let cfg: McpServerConfig = match toml::from_str(&format!(
r#"
command = "echo"
environment_id = "remote"
cwd = {cwd:?}
"#
)) {
Ok(cfg) => cfg,
Err(error) => panic!("remote stdio MCP should accept absolute cwd: {error}"),
};
assert_eq!(
cfg.transport,
McpServerTransportConfig::Stdio {
command: "echo".to_string(),
args: vec![],
env: None,
env_vars: Vec::new(),
cwd: Some(cwd),
}
);
}
#[test]
fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() {
let cfg: McpServerConfig = toml::from_str(
@@ -402,7 +458,7 @@ fn deserialize_ignores_unknown_server_fields() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+4 -4
View File
@@ -235,7 +235,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -738,7 +738,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -850,7 +850,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1012,7 +1012,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+1 -1
View File
@@ -2304,7 +2304,7 @@
},
"type": "array"
},
"experimental_environment": {
"environment_id": {
"default": null,
"type": "string"
},
+29 -12
View File
@@ -260,6 +260,7 @@ struct WebsocketSession {
connection: Option<ApiWebSocketConnection>,
last_request: Option<ResponsesApiRequest>,
last_response_rx: Option<oneshot::Receiver<LastResponse>>,
last_response_from_untraced_warmup: bool,
connection_reused: StdMutex<bool>,
}
@@ -941,6 +942,7 @@ impl ModelClientSession {
self.websocket_session.connection = None;
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
self.websocket_session.last_response_from_untraced_warmup = false;
self.websocket_session
.set_connection_reused(/*connection_reused*/ false);
}
@@ -1044,28 +1046,33 @@ impl ModelClientSession {
&mut self,
payload: ResponseCreateWsRequest,
request: &ResponsesApiRequest,
) -> ResponsesWsRequest {
) -> (ResponsesWsRequest, bool) {
let Some(last_response) = self.get_last_response() else {
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
};
let previous_response_id_from_untraced_warmup =
self.websocket_session.last_response_from_untraced_warmup;
let Some(incremental_items) = self.get_incremental_items(
request,
Some(&last_response),
/*allow_empty_delta*/ true,
) else {
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
};
if last_response.response_id.is_empty() {
trace!("incremental request failed, no previous response id");
return ResponsesWsRequest::ResponseCreate(payload);
return (ResponsesWsRequest::ResponseCreate(payload), false);
}
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some(last_response.response_id),
input: incremental_items,
..payload
})
(
ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest {
previous_response_id: Some(last_response.response_id),
input: incremental_items,
..payload
}),
previous_response_id_from_untraced_warmup,
)
}
/// Opportunistically preconnects a websocket for this turn-scoped client session.
@@ -1144,6 +1151,7 @@ impl ModelClientSession {
if needs_new {
self.websocket_session.last_request = None;
self.websocket_session.last_response_rx = None;
self.websocket_session.last_response_from_untraced_warmup = false;
let turn_state = options
.turn_state
.clone()
@@ -1412,8 +1420,8 @@ impl ModelClientSession {
Err(err) => return Err(map_api_error(err)),
}
let mut ws_request = self.prepare_websocket_request(ws_payload, &request);
self.websocket_session.last_request = Some(request);
let (mut ws_request, previous_response_id_from_untraced_warmup) =
self.prepare_websocket_request(ws_payload, &request);
let inference_trace_attempt = if warmup {
// Prewarm sends `generate=false`; it is connection setup, not a
// model inference attempt that should appear in rollout traces.
@@ -1422,7 +1430,16 @@ impl ModelClientSession {
inference_trace.start_attempt()
};
stamp_ws_stream_request_start_ms(&mut ws_request);
inference_trace_attempt.record_started(&ws_request);
if previous_response_id_from_untraced_warmup {
// The transport can reuse an untraced warmup response id and omit the
// already-sent input, but rollout replay needs the logical model-visible
// request rather than the compressed websocket delta.
inference_trace_attempt.record_started(&request);
} else {
inference_trace_attempt.record_started(&ws_request);
}
self.websocket_session.last_request = Some(request);
self.websocket_session.last_response_from_untraced_warmup = warmup;
let websocket_connection =
self.websocket_session.connection.as_ref().ok_or_else(|| {
map_api_error(ApiError::Stream(
+1 -1
View File
@@ -286,7 +286,7 @@ pub(crate) async fn process_compacted_history(
/// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary
/// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this
/// check.
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
match item {
ResponseItem::Message { role, .. } if role == "developer" => false,
ResponseItem::Message { role, .. } if role == "user" => {
+240 -6
View File
@@ -10,6 +10,7 @@ use crate::compact::compaction_status_from_result;
use crate::compact_remote::build_compact_request_log_data;
use crate::compact_remote::log_remote_compact_failure;
use crate::compact_remote::process_compacted_history;
use crate::compact_remote::should_keep_compacted_history_item;
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
use crate::hook_runtime::PostCompactHookOutcome;
use crate::hook_runtime::PreCompactHookOutcome;
@@ -27,17 +28,25 @@ use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TruncationPolicy;
use codex_protocol::protocol::TurnStartedEvent;
use codex_rollout_trace::CompactionCheckpointTracePayload;
use codex_rollout_trace::InferenceTraceContext;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::truncate_text;
use futures::StreamExt;
use futures::TryFutureExt;
use tokio_util::sync::CancellationToken;
use tracing::info;
// Mirror the current /responses/compact retained-message default while the
// server-side path remains the reference implementation.
const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000;
pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
@@ -343,11 +352,14 @@ fn build_v2_compacted_history(
prompt_input: &[ResponseItem],
compaction_output: ResponseItem,
) -> Vec<ResponseItem> {
let mut retained = prompt_input
let retained = prompt_input
.iter()
.filter(|item| is_retained_for_remote_compaction_v2(item))
.filter(|item| should_keep_compacted_history_item(item))
.cloned()
.collect::<Vec<_>>();
let mut retained =
truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET);
retained.push(compaction_output);
retained
}
@@ -360,6 +372,98 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
matches!(role.as_str(), "user" | "developer" | "system")
}
fn truncate_retained_messages_for_remote_compaction(
items: Vec<ResponseItem>,
max_tokens: usize,
) -> Vec<ResponseItem> {
let mut remaining = max_tokens;
let mut truncated_reversed = Vec::with_capacity(items.len());
for item in items.into_iter().rev() {
if remaining == 0 {
continue;
}
let token_count = message_text_token_count(&item).max(1);
if token_count <= remaining {
truncated_reversed.push(item);
remaining = remaining.saturating_sub(token_count);
} else if let Some(truncated_item) =
truncate_message_text_to_token_budget(item, /*max_tokens*/ remaining)
{
truncated_reversed.push(truncated_item);
remaining = 0;
}
}
truncated_reversed.reverse();
truncated_reversed
}
fn message_text_token_count(item: &ResponseItem) -> usize {
let ResponseItem::Message { content, .. } = item else {
return 0;
};
content
.iter()
.map(|item| match item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
approx_token_count(text)
}
ContentItem::InputImage { .. } => 0,
})
.sum()
}
fn truncate_message_text_to_token_budget(
item: ResponseItem,
max_tokens: usize,
) -> Option<ResponseItem> {
let ResponseItem::Message {
id,
role,
content,
phase,
} = item
else {
return Some(item);
};
let mut remaining = max_tokens;
let mut truncated_content = Vec::with_capacity(content.len());
for mut content_item in content {
match &mut content_item {
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
if remaining == 0 {
continue;
}
let token_count = approx_token_count(text);
if token_count <= remaining {
remaining = remaining.saturating_sub(token_count);
} else {
*text = truncate_text(text, TruncationPolicy::Tokens(remaining));
remaining = 0;
}
if !text.is_empty() {
truncated_content.push(content_item);
}
}
ContentItem::InputImage { .. } => truncated_content.push(content_item),
}
}
if truncated_content.is_empty() {
return None;
}
Some(ResponseItem::Message {
id,
role,
content: truncated_content,
phase,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -395,7 +499,7 @@ mod tests {
}
#[test]
fn build_v2_compacted_history_matches_prod_retention_shape() {
fn build_v2_compacted_history_filters_to_installed_retention_shape() {
let input = vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
@@ -421,15 +525,145 @@ mod tests {
assert_eq!(
history,
vec![message("user", "user", /*phase*/ None), output]
);
}
#[test]
fn build_v2_compacted_history_discards_messages_before_truncating() {
let old = message("user", "old", /*phase*/ None);
let new = message("user", "new", /*phase*/ None);
let huge_developer_message = "d".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4);
let huge_contextual_message = format!(
"<environment_context>\n{}\n</environment_context>",
"c".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4)
);
let input = vec![
old.clone(),
message("developer", &huge_developer_message, /*phase*/ None),
message("user", &huge_contextual_message, /*phase*/ None),
new.clone(),
];
let output = ResponseItem::Compaction {
encrypted_content: "new".to_string(),
};
let history = build_v2_compacted_history(&input, output.clone());
assert_eq!(history, vec![old, new, output]);
}
#[test]
fn retained_history_truncation_keeps_newest_messages_first() {
let middle = message("user", "middle1234", /*phase*/ None);
let new = message("user", "new", /*phase*/ None);
let retained = vec![
message("user", "old-old", /*phase*/ None),
middle,
new.clone(),
];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 3);
assert_eq!(
truncated,
vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
message("user", "user", /*phase*/ None),
output,
message("user", "midd…1 tokens truncated…1234", /*phase*/ None),
new,
]
);
}
#[test]
fn retained_history_truncation_preserves_images_and_truncates_later_text_parts() {
let item = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "abcdef".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::OutputText {
text: "uvwxyz".to_string(),
},
],
phase: None,
};
let truncated =
truncate_retained_messages_for_remote_compaction(vec![item], /*max_tokens*/ 3);
assert_eq!(
truncated,
vec![ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "abcdef".to_string(),
},
ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
},
ContentItem::OutputText {
text: "uv…1 tokens truncated…yz".to_string(),
},
],
phase: None,
}]
);
}
#[test]
fn retained_history_truncation_charges_image_only_messages() {
let image_only_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
}],
phase: None,
};
let newest = message("user", "new", /*phase*/ None);
let retained = vec![
message("user", "old", /*phase*/ None),
image_only_message.clone(),
newest.clone(),
];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 2);
assert_eq!(truncated, vec![image_only_message, newest]);
}
#[test]
fn retained_history_truncation_drops_image_only_messages_after_budget_is_spent() {
let image_only_message = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: "data:image/png;base64,abc".to_string(),
detail: None,
}],
phase: None,
};
let newest = message("user", "new", /*phase*/ None);
let retained = vec![image_only_message, newest.clone()];
let truncated =
truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 1);
assert_eq!(truncated, vec![newest]);
}
#[tokio::test]
async fn collect_compaction_output_accepts_additional_output_items() {
let compaction = ResponseItem::Compaction {
+20 -20
View File
@@ -132,7 +132,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -157,7 +157,7 @@ fn http_mcp(url: &str) -> McpServerConfig {
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5144,9 +5144,9 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
args: vec!["hello".to_string()],
env: None,
env_vars: Vec::new(),
cwd: None,
cwd: Some(codex_home.path().to_path_buf()),
},
experimental_environment: Some("remote".to_string()),
environment_id: "remote".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5184,13 +5184,13 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> {
assert_eq!(args, &vec!["hello".to_string()]);
assert!(env.is_none());
assert!(env_vars.is_empty());
assert!(cwd.is_none());
assert_eq!(cwd, &Some(codex_home.path().to_path_buf()));
}
other => panic!("unexpected transport {other:?}"),
}
assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3)));
assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5)));
assert_eq!(docs.experimental_environment.as_deref(), Some("remote"));
assert_eq!(docs.environment_id, "remote");
assert!(docs.enabled);
let empty = BTreeMap::new();
@@ -5493,7 +5493,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5570,7 +5570,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
env_vars: vec!["ALPHA".into(), "BETA".into()],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5632,7 +5632,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()>
],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5684,7 +5684,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: Some(cwd_path.clone()),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5739,7 +5739,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5810,7 +5810,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh
"DOCS_AUTH".to_string(),
)])),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5893,7 +5893,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
"DOCS_AUTH".to_string(),
)])),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -5929,7 +5929,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6000,7 +6000,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
"DOCS_AUTH".to_string(),
)])),
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6026,7 +6026,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers()
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6115,7 +6115,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -6166,7 +6166,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: true,
supports_parallel_tool_calls: false,
@@ -6217,7 +6217,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -6272,7 +6272,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+2 -2
View File
@@ -300,8 +300,8 @@ mod document_helpers {
if !config.enabled {
entry["enabled"] = value(false);
}
if let Some(environment) = &config.experimental_environment {
entry["experimental_environment"] = value(environment.clone());
if !config.is_local_environment() {
entry["environment_id"] = value(config.environment_id.clone());
}
if config.required {
entry["required"] = value(true);
+7 -7
View File
@@ -945,7 +945,7 @@ fn blocking_replace_mcp_servers_round_trips() {
env_vars: vec!["FOO".into()],
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: true,
@@ -975,7 +975,7 @@ fn blocking_replace_mcp_servers_round_trips() {
),
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -1047,7 +1047,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1112,7 +1112,7 @@ foo = { command = "cmd" }
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1167,7 +1167,7 @@ foo = { command = "cmd" } # keep me
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
@@ -1221,7 +1221,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -1276,7 +1276,7 @@ foo = { command = "cmd" }
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: false,
required: false,
supports_parallel_tool_calls: false,
+4 -8
View File
@@ -35,7 +35,7 @@ use codex_login::CodexAuth;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::ToolInfo;
use codex_mcp::ToolPluginProvenance;
use codex_mcp::codex_apps_tools_cache_key;
@@ -203,7 +203,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,
&environment_manager,
Arc::new(environment_manager),
)
.await
}
@@ -211,7 +211,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
config: &Config,
force_refetch: bool,
environment_manager: &EnvironmentManager,
environment_manager: Arc<EnvironmentManager>,
) -> anyhow::Result<AccessibleConnectorsStatus> {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await;
@@ -272,11 +272,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager(
PermissionProfile::default(),
// Connector discovery is threadless. Use an actually configured env if
// one exists, but do not reintroduce the old hidden-local fallback.
McpRuntimeEnvironment::new(
environment_manager.default_or_local_environment(),
environment_manager.try_local_environment(),
config.cwd.to_path_buf(),
),
McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()),
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
@@ -31,10 +31,7 @@ fn detects_subagent_notification_fragment_case_insensitively() {
#[test]
fn detects_goal_context_fragment() {
let text = GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
}
.render();
let text = GoalContext::new("Continue working toward the active thread goal.").render();
assert!(is_contextual_user_fragment(&ContentItem::InputText {
text
@@ -43,9 +40,9 @@ fn detects_goal_context_fragment() {
#[test]
fn contextual_user_fragment_is_dyn_compatible() {
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
});
let fragment: Box<dyn ContextualUserFragment> = Box::new(GoalContext::new(
"Continue working toward the active thread goal.",
));
assert_eq!(
fragment.render(),
+25 -2
View File
@@ -1,10 +1,33 @@
//! Hidden user-context fragment for runtime-owned goal steering prompts.
use super::ContextualUserFragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
/// Hidden runtime-owned goal steering context injected into model input.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct GoalContext {
pub(crate) prompt: String,
pub struct GoalContext {
prompt: String,
}
impl GoalContext {
/// Creates goal context around an already-rendered steering prompt.
pub fn new(prompt: impl Into<String>) -> Self {
Self {
prompt: prompt.into(),
}
}
/// Converts the registered fragment into an active-turn injectable item.
pub fn into_response_input_item(self) -> ResponseInputItem {
ResponseInputItem::Message {
role: <Self as ContextualUserFragment>::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}
}
impl ContextualUserFragment for GoalContext {
+1 -1
View File
@@ -40,7 +40,7 @@ pub(crate) use environment_context::EnvironmentContext;
pub use fragment::ContextualUserFragment;
pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use goal_context::GoalContext;
pub use goal_context::GoalContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
+1 -4
View File
@@ -322,10 +322,7 @@ fn goal_context_does_not_parse_as_visible_turn_item() {
id: Some("msg-1".to_string()),
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: GoalContext {
prompt: "Continue working toward the active thread goal.".to_string(),
}
.render(),
text: GoalContext::new("Continue working toward the active thread goal.").render(),
}],
phase: None,
};
+1 -10
View File
@@ -5,7 +5,6 @@
//! events, and owns helper hooks used by goal lifecycle behavior.
use crate::StateDbHandle;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::session::TurnInput;
use crate::session::session::Session;
@@ -26,7 +25,6 @@ use codex_otel::GOAL_TOKEN_COUNT_METRIC;
use codex_otel::GOAL_USAGE_LIMITED_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadGoal;
@@ -1632,14 +1630,7 @@ fn budget_limit_steering_item(goal: &ThreadGoal) -> ResponseInputItem {
}
fn goal_context_input_item(prompt: String) -> ResponseInputItem {
let context = GoalContext { prompt };
ResponseInputItem::Message {
role: GoalContext::role().to_string(),
content: vec![ContentItem::InputText {
text: context.render(),
}],
phase: None,
}
GoalContext::new(prompt).into_response_input_item()
}
pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {
+2 -2
View File
@@ -361,7 +361,7 @@ fn mcp_dependency_to_server_config(
http_headers: None,
env_http_headers: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
@@ -391,7 +391,7 @@ fn mcp_dependency_to_server_config(
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+4 -13
View File
@@ -1255,11 +1255,6 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata {
async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) {
let auth = session.services.auth_manager.auth().await;
let environment = session
.services
.environment_manager
.default_or_local_environment()
.expect("test session should have an MCP runtime environment");
let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new(
&HashMap::new(),
turn_context.config.mcp_oauth_credentials_store_mode,
@@ -1268,14 +1263,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context:
turn_context.sub_id.clone(),
session.get_tx_event(),
turn_context.permission_profile(),
codex_mcp::McpRuntimeEnvironment::new(
Some(environment),
session.services.environment_manager.try_local_environment(),
{
#[allow(deprecated)]
turn_context.cwd.to_path_buf()
},
),
codex_mcp::McpRuntimeContext::new(Arc::clone(&session.services.environment_manager), {
#[allow(deprecated)]
turn_context.cwd.to_path_buf()
}),
turn_context.config.codex_home.to_path_buf(),
codex_mcp::codex_apps_tools_cache_key(auth.as_ref()),
/*host_owned_codex_apps_enabled*/ true,
+6 -10
View File
@@ -325,17 +325,13 @@ impl Session {
host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
let auth_statuses =
compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await;
let mcp_runtime_environment = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeEnvironment::new(
Some(Arc::clone(&turn_environment.environment)),
self.services.environment_manager.try_local_environment(),
let mcp_runtime_context = match turn_context.environments.primary() {
Some(turn_environment) => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
self.services
.environment_manager
.default_or_local_environment(),
self.services.environment_manager.try_local_environment(),
None => McpRuntimeContext::new(
Arc::clone(&self.services.environment_manager),
#[allow(deprecated)]
turn_context.cwd.to_path_buf(),
),
@@ -353,7 +349,7 @@ impl Session {
turn_context.sub_id.clone(),
self.get_tx_event(),
turn_context.permission_profile(),
mcp_runtime_environment,
mcp_runtime_context,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth.as_ref()),
host_owned_codex_apps_enabled,
+1 -1
View File
@@ -61,7 +61,7 @@ use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_mcp::McpConnectionManager;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpRuntimeContext;
use codex_mcp::codex_apps_tools_cache_key;
use codex_models_manager::manager::RefreshStrategy;
use codex_models_manager::manager::SharedModelsManager;
+6 -8
View File
@@ -1135,15 +1135,13 @@ impl Session {
})?
.primary()
.cloned();
let mcp_runtime_environment = match turn_environment {
Some(turn_environment) => McpRuntimeEnvironment::new(
Some(Arc::clone(&turn_environment.environment)),
sess.services.environment_manager.try_local_environment(),
let mcp_runtime_context = match turn_environment {
Some(turn_environment) => McpRuntimeContext::new(
Arc::clone(&sess.services.environment_manager),
turn_environment.cwd.to_path_buf(),
),
None => McpRuntimeEnvironment::new(
sess.services.environment_manager.default_or_local_environment(),
sess.services.environment_manager.try_local_environment(),
None => McpRuntimeContext::new(
Arc::clone(&sess.services.environment_manager),
session_configuration.cwd.to_path_buf(),
),
};
@@ -1155,7 +1153,7 @@ impl Session {
INITIAL_SUBMIT_ID.to_owned(),
tx_event.clone(),
session_configuration.permission_profile(),
mcp_runtime_environment,
mcp_runtime_context,
config.codex_home.to_path_buf(),
codex_apps_tools_cache_key(auth),
host_owned_codex_apps_enabled,
@@ -94,8 +94,8 @@ impl ToolExecutor<ToolInvocation> for CodeModeExecuteHandler {
ToolName::plain(PUBLIC_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(self.spec.clone())
fn spec(&self) -> ToolSpec {
self.spec.clone()
}
async fn handle(
@@ -47,8 +47,8 @@ impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
ToolName::plain(WAIT_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_wait_tool())
fn spec(&self) -> ToolSpec {
create_wait_tool()
}
async fn handle(
@@ -19,8 +19,8 @@ impl ToolExecutor<ToolInvocation> for ReportAgentJobResultHandler {
ToolName::plain("report_agent_job_result")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_report_agent_job_result_tool())
fn spec(&self) -> ToolSpec {
create_report_agent_job_result_tool()
}
async fn handle(
@@ -20,8 +20,8 @@ impl ToolExecutor<ToolInvocation> for SpawnAgentsOnCsvHandler {
ToolName::plain("spawn_agents_on_csv")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_spawn_agents_on_csv_tool())
fn spec(&self) -> ToolSpec {
create_spawn_agents_on_csv_tool()
}
async fn handle(
@@ -303,8 +303,8 @@ impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
ToolName::plain("apply_patch")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_apply_patch_freeform_tool(self.multi_environment))
fn spec(&self) -> ToolSpec {
create_apply_patch_freeform_tool(self.multi_environment)
}
async fn handle(
+4 -4
View File
@@ -31,7 +31,7 @@ use tracing::warn;
pub struct DynamicToolHandler {
tool_name: ToolName,
spec: Option<ToolSpec>,
spec: ToolSpec,
exposure: ToolExposure,
search_text: String,
}
@@ -50,7 +50,7 @@ impl DynamicToolHandler {
};
Some(Self {
tool_name,
spec: Some(spec),
spec,
exposure: if tool.defer_loading {
ToolExposure::Deferred
} else {
@@ -67,7 +67,7 @@ impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
self.tool_name.clone()
}
fn spec(&self) -> Option<ToolSpec> {
fn spec(&self) -> ToolSpec {
self.spec.clone()
}
@@ -130,7 +130,7 @@ impl CoreToolRuntime for DynamicToolHandler {
fn search_info(&self) -> Option<ToolSearchInfo> {
ToolSearchInfo::from_spec(
self.search_text.clone(),
self.spec()?,
self.spec(),
Some(ToolSearchSourceInfo {
name: "Dynamic tools".to_string(),
description: Some("Tools provided by the current Codex thread.".to_string()),
@@ -37,7 +37,7 @@ impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
self.0.tool_name()
}
fn spec(&self) -> Option<ToolSpec> {
fn spec(&self) -> ToolSpec {
self.0.spec()
}
@@ -130,25 +130,23 @@ mod tests {
codex_tools::ToolName::plain("extension_echo")
}
fn spec(&self) -> Option<codex_tools::ToolSpec> {
Some(codex_tools::ToolSpec::Function(
codex_tools::ResponsesApiTool {
name: "extension_echo".to_string(),
description: "Echoes arguments.".to_string(),
strict: true,
parameters: codex_tools::parse_tool_input_schema(&json!({
"type": "object",
"properties": {
"message": { "type": "string" },
},
"required": ["message"],
"additionalProperties": false,
}))
.expect("extension schema should parse"),
output_schema: None,
defer_loading: None,
},
))
fn spec(&self) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: "extension_echo".to_string(),
description: "Echoes arguments.".to_string(),
strict: true,
parameters: codex_tools::parse_tool_input_schema(&json!({
"type": "object",
"properties": {
"message": { "type": "string" },
},
"required": ["message"],
"additionalProperties": false,
}))
.expect("extension schema should parse"),
output_schema: None,
defer_loading: None,
})
}
async fn handle(
@@ -171,6 +169,17 @@ mod tests {
codex_tools::ToolName::plain("extension_echo")
}
fn spec(&self) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: "extension_echo".to_string(),
description: "Captures arguments.".to_string(),
strict: false,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
defer_loading: None,
})
}
async fn handle(
&self,
call: codex_tools::ToolCall,
@@ -24,8 +24,8 @@ impl ToolExecutor<ToolInvocation> for CreateGoalHandler {
ToolName::plain(CREATE_GOAL_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_create_goal_tool())
fn spec(&self) -> ToolSpec {
create_create_goal_tool()
}
async fn handle(
@@ -21,8 +21,8 @@ impl ToolExecutor<ToolInvocation> for GetGoalHandler {
ToolName::plain(GET_GOAL_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_get_goal_tool())
fn spec(&self) -> ToolSpec {
create_get_goal_tool()
}
async fn handle(
@@ -26,8 +26,8 @@ impl ToolExecutor<ToolInvocation> for UpdateGoalHandler {
ToolName::plain(UPDATE_GOAL_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_update_goal_tool())
fn spec(&self) -> ToolSpec {
create_update_goal_tool()
}
async fn handle(
@@ -60,8 +60,8 @@ impl ToolExecutor<ToolInvocation> for ListAvailablePluginsToInstallHandler {
ToolName::plain(LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_list_available_plugins_to_install_tool())
fn spec(&self) -> ToolSpec {
create_list_available_plugins_to_install_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
+41 -46
View File
@@ -14,7 +14,6 @@ use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolExposure;
use crate::tools::registry::ToolTelemetryTags;
use crate::tools::tool_search_entry::ToolSearchInfo;
use codex_mcp::ToolInfo;
@@ -29,19 +28,13 @@ use serde_json::Value;
pub struct McpHandler {
tool_info: ToolInfo,
exposure: ToolExposure,
spec: ToolSpec,
}
impl McpHandler {
pub fn new(tool_info: ToolInfo) -> Self {
Self::with_exposure(tool_info, ToolExposure::Direct)
}
pub fn with_exposure(tool_info: ToolInfo, exposure: ToolExposure) -> Self {
Self {
tool_info,
exposure,
}
pub fn new(tool_info: ToolInfo) -> Result<Self, serde_json::Error> {
let spec = create_tool_spec(&tool_info)?;
Ok(Self { tool_info, spec })
}
}
@@ -51,36 +44,8 @@ impl ToolExecutor<ToolInvocation> for McpHandler {
self.tool_info.canonical_tool_name()
}
fn spec(&self) -> Option<ToolSpec> {
let tool_name = self.tool_name();
let namespace_name = tool_name.namespace.as_ref()?;
let tool = mcp_tool_to_responses_api_tool(&tool_name, &self.tool_info.tool).ok()?;
let description = self
.tool_info
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.or_else(|| {
self.tool_info
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
})
.unwrap_or_default();
Some(ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace_name.clone(),
description,
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
}))
}
fn exposure(&self) -> ToolExposure {
self.exposure
fn spec(&self) -> ToolSpec {
self.spec.clone()
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -152,7 +117,7 @@ impl CoreToolRuntime for McpHandler {
ToolSearchInfo::from_spec(
build_mcp_search_text(&self.tool_info),
self.spec()?,
self.spec(),
source_info,
)
}
@@ -223,6 +188,32 @@ impl CoreToolRuntime for McpHandler {
}
}
fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error> {
let tool_name = tool_info.canonical_tool_name();
let tool = mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)?;
let description = tool_info
.namespace_description
.as_deref()
.map(str::trim)
.filter(|description| !description.is_empty())
.map(str::to_string)
.or_else(|| {
tool_info
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
})
.unwrap_or_default();
Ok(ToolSpec::Namespace(ResponsesApiNamespace {
name: tool_info.callable_namespace.clone(),
description,
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
}))
}
fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
if raw_arguments.trim().is_empty() {
return Value::Object(Map::new());
@@ -306,7 +297,8 @@ mod tests {
.to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("memory", "mcp__memory__", "create_entities"));
let handler = McpHandler::new(tool_info("memory", "mcp__memory__", "create_entities"))
.expect("MCP tool spec should build");
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
session: session.into(),
@@ -336,7 +328,8 @@ mod tests {
arguments: json!({ "message": "hello" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"));
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"))
.expect("MCP tool spec should build");
assert_eq!(
handler.pre_tool_use_payload(&ToolInvocation {
@@ -362,7 +355,8 @@ mod tests {
arguments: json!({ "message": "hello" }).to_string(),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"));
let handler = McpHandler::new(tool_info("foo", "mcp__foo__", "exec_command"))
.expect("MCP tool spec should build");
let invocation = handler
.with_updated_hook_input(
@@ -411,7 +405,8 @@ mod tests {
truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
};
let (session, turn) = make_session_and_context().await;
let handler = McpHandler::new(tool_info("filesystem", "mcp__filesystem__", "read_file"));
let handler = McpHandler::new(tool_info("filesystem", "mcp__filesystem__", "read_file"))
.expect("MCP tool spec should build");
let invocation = ToolInvocation {
session: session.into(),
turn: turn.into(),
@@ -32,8 +32,8 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
ToolName::plain("list_mcp_resource_templates")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_list_mcp_resource_templates_tool())
fn spec(&self) -> ToolSpec {
create_list_mcp_resource_templates_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -32,8 +32,8 @@ impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
ToolName::plain("list_mcp_resources")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_list_mcp_resources_tool())
fn spec(&self) -> ToolSpec {
create_list_mcp_resources_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -32,8 +32,8 @@ impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
ToolName::plain("read_mcp_resource")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_read_mcp_resource_tool())
fn spec(&self) -> ToolSpec {
create_read_mcp_resource_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -6,7 +6,7 @@ use serde_json::json;
#[test]
fn search_info_uses_mcp_tool_metadata_and_parameter_names() {
let handler = McpHandler::new(tool_info());
let handler = McpHandler::new(tool_info()).expect("MCP tool spec should build");
let search_info = handler.search_info().expect("MCP search info");
assert_eq!(
@@ -26,7 +26,7 @@ fn search_info_uses_mcp_tool_metadata_and_parameter_names() {
fn search_info_uses_connector_name_for_output_namespace_description() {
let mut tool_info = tool_info();
tool_info.namespace_description = None;
let handler = McpHandler::new(tool_info);
let handler = McpHandler::new(tool_info).expect("MCP tool spec should build");
let search_info = handler.search_info().expect("MCP search info");
let LoadableToolSpec::Namespace(namespace) = search_info.entry.output else {
@@ -11,8 +11,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "close_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_close_agent_tool_v1())
fn spec(&self) -> ToolSpec {
create_close_agent_tool_v1()
}
async fn handle(
@@ -110,7 +110,7 @@ impl CoreToolRuntime for Handler {
fn search_info(&self) -> Option<ToolSearchInfo> {
multi_agent_tool_search_info(
"close_agent close shutdown stop agent subagent thread status target",
self.spec()?,
self.spec(),
)
}
@@ -13,8 +13,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "resume_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_resume_agent_tool())
fn spec(&self) -> ToolSpec {
create_resume_agent_tool()
}
async fn handle(
@@ -138,7 +138,7 @@ impl CoreToolRuntime for Handler {
fn search_info(&self) -> Option<ToolSearchInfo> {
multi_agent_tool_search_info(
"resume_agent resume reopen closed agent subagent thread id target",
self.spec()?,
self.spec(),
)
}
@@ -12,8 +12,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "send_input")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_send_input_tool_v1())
fn spec(&self) -> ToolSpec {
create_send_input_tool_v1()
}
async fn handle(
@@ -94,7 +94,7 @@ impl CoreToolRuntime for Handler {
fn search_info(&self) -> Option<ToolSearchInfo> {
multi_agent_tool_search_info(
"send_input send message existing agent subagent follow up interrupt redirect queue target",
self.spec()?,
self.spec(),
)
}
@@ -28,8 +28,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_spawn_agent_tool_v1(self.options.clone()))
fn spec(&self) -> ToolSpec {
create_spawn_agent_tool_v1(self.options.clone())
}
async fn handle(
@@ -206,7 +206,7 @@ impl CoreToolRuntime for Handler {
fn search_info(&self) -> Option<ToolSearchInfo> {
multi_agent_tool_search_info(
"spawn_agent spawn agent subagent sub-agent delegate delegation parallel work worker explorer no-apps fork model reasoning",
self.spec()?,
self.spec(),
)
}
@@ -33,8 +33,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "wait_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_wait_agent_tool_v1(self.options))
fn spec(&self) -> ToolSpec {
create_wait_agent_tool_v1(self.options)
}
async fn handle(
@@ -206,7 +206,7 @@ impl CoreToolRuntime for Handler {
fn search_info(&self) -> Option<ToolSearchInfo> {
multi_agent_tool_search_info(
"wait_agent wait agent subagent status final result complete timeout targets",
self.spec()?,
self.spec(),
)
}
@@ -11,8 +11,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::plain("close_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_close_agent_tool_v2())
fn spec(&self) -> ToolSpec {
create_close_agent_tool_v2()
}
async fn handle(
@@ -13,8 +13,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::plain("followup_task")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_followup_task_tool())
fn spec(&self) -> ToolSpec {
create_followup_task_tool()
}
async fn handle(
@@ -11,8 +11,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::plain("list_agents")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_list_agents_tool())
fn spec(&self) -> ToolSpec {
create_list_agents_tool()
}
async fn handle(
@@ -13,8 +13,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::plain("send_message")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_send_message_tool())
fn spec(&self) -> ToolSpec {
create_send_message_tool()
}
async fn handle(
@@ -30,8 +30,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::plain("spawn_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_spawn_agent_tool_v2(self.options.clone()))
fn spec(&self) -> ToolSpec {
create_spawn_agent_tool_v2(self.options.clone())
}
async fn handle(
@@ -25,8 +25,8 @@ impl ToolExecutor<ToolInvocation> for Handler {
ToolName::plain("wait_agent")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_wait_agent_tool_v2(self.options))
fn spec(&self) -> ToolSpec {
create_wait_agent_tool_v2(self.options)
}
async fn handle(
+2 -2
View File
@@ -51,8 +51,8 @@ impl ToolExecutor<ToolInvocation> for PlanHandler {
ToolName::plain("update_plan")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_update_plan_tool())
fn spec(&self) -> ToolSpec {
create_update_plan_tool()
}
async fn handle(
@@ -22,10 +22,8 @@ impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
ToolName::plain("request_permissions")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_request_permissions_tool(
request_permissions_tool_description(),
))
fn spec(&self) -> ToolSpec {
create_request_permissions_tool(request_permissions_tool_description())
}
async fn handle(
@@ -45,8 +45,8 @@ impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_request_plugin_install_tool())
fn spec(&self) -> ToolSpec {
create_request_plugin_install_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -26,10 +26,8 @@ impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_request_user_input_tool(
request_user_input_tool_description(&self.available_modes),
))
fn spec(&self) -> ToolSpec {
create_request_user_input_tool(request_user_input_tool_description(&self.available_modes))
}
async fn handle(
@@ -39,7 +39,7 @@ enum ShellCommandBackend {
pub struct ShellCommandHandler {
backend: ShellCommandBackend,
options: Option<ShellCommandHandlerOptions>,
options: ShellCommandHandlerOptions,
}
#[derive(Clone, Copy)]
@@ -51,10 +51,11 @@ pub(crate) struct ShellCommandHandlerOptions {
impl ShellCommandHandler {
pub(crate) fn new(options: ShellCommandHandlerOptions) -> Self {
Self {
options: Some(options),
..Self::from(options.backend_config)
}
let backend = match options.backend_config {
ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic,
ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork,
};
Self { backend, options }
}
fn shell_runtime_backend(&self) -> ShellRuntimeBackend {
@@ -114,15 +115,12 @@ impl ShellCommandHandler {
}
impl From<ShellCommandBackendConfig> for ShellCommandHandler {
fn from(config: ShellCommandBackendConfig) -> Self {
let backend = match config {
ShellCommandBackendConfig::Classic => ShellCommandBackend::Classic,
ShellCommandBackendConfig::ZshFork => ShellCommandBackend::ZshFork,
};
Self {
backend,
options: None,
}
fn from(backend_config: ShellCommandBackendConfig) -> Self {
Self::new(ShellCommandHandlerOptions {
backend_config,
allow_login_shell: false,
exec_permission_approvals_enabled: false,
})
}
}
@@ -132,17 +130,15 @@ impl ToolExecutor<ToolInvocation> for ShellCommandHandler {
ToolName::plain("shell_command")
}
fn spec(&self) -> Option<ToolSpec> {
self.options.map(|options| {
create_shell_command_tool(CommandToolOptions {
allow_login_shell: options.allow_login_shell,
exec_permission_approvals_enabled: options.exec_permission_approvals_enabled,
})
fn spec(&self) -> ToolSpec {
create_shell_command_tool(CommandToolOptions {
allow_login_shell: self.options.allow_login_shell,
exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
})
}
fn supports_parallel_tool_calls(&self) -> bool {
self.options.is_some()
true
}
async fn handle(
@@ -63,8 +63,8 @@ impl ToolExecutor<ToolInvocation> for TestSyncHandler {
ToolName::plain("test_sync_tool")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_test_sync_tool())
fn spec(&self) -> ToolSpec {
create_test_sync_tool()
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -59,11 +59,8 @@ impl ToolExecutor<ToolInvocation> for ToolSearchHandler {
ToolName::plain(TOOL_SEARCH_TOOL_NAME)
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_tool_search_tool(
&self.search_source_infos,
TOOL_SEARCH_DEFAULT_LIMIT,
))
fn spec(&self) -> ToolSpec {
create_tool_search_tool(&self.search_source_infos, TOOL_SEARCH_DEFAULT_LIMIT)
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -174,6 +171,7 @@ mod tests {
.iter()
.map(|tool| {
McpHandler::new(tool.clone())
.expect("MCP tool should convert")
.search_info()
.expect("MCP handler should return search info")
})
@@ -74,14 +74,14 @@ impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
ToolName::plain("exec_command")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_exec_command_tool_with_environment_id(
fn spec(&self) -> ToolSpec {
create_exec_command_tool_with_environment_id(
CommandToolOptions {
allow_login_shell: self.options.allow_login_shell,
exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
},
self.options.include_environment_id,
))
)
}
fn supports_parallel_tool_calls(&self) -> bool {
@@ -36,8 +36,8 @@ impl ToolExecutor<ToolInvocation> for WriteStdinHandler {
ToolName::plain("write_stdin")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_write_stdin_tool())
fn spec(&self) -> ToolSpec {
create_write_stdin_tool()
}
async fn handle(
@@ -70,8 +70,8 @@ impl ToolExecutor<ToolInvocation> for ViewImageHandler {
ToolName::plain("view_image")
}
fn spec(&self) -> Option<ToolSpec> {
Some(create_view_image_tool(self.options))
fn spec(&self) -> ToolSpec {
create_view_image_tool(self.options)
}
fn supports_parallel_tool_calls(&self) -> bool {
-1
View File
@@ -14,7 +14,6 @@ pub(crate) mod runtimes;
pub(crate) mod sandboxing;
pub(crate) mod spec_plan;
pub(crate) mod tool_dispatch_trace;
pub(crate) mod tool_family;
pub(crate) mod tool_search_entry;
use std::borrow::Cow;
+11
View File
@@ -250,6 +250,17 @@ mod tests {
self.tool_name.clone()
}
fn spec(&self) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: self.tool_name.name.clone(),
description: "Immediate test tool.".to_string(),
strict: false,
defer_loading: None,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
})
}
async fn handle(
&self,
_invocation: ToolInvocation,
+2 -2
View File
@@ -184,7 +184,7 @@ impl ToolExecutor<ToolInvocation> for ExposureOverride {
self.handler.tool_name()
}
fn spec(&self) -> Option<ToolSpec> {
fn spec(&self) -> ToolSpec {
self.handler.spec()
}
@@ -193,7 +193,7 @@ impl ToolExecutor<ToolInvocation> for ExposureOverride {
}
fn supports_parallel_tool_calls(&self) -> bool {
self.handler.supports_parallel_tool_calls()
self.exposure != ToolExposure::Hidden && self.handler.supports_parallel_tool_calls()
}
async fn handle(
+19
View File
@@ -11,6 +11,10 @@ impl ToolExecutor<ToolInvocation> for TestHandler {
self.tool_name.clone()
}
fn spec(&self) -> codex_tools::ToolSpec {
test_spec(&self.tool_name)
}
async fn handle(
&self,
_invocation: ToolInvocation,
@@ -40,6 +44,10 @@ impl ToolExecutor<ToolInvocation> for LifecycleTestHandler {
self.tool_name.clone()
}
fn spec(&self) -> codex_tools::ToolSpec {
test_spec(&self.tool_name)
}
async fn handle(
&self,
_invocation: ToolInvocation,
@@ -60,6 +68,17 @@ impl ToolExecutor<ToolInvocation> for LifecycleTestHandler {
impl CoreToolRuntime for LifecycleTestHandler {}
fn test_spec(tool_name: &codex_tools::ToolName) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: tool_name.name.clone(),
description: "Test tool.".to_string(),
strict: false,
defer_loading: None,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
})
}
#[derive(Debug, PartialEq, Eq)]
enum RecordedToolLifecycle {
Start {
+3 -3
View File
@@ -49,8 +49,8 @@ impl ToolExecutor<ExtensionToolCall> for ExtensionEchoExecutor {
ToolName::namespaced("extension/", "echo")
}
fn spec(&self) -> Option<ToolSpec> {
Some(ToolSpec::Namespace(ResponsesApiNamespace {
fn spec(&self) -> ToolSpec {
ToolSpec::Namespace(ResponsesApiNamespace {
name: "extension/".to_string(),
description: default_namespace_description("extension/"),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
@@ -69,7 +69,7 @@ impl ToolExecutor<ExtensionToolCall> for ExtensionEchoExecutor {
output_schema: None,
defer_loading: None,
})],
}))
})
}
async fn handle(
+148 -102
View File
@@ -6,6 +6,8 @@ use crate::tools::handlers::CodeModeExecuteHandler;
use crate::tools::handlers::CodeModeWaitHandler;
use crate::tools::handlers::CreateGoalHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::ExecCommandHandlerOptions;
use crate::tools::handlers::GetGoalHandler;
use crate::tools::handlers::ListAvailablePluginsToInstallHandler;
use crate::tools::handlers::ListMcpResourceTemplatesHandler;
@@ -16,10 +18,13 @@ use crate::tools::handlers::ReadMcpResourceHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellCommandHandlerOptions;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::UpdateGoalHandler;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::WriteStdinHandler;
use crate::tools::handlers::agent_jobs::ReportAgentJobResultHandler;
use crate::tools::handlers::agent_jobs::SpawnAgentsOnCsvHandler;
use crate::tools::handlers::extension_tools::ExtensionToolAdapter;
@@ -49,12 +54,11 @@ use crate::tools::registry::ToolRegistry;
use crate::tools::registry::override_tool_exposure;
use crate::tools::router::ToolRouter;
use crate::tools::router::ToolRouterParams;
use crate::tools::tool_family::shell::ShellToolsOptions;
use crate::tools::tool_family::shell::build_shell_tools;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
@@ -91,15 +95,34 @@ struct PlannedTools {
}
impl PlannedTools {
fn add_runtime<T>(&mut self, runtime: T)
fn add<T>(&mut self, handler: T)
where
T: CoreToolRuntime + 'static,
{
self.runtimes.push(Arc::new(runtime));
self.runtimes.push(Arc::new(handler));
}
fn add_runtime_arc(&mut self, runtime: PlannedRuntime) {
self.runtimes.push(runtime);
fn add_arc(&mut self, handler: PlannedRuntime) {
self.runtimes.push(handler);
}
fn add_with_exposure<T>(&mut self, handler: T, exposure: ToolExposure)
where
T: CoreToolRuntime + 'static,
{
self.runtimes
.push(override_tool_exposure(Arc::new(handler), exposure));
}
fn add_dispatch_only<T>(&mut self, handler: T)
where
T: CoreToolRuntime + 'static,
{
self.add_with_exposure(handler, ToolExposure::Hidden);
}
fn add_hosted_spec(&mut self, spec: ToolSpec) {
self.hosted_specs.push(spec);
}
fn runtimes(&self) -> &[PlannedRuntime] {
@@ -173,10 +196,9 @@ fn build_model_visible_specs_and_registry(
continue;
}
let exposure = runtime.exposure();
if exposure.is_direct()
&& !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure)
&& let Some(spec) = runtime.spec()
if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure)
{
let spec = runtime.spec();
specs.push(spec_for_model_request(turn_context, exposure, spec));
}
}
@@ -375,9 +397,10 @@ fn build_code_mode_executors(
continue;
}
let Some(spec) = executor.spec() else {
if exposure == ToolExposure::Hidden {
continue;
};
}
let spec = executor.spec();
if exposure != ToolExposure::Deferred {
exec_prompt_tool_specs.push(spec.clone());
@@ -481,30 +504,55 @@ fn add_tool_sources(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plann
add_mcp_runtime_tools(context, planned_tools);
add_dynamic_tools(context, planned_tools);
add_extension_tools(context, planned_tools);
planned_tools
.hosted_specs
.extend(hosted_model_tool_specs(context.turn_context));
for spec in hosted_model_tool_specs(context.turn_context) {
planned_tools.add_hosted_spec(spec);
}
}
fn add_shell_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
let turn_context = context.turn_context;
let features = turn_context.features.get();
planned_tools
.runtimes
.extend(build_shell_tools(ShellToolsOptions {
shell_type: shell_type_for_model_and_features(&turn_context.model_info, features),
shell_command_backend: shell_command_backend_for_features(features),
environment_mode: turn_context.tool_environment_mode(),
allow_login_shell: turn_context.config.permissions.allow_login_shell,
exec_permission_approvals_enabled: features.enabled(Feature::ExecPermissionApprovals),
}));
let environment_mode = turn_context.tool_environment_mode();
if !environment_mode.has_environment() {
return;
}
let allow_login_shell = turn_context.config.permissions.allow_login_shell;
let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals);
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
let shell_command_options = ShellCommandHandlerOptions {
backend_config: shell_command_backend_for_features(features),
allow_login_shell,
exec_permission_approvals_enabled,
};
match shell_type_for_model_and_features(&turn_context.model_info, features) {
ConfigShellToolType::UnifiedExec => {
planned_tools.add(ExecCommandHandler::new(ExecCommandHandlerOptions {
allow_login_shell,
exec_permission_approvals_enabled,
include_environment_id,
}));
planned_tools.add(WriteStdinHandler);
// Keep the legacy shell tool registered while unified exec is
// model-visible.
planned_tools.add_dispatch_only(ShellCommandHandler::new(shell_command_options));
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::Default
| ConfigShellToolType::Local
| ConfigShellToolType::ShellCommand => {
planned_tools.add(ShellCommandHandler::new(shell_command_options));
}
}
}
fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
if context.mcp_tools.is_some() {
planned_tools.add_runtime(ListMcpResourcesHandler);
planned_tools.add_runtime(ListMcpResourceTemplatesHandler);
planned_tools.add_runtime(ReadMcpResourceHandler);
planned_tools.add(ListMcpResourcesHandler);
planned_tools.add(ListMcpResourceTemplatesHandler);
planned_tools.add(ReadMcpResourceHandler);
}
}
@@ -513,35 +561,35 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
let features = turn_context.features.get();
let environment_mode = turn_context.tool_environment_mode();
planned_tools.add_runtime(PlanHandler);
planned_tools.add(PlanHandler);
if goal_tools_enabled(turn_context) {
planned_tools.add_runtime(GetGoalHandler);
planned_tools.add_runtime(CreateGoalHandler);
planned_tools.add_runtime(UpdateGoalHandler);
planned_tools.add(GetGoalHandler);
planned_tools.add(CreateGoalHandler);
planned_tools.add(UpdateGoalHandler);
}
planned_tools.add_runtime(RequestUserInputHandler {
planned_tools.add(RequestUserInputHandler {
available_modes: request_user_input_available_modes(features),
});
if features.enabled(Feature::RequestPermissionsTool) {
planned_tools.add_runtime(RequestPermissionsHandler);
planned_tools.add(RequestPermissionsHandler);
}
if tool_suggest_enabled(turn_context)
&& let Some(discoverable_tools) =
context.discoverable_tools.filter(|tools| !tools.is_empty())
{
planned_tools.add_runtime(ListAvailablePluginsToInstallHandler::new(
planned_tools.add(ListAvailablePluginsToInstallHandler::new(
collect_request_plugin_install_entries(discoverable_tools),
));
planned_tools.add_runtime(RequestPluginInstallHandler);
planned_tools.add(RequestPluginInstallHandler);
}
if environment_mode.has_environment() && turn_context.model_info.apply_patch_tool_type.is_some()
{
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
planned_tools.add_runtime(ApplyPatchHandler::new(include_environment_id));
planned_tools.add(ApplyPatchHandler::new(include_environment_id));
}
if turn_context
@@ -550,12 +598,12 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
.iter()
.any(|tool| tool == "test_sync_tool")
{
planned_tools.add_runtime(TestSyncHandler);
planned_tools.add(TestSyncHandler);
}
if environment_mode.has_environment() {
let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
planned_tools.add_runtime(ViewImageHandler::new(ViewImageToolOptions {
planned_tools.add(ViewImageHandler::new(ViewImageToolOptions {
can_request_original_image_detail: can_request_original_image_detail(
&turn_context.model_info,
),
@@ -578,47 +626,47 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
.flatten();
let agent_type_description =
agent_type_description(turn_context, context.default_agent_type_description);
planned_tools.add_runtime_arc(multi_agent_v2_handler(
SpawnAgentHandlerV2::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
agent_type_description,
hide_agent_type_model_reasoning: turn_context
.config
.multi_agent_v2
.hide_spawn_agent_metadata,
include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled,
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
max_concurrent_threads_per_session: max_concurrent_threads_per_session(
turn_context,
),
}),
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(
SpawnAgentHandlerV2::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
agent_type_description,
hide_agent_type_model_reasoning: turn_context
.config
.multi_agent_v2
.hide_spawn_agent_metadata,
include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled,
usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
max_concurrent_threads_per_session: max_concurrent_threads_per_session(
turn_context,
),
}),
tool_namespace,
),
exposure,
tool_namespace,
));
planned_tools.add_runtime_arc(multi_agent_v2_handler(
SendMessageHandlerV2,
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(SendMessageHandlerV2, tool_namespace),
exposure,
tool_namespace,
));
planned_tools.add_runtime_arc(multi_agent_v2_handler(
FollowupTaskHandlerV2,
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(FollowupTaskHandlerV2, tool_namespace),
exposure,
tool_namespace,
));
planned_tools.add_runtime_arc(multi_agent_v2_handler(
WaitAgentHandlerV2::new(context.wait_agent_timeouts),
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(
WaitAgentHandlerV2::new(context.wait_agent_timeouts),
tool_namespace,
),
exposure,
tool_namespace,
));
planned_tools.add_runtime_arc(multi_agent_v2_handler(
CloseAgentHandlerV2,
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(CloseAgentHandlerV2, tool_namespace),
exposure,
tool_namespace,
));
planned_tools.add_runtime_arc(multi_agent_v2_handler(
ListAgentsHandlerV2,
planned_tools.add_arc(override_tool_exposure(
multi_agent_v2_handler(ListAgentsHandlerV2, tool_namespace),
exposure,
tool_namespace,
));
} else {
let agent_type_description =
@@ -629,7 +677,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
} else {
ToolExposure::Direct
};
planned_tools.add_runtime_arc(multi_agent_v1_handler(
planned_tools.add_with_exposure(
SpawnAgentHandler::new(SpawnAgentToolOptions {
available_models: turn_context.available_models.clone(),
agent_type_description,
@@ -644,21 +692,19 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
),
}),
exposure,
));
planned_tools.add_runtime_arc(multi_agent_v1_handler(SendInputHandler, exposure));
planned_tools.add_runtime_arc(multi_agent_v1_handler(ResumeAgentHandler, exposure));
planned_tools.add_runtime_arc(multi_agent_v1_handler(
WaitAgentHandler::new(context.wait_agent_timeouts),
exposure,
));
planned_tools.add_runtime_arc(multi_agent_v1_handler(CloseAgentHandler, exposure));
);
planned_tools.add_with_exposure(SendInputHandler, exposure);
planned_tools.add_with_exposure(ResumeAgentHandler, exposure);
planned_tools
.add_with_exposure(WaitAgentHandler::new(context.wait_agent_timeouts), exposure);
planned_tools.add_with_exposure(CloseAgentHandler, exposure);
}
}
if agent_jobs_tools_enabled(turn_context) {
planned_tools.add_runtime(SpawnAgentsOnCsvHandler);
planned_tools.add(SpawnAgentsOnCsvHandler);
if agent_jobs_worker_tools_enabled(turn_context) {
planned_tools.add_runtime(ReportAgentJobResultHandler);
planned_tools.add(ReportAgentJobResultHandler);
}
}
}
@@ -666,16 +712,25 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu
fn add_mcp_runtime_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut PlannedTools) {
if let Some(mcp_tools) = context.mcp_tools {
for tool in mcp_tools {
planned_tools.add_runtime(McpHandler::new(tool.clone()));
match McpHandler::new(tool.clone()) {
Ok(handler) => planned_tools.add(handler),
Err(err) => warn!(
"Skipping MCP tool `{}`: failed to build tool spec: {err}",
tool.canonical_tool_name()
),
}
}
}
if let Some(deferred_mcp_tools) = context.deferred_mcp_tools {
for tool in deferred_mcp_tools {
planned_tools.add_runtime(McpHandler::with_exposure(
tool.clone(),
ToolExposure::Deferred,
));
match McpHandler::new(tool.clone()) {
Ok(handler) => planned_tools.add_with_exposure(handler, ToolExposure::Deferred),
Err(err) => warn!(
"Skipping deferred MCP tool `{}`: failed to build tool spec: {err}",
tool.canonical_tool_name()
),
}
}
}
}
@@ -690,7 +745,7 @@ fn add_dynamic_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut Plan
continue;
};
planned_tools.add_runtime(handler);
planned_tools.add(handler);
}
}
@@ -723,7 +778,7 @@ fn append_tool_search_executor(
return;
}
planned_tools.add_runtime(ToolSearchHandler::new(search_infos));
planned_tools.add(ToolSearchHandler::new(search_infos));
}
fn prepend_code_mode_executors(
@@ -778,30 +833,21 @@ fn append_extension_tool_executors(
warn!("Skipping extension tool `{tool_name}`: tool already registered");
continue;
}
planned_tools.add_runtime(ExtensionToolAdapter::new(executor));
planned_tools.add(ExtensionToolAdapter::new(executor));
}
}
fn multi_agent_v1_handler(
handler: impl CoreToolRuntime + 'static,
exposure: ToolExposure,
) -> Arc<dyn CoreToolRuntime> {
override_tool_exposure(Arc::new(handler), exposure)
}
fn multi_agent_v2_handler(
handler: impl CoreToolRuntime + 'static,
exposure: ToolExposure,
namespace: Option<&str>,
) -> Arc<dyn CoreToolRuntime> {
let handler: Arc<dyn CoreToolRuntime> = match namespace {
match namespace {
Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride {
handler: Arc::new(handler),
namespace: namespace.to_string(),
}),
None => Arc::new(handler),
};
override_tool_exposure(handler, exposure)
}
}
struct MultiAgentV2NamespaceOverride {
@@ -815,14 +861,14 @@ impl ToolExecutor<ToolInvocation> for MultiAgentV2NamespaceOverride {
ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name)
}
fn spec(&self) -> Option<ToolSpec> {
match self.handler.spec()? {
ToolSpec::Function(tool) => Some(ToolSpec::Namespace(ResponsesApiNamespace {
fn spec(&self) -> ToolSpec {
match self.handler.spec() {
ToolSpec::Function(tool) => ToolSpec::Namespace(ResponsesApiNamespace {
name: self.namespace.clone(),
description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(tool)],
})),
spec => Some(spec),
}),
spec => spec,
}
}
+28 -1
View File
@@ -288,6 +288,14 @@ fn mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
}
}
fn invalid_mcp_tool(server: &str, namespace: &str, name: &str) -> ToolInfo {
let mut tool = mcp_tool(server, namespace, name);
tool.tool.input_schema = Arc::new(rmcp::model::object(json!({
"type": "null",
})));
tool
}
fn dynamic_tool(namespace: Option<&str>, name: &str, defer_loading: bool) -> DynamicToolSpec {
DynamicToolSpec {
namespace: namespace.map(str::to_string),
@@ -342,7 +350,7 @@ async fn shell_family_registers_visible_unified_exec_and_hidden_legacy_shell() {
plan.assert_visible_contains(&["exec_command", "write_stdin"]);
plan.assert_visible_lacks(&["shell_command"]);
plan.assert_registered_contains(&["exec_command", "write_stdin", "shell_command"]);
assert_eq!(plan.exposure("shell_command"), ToolExposure::Direct);
assert_eq!(plan.exposure("shell_command"), ToolExposure::Hidden);
}
#[tokio::test]
@@ -507,6 +515,25 @@ async fn mcp_and_tool_search_follow_direct_and_deferred_tool_exposure() {
enabled.assert_registered_contains(&["tool_search", "mcp__searchable__lookup"]);
}
#[tokio::test]
async fn invalid_mcp_tools_are_not_registered() {
let plan = probe_with(
|_| {},
ToolPlanInputs {
mcp_tools: Some(vec![invalid_mcp_tool(
"invalid",
"mcp__invalid__",
"lookup",
)]),
..ToolPlanInputs::default()
},
)
.await;
plan.assert_visible_lacks(&["mcp__invalid__"]);
plan.assert_registered_lacks(&["mcp__invalid__lookup"]);
}
#[tokio::test]
async fn request_plugin_install_requires_all_discovery_features_and_discoverable_tools() {
let discoverable_tools = Some(vec![discoverable_plugin("github", "GitHub")]);
@@ -36,6 +36,17 @@ impl ToolExecutor<ToolInvocation> for TestHandler {
self.tool_name.clone()
}
fn spec(&self) -> codex_tools::ToolSpec {
codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
name: self.tool_name.name.clone(),
description: "Test tool.".to_string(),
strict: false,
defer_loading: None,
parameters: codex_tools::JsonSchema::default(),
output_schema: None,
})
}
async fn handle(
&self,
_invocation: ToolInvocation,
@@ -1 +0,0 @@
pub(crate) mod shell;
@@ -1,72 +0,0 @@
use std::sync::Arc;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_tools::ShellCommandBackendConfig;
use codex_tools::ToolEnvironmentMode;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::ExecCommandHandlerOptions;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellCommandHandlerOptions;
use crate::tools::handlers::WriteStdinHandler;
use crate::tools::registry::CoreToolRuntime;
#[derive(Clone, Copy, Debug)]
pub(crate) struct ShellToolsOptions {
pub(crate) shell_type: ConfigShellToolType,
pub(crate) shell_command_backend: ShellCommandBackendConfig,
pub(crate) environment_mode: ToolEnvironmentMode,
pub(crate) allow_login_shell: bool,
pub(crate) exec_permission_approvals_enabled: bool,
}
pub(crate) fn build_shell_tools(options: ShellToolsOptions) -> Vec<Arc<dyn CoreToolRuntime>> {
let mut runtimes = Vec::new();
if !options.environment_mode.has_environment() {
return runtimes;
}
let include_environment_id = matches!(options.environment_mode, ToolEnvironmentMode::Multiple);
match options.shell_type {
ConfigShellToolType::UnifiedExec => {
add_runtime(
&mut runtimes,
ExecCommandHandler::new(ExecCommandHandlerOptions {
allow_login_shell: options.allow_login_shell,
exec_permission_approvals_enabled: options.exec_permission_approvals_enabled,
include_environment_id,
}),
);
add_runtime(&mut runtimes, WriteStdinHandler);
// Keep the legacy shell tool registered as a hidden runtime while
// unified exec is model-visible.
add_runtime(
&mut runtimes,
ShellCommandHandler::from(options.shell_command_backend),
);
}
ConfigShellToolType::Disabled => {}
ConfigShellToolType::Default
| ConfigShellToolType::Local
| ConfigShellToolType::ShellCommand => {
add_runtime(
&mut runtimes,
ShellCommandHandler::new(ShellCommandHandlerOptions {
backend_config: options.shell_command_backend,
allow_login_shell: options.allow_login_shell,
exec_permission_approvals_enabled: options.exec_permission_approvals_enabled,
}),
);
}
}
runtimes
}
fn add_runtime<T>(runtimes: &mut Vec<Arc<dyn CoreToolRuntime>>, runtime: T)
where
T: CoreToolRuntime + 'static,
{
runtimes.push(Arc::new(runtime));
}
+5 -5
View File
@@ -196,9 +196,9 @@ async fn exec_cli_applies_model_instructions_file() {
);
}
/// Verify that `codex exec --profile ...` preserves the active profile when it
/// starts the in-process app-server thread, so profile-scoped
/// `model_instructions_file` is applied to the outbound request.
/// Verify that `codex exec --profile ...` preserves the active user config
/// profile when it starts the in-process app-server thread, so the selected
/// profile's `model_instructions_file` reaches the outbound request.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_cli_profile_applies_model_instructions_file() {
skip_if_no_network!();
@@ -223,8 +223,8 @@ async fn exec_cli_profile_applies_model_instructions_file() {
let home = TempDir::new().unwrap();
std::fs::write(
home.path().join("config.toml"),
format!("[profiles.default]\nmodel_instructions_file = \"{custom_path_str}\"\n",),
home.path().join("default.config.toml"),
format!("model_instructions_file = \"{custom_path_str}\"\n"),
)
.unwrap();
@@ -30,6 +30,11 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::UserInput;
use codex_rollout_trace::ConversationPart;
use codex_rollout_trace::InferenceTraceContext;
use codex_rollout_trace::RawTraceEventPayload;
use codex_rollout_trace::TraceWriter;
use codex_rollout_trace::replay_bundle;
use core_test_support::load_default_config_for_test;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::WebSocketTestServer;
@@ -535,6 +540,112 @@ async fn responses_websocket_request_prewarm_reuses_connection() {
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_request_prewarm_traces_logical_request() {
skip_if_no_network!();
let server = start_websocket_server(vec![vec![
vec![ev_response_created("warm-1"), ev_completed("warm-1")],
vec![ev_response_created("resp-1"), ev_completed("resp-1")],
]])
.await;
let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await;
let mut client_session = harness.client.new_session();
let prompt = prompt_with_input(vec![message_item("hello")]);
client_session
.prewarm_websocket(
&prompt,
&harness.model_info,
&harness.session_telemetry,
harness.effort,
harness.summary,
/*service_tier*/ None,
/*turn_metadata_header*/ None,
)
.await
.expect("websocket prewarm failed");
let trace_dir = TempDir::new().expect("trace dir");
let writer = Arc::new(
TraceWriter::create(
trace_dir.path(),
"trace-1".to_string(),
harness.session_id.to_string(),
harness.thread_id.to_string(),
)
.expect("trace writer"),
);
writer
.append(RawTraceEventPayload::ThreadStarted {
thread_id: harness.thread_id.to_string(),
agent_path: "/root".to_string(),
metadata_payload: None,
})
.expect("thread started");
writer
.append(RawTraceEventPayload::CodexTurnStarted {
codex_turn_id: "turn-1".to_string(),
thread_id: harness.thread_id.to_string(),
})
.expect("turn started");
let inference_trace = InferenceTraceContext::enabled(
writer,
harness.thread_id.to_string(),
"turn-1".to_string(),
harness.model_info.slug.clone(),
"test-provider".to_string(),
);
let mut stream = client_session
.stream(
&prompt,
&harness.model_info,
&harness.session_telemetry,
harness.effort,
harness.summary,
/*service_tier*/ None,
/*turn_metadata_header*/ None,
&inference_trace,
)
.await
.expect("websocket stream failed");
while let Some(event) = stream.next().await {
if matches!(event, Ok(ResponseEvent::Completed { .. })) {
break;
}
}
let connection = server.single_connection();
let follow_up = connection
.get(1)
.expect("missing follow-up request")
.body_json();
assert_eq!(follow_up["previous_response_id"].as_str(), Some("warm-1"));
assert_eq!(follow_up["input"], serde_json::json!([]));
let rollout = replay_bundle(trace_dir.path()).expect("replay trace");
let inference = rollout
.inference_calls
.values()
.next()
.expect("inference should be present");
assert_eq!(inference.request_item_ids.len(), 1);
assert_eq!(
rollout.conversation_items[&inference.request_item_ids[0]]
.body
.parts,
vec![ConversationPart::Text {
text: "hello".to_string(),
}],
);
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_websocket_reuses_connection_after_session_drop() {
skip_if_no_network!();
+1 -1
View File
@@ -242,7 +242,7 @@ async fn run_code_mode_turn_with_rmcp_config(
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+1 -1
View File
@@ -182,7 +182,7 @@ fn insert_rmcp_test_server(config: &mut Config, command: String, approval_mode:
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
+29 -71
View File
@@ -139,11 +139,13 @@ enum McpCallEvent {
const REMOTE_MCP_ENVIRONMENT: &str = "remote";
fn remote_aware_experimental_environment() -> Option<String> {
fn remote_aware_environment_id() -> String {
// These tests run locally in normal CI and against the Docker-backed
// executor in full-ci. Match that shared test environment instead of
// parameterizing each stdio MCP test with its own local/remote cases.
std::env::var_os(remote_env_env_var()).map(|_| REMOTE_MCP_ENVIRONMENT.to_string())
std::env::var_os(remote_env_env_var())
.map(|_| REMOTE_MCP_ENVIRONMENT.to_string())
.unwrap_or_else(|| codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string())
}
/// Returns the stdio MCP test server command path for the active test placement.
@@ -285,13 +287,22 @@ async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::
Ok(())
}
#[derive(Default)]
struct TestMcpServerOptions {
experimental_environment: Option<String>,
environment_id: String,
supports_parallel_tool_calls: bool,
tool_timeout_sec: Option<Duration>,
}
impl Default for TestMcpServerOptions {
fn default() -> Self {
Self {
environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
supports_parallel_tool_calls: false,
tool_timeout_sec: None,
}
}
}
fn stdio_transport(
command: String,
env: Option<HashMap<String, String>>,
@@ -326,7 +337,7 @@ fn insert_mcp_server(
server_name.to_string(),
McpServerConfig {
transport,
experimental_environment: options.experimental_environment,
environment_id: options.environment_id,
enabled: true,
required: false,
supports_parallel_tool_calls: options.supports_parallel_tool_calls,
@@ -480,7 +491,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
Vec::new(),
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -603,7 +614,7 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R
Some(configured_cwd),
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -623,59 +634,6 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_cwd)]
async fn remote_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> anyhow::Result<()>
{
skip_if_no_network!(Ok(()));
if std::env::var_os(remote_env_env_var()).is_none() {
return Ok(());
}
let server = responses::start_mock_server().await;
let server_name = "rmcp_fallback_cwd";
let expected_cwd = Arc::new(Mutex::new(None::<PathBuf>));
let expected_cwd_for_config = Arc::clone(&expected_cwd);
let rmcp_test_server_bin = remote_aware_stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
*expected_cwd_for_config
.lock()
.expect("expected cwd lock should not be poisoned") =
Some(config.cwd.to_path_buf());
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
"fallback-cwd".to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
..Default::default()
},
);
})
.build_with_remote_env(&server)
.await?;
let expected_cwd = expected_cwd
.lock()
.expect("expected cwd lock should not be poisoned")
.clone()
.expect("test config should record runtime fallback cwd");
let structured = call_cwd_tool(&server, &fixture, server_name, "call-fallback-cwd").await?;
assert_cwd_tool_output(&structured, &expected_cwd);
server.verify().await;
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_cwd)]
@@ -775,7 +733,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -872,7 +830,7 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
tool_timeout_sec: Some(Duration::from_secs(2)),
..Default::default()
},
@@ -989,7 +947,7 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
supports_parallel_tool_calls: true,
tool_timeout_sec: Some(Duration::from_secs(2)),
},
@@ -1071,7 +1029,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
Vec::new(),
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -1204,7 +1162,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -1340,7 +1298,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
Vec::new(),
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -1442,7 +1400,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
vec!["MCP_TEST_VALUE".into()],
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -1560,7 +1518,7 @@ async fn stdio_server_propagates_explicit_local_env_var_source() -> anyhow::Resu
}],
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -1652,7 +1610,7 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result
}],
),
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -1835,7 +1793,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
env_http_headers: None,
},
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
@@ -2021,7 +1979,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
env_http_headers: None,
},
TestMcpServerOptions {
experimental_environment: remote_aware_experimental_environment(),
environment_id: remote_aware_environment_id(),
..Default::default()
},
);
+3 -3
View File
@@ -1012,7 +1012,7 @@ async fn tool_search_indexes_only_enabled_non_app_mcp_tools() -> Result<()> {
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
disabled_reason: None,
@@ -1141,7 +1141,7 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result<
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
disabled_reason: None,
@@ -1288,7 +1288,7 @@ async fn tool_search_uses_non_app_mcp_server_instructions_as_namespace_descripti
env_vars: Vec::new(),
cwd: None,
},
experimental_environment: None,
environment_id: "local".to_string(),
enabled: true,
required: false,
disabled_reason: None,

Some files were not shown because too many files have changed in this diff Show More