Merge origin/main into abhinav/spawn-agent-hook-alias

This commit is contained in:
Abhinav Vedmala
2026-05-21 15:40:49 -07:00
299 changed files with 7664 additions and 2795 deletions
+5 -9
View File
@@ -26,6 +26,9 @@ jobs:
- name: Verify Bazel clippy flags match Cargo workspace lints
run: python3 .github/scripts/verify_bazel_clippy_lints.py
- name: Test Codex package builder
run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py'
- name: Setup pnpm
uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5
with:
@@ -39,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:
@@ -52,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 -6
View File
@@ -258,16 +258,12 @@ jobs:
stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}"
wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}"
# Keep the helpers next to codex.exe in the runtime wheel so Windows
# sandbox/elevation lookup matches the standalone release zip.
python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \
stage-runtime \
"$stage_dir" \
"${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \
"dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \
--codex-version "${GITHUB_REF_NAME}" \
--platform-tag "$platform_tag" \
--resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \
--resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe"
--platform-tag "$platform_tag"
"${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
- name: Upload Python runtime wheel
+16 -24
View File
@@ -569,18 +569,10 @@ jobs:
"${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py"
stage-runtime
"$stage_dir"
"${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex"
"dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz"
--codex-version "${GITHUB_REF_NAME}"
--platform-tag "$platform_tag"
)
if [[ "${{ matrix.target }}" == *linux* ]]; then
# Keep bwrap in the runtime wheel so Linux sandbox fallback behavior
# matches the standalone release bundle on hosts without system bwrap.
stage_runtime_args+=(
--resource-binary
"${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap"
)
fi
python3 "${stage_runtime_args[@]}"
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
@@ -800,6 +792,20 @@ jobs:
cp "$dmg_source" "$dest/$dmg_name"
fi
- name: Build Codex package archive
shell: bash
env:
TARGET: ${{ matrix.target }}
BUNDLE: ${{ matrix.bundle }}
run: |
set -euo pipefail
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "$TARGET" \
--bundle "$BUNDLE" \
--entrypoint-dir "dist/${TARGET}" \
--archive-dir "dist/${TARGET}" \
--target-suffixed-entrypoint
- name: Build Python runtime wheel
if: ${{ matrix.bundle == 'primary' }}
shell: bash
@@ -828,25 +834,11 @@ jobs:
"${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \
stage-runtime \
"$stage_dir" \
"${GITHUB_WORKSPACE}/codex-rs/dist/${{ matrix.target }}/codex-${{ matrix.target }}" \
"dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \
--codex-version "${GITHUB_REF_NAME}" \
--platform-tag "$platform_tag"
"${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir"
- name: Build Codex package archive
shell: bash
env:
TARGET: ${{ matrix.target }}
BUNDLE: ${{ matrix.bundle }}
run: |
set -euo pipefail
bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \
--target "$TARGET" \
--bundle "$BUNDLE" \
--entrypoint-dir "dist/${TARGET}" \
--archive-dir "dist/${TARGET}" \
--target-suffixed-entrypoint
- name: Upload Python runtime wheel
if: ${{ matrix.bundle == 'primary' }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
-1
View File
@@ -1799,7 +1799,6 @@
"winnow_0.7.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.11.4\"},{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.100\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"circular\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"is_terminal_polyfill\",\"optional\":true,\"req\":\"^1.48.1\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2.1.1\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"term-transcript\",\"req\":\"^0.2.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\",\"dep:is_terminal_polyfill\",\"dep:terminal_size\"],\"default\":[\"std\"],\"simd\":[\"dep:memchr\"],\"std\":[\"alloc\",\"memchr?/std\"],\"unstable-doc\":[\"alloc\",\"std\",\"simd\",\"unstable-recover\"],\"unstable-recover\":[]}}",
"winreg_0.10.1": "{\"dependencies\":[{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"impl-default\",\"impl-debug\",\"minwindef\",\"minwinbase\",\"timezoneapi\",\"winerror\",\"winnt\",\"winreg\",\"handleapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[\"winapi/ktmw32\"]}}",
"winreg_0.50.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Time\",\"Win32_System_Registry\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\"^0.48.0\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[]}}",
"winres_0.1.12": "{\"dependencies\":[{\"name\":\"toml\",\"req\":\"^0.5\"},{\"features\":[\"winnt\"],\"kind\":\"dev\",\"name\":\"winapi\",\"req\":\"^0.3\"}],\"features\":{}}",
"winsafe_0.0.19": "{\"dependencies\":[],\"features\":{\"comctl\":[\"ole\"],\"dshow\":[\"oleaut\"],\"dwm\":[\"uxtheme\"],\"dxgi\":[\"ole\"],\"gdi\":[\"user\"],\"gui\":[\"comctl\",\"shell\",\"uxtheme\"],\"kernel\":[],\"mf\":[\"oleaut\"],\"ole\":[\"user\"],\"oleaut\":[\"ole\"],\"shell\":[\"oleaut\"],\"taskschd\":[\"oleaut\"],\"user\":[\"kernel\"],\"uxtheme\":[\"gdi\",\"ole\"],\"version\":[\"kernel\"]}}",
"winsplit_0.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"wiremock_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.10.0\"},{\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"features\":[\"attributes\",\"tokio1\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.2\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"deadpool\",\"req\":\"^0.12.2\"},{\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"req\":\"^1.3\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"full\"],\"name\":\"hyper\",\"req\":\"^1.7\"},{\"features\":[\"tokio\",\"server\",\"http1\",\"http2\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.23\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"rt\",\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"name\":\"url\",\"req\":\"^2.5\"}],\"features\":{}}",
+2 -2
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,8 +11,7 @@
"node": ">=16"
},
"files": [
"bin",
"vendor"
"bin/codex.js"
],
"repository": {
"type": "git",
+5 -5
View File
@@ -11,13 +11,13 @@ example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0`
--package codex-sdk
```
This downloads the native package archive artifacts once, hydrates `vendor/` for each
package, and writes tarballs to `dist/npm/`.
This downloads the required native package archive artifacts, hydrates `vendor/` for
each package, and writes tarballs to `dist/npm/`.
When `--package codex` is provided, the staging helper builds the lightweight
`@openai/codex` meta package plus all platform-native `@openai/codex` variants
that are later published under platform-specific dist-tags.
If you need to invoke `build_npm_package.py` directly, run
`codex-cli/scripts/install_native_deps.py --component codex-package` first and pass
`--vendor-src` pointing to the directory that contains the populated `vendor/` tree.
Direct `build_npm_package.py` invocations are still useful for package-specific
debugging, but native packages expect `--vendor-src` to point at a prehydrated
`vendor/` tree. Release packaging should use `scripts/stage_npm_packages.py`.
+16 -82
View File
@@ -3,6 +3,7 @@
import argparse
import json
import os
import shutil
import subprocess
import sys
@@ -16,7 +17,6 @@ RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" /
CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript"
CODEX_NPM_NAME = "@openai/codex"
CODEX_PACKAGE_COMPONENT = "codex-package"
CODEX_PACKAGE_ENTRIES = ("codex-package.json", "bin", "codex-resources", "codex-path")
# `npm_name` is the local optional-dependency alias consumed by `bin/codex.js`.
# The underlying package published to npm is always `@openai/codex`.
@@ -88,16 +88,6 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = {
PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS)
COMPONENT_DEST_DIR: dict[str, str] = {
"bwrap": "codex-resources",
"codex": "codex",
"codex-responses-api-proxy": "codex-responses-api-proxy",
"codex-windows-sandbox-setup": "codex",
"codex-command-runner": "codex",
"rg": "path",
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.")
parser.add_argument(
@@ -140,16 +130,6 @@ def parse_args() -> argparse.Namespace:
type=Path,
help="Directory containing pre-installed native binaries to bundle (vendor root).",
)
parser.add_argument(
"--allow-missing-native-component",
dest="allow_missing_native_components",
action="append",
default=[],
help=(
"Native component that may be absent from --vendor-src. Intended for CI "
"compatibility with older artifact workflows; releases should not use this."
),
)
return parser.parse_args()
@@ -190,7 +170,6 @@ def main() -> int:
staging_dir,
native_components,
target_filter={target_filter} if target_filter else None,
allow_missing_components=set(args.allow_missing_native_components),
)
if release_version:
@@ -255,9 +234,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 +292,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}@"
@@ -349,7 +325,7 @@ def compute_platform_package_version(version: str, platform_tag: str) -> str:
def run_command(cmd: list[str], cwd: Path | None = None) -> None:
print("+", " ".join(cmd))
print("+", " ".join(cmd), flush=True)
subprocess.run(cmd, cwd=cwd, check=True)
@@ -379,18 +355,12 @@ def copy_native_binaries(
staging_dir: Path,
components: list[str],
target_filter: set[str] | None = None,
allow_missing_components: set[str] | None = None,
) -> None:
vendor_src = vendor_src.resolve()
if not vendor_src.exists():
raise RuntimeError(f"Vendor source directory not found: {vendor_src}")
components_set = {
component
for component in components
if component == CODEX_PACKAGE_COMPONENT or component in COMPONENT_DEST_DIR
}
allow_missing_components = allow_missing_components or set()
components_set = set(components)
if not components_set:
return
@@ -413,34 +383,20 @@ def copy_native_binaries(
dest_target_dir = vendor_dest / target_dir.name
if CODEX_PACKAGE_COMPONENT in components_set:
validate_codex_package_dir(target_dir)
if dest_target_dir.exists():
shutil.rmtree(dest_target_dir)
dest_target_dir.mkdir(parents=True, exist_ok=True)
for entry in CODEX_PACKAGE_ENTRIES:
src = target_dir / entry
dest = dest_target_dir / entry
if src.is_dir():
shutil.copytree(src, dest)
else:
shutil.copy2(src, dest)
shutil.copytree(target_dir, dest_target_dir)
else:
dest_target_dir.mkdir(parents=True, exist_ok=True)
for component in components_set - {CODEX_PACKAGE_COMPONENT}:
dest_dir_name = COMPONENT_DEST_DIR.get(component)
if dest_dir_name is None:
continue
src_component_dir = target_dir / dest_dir_name
for component in sorted(components_set - {CODEX_PACKAGE_COMPONENT}):
src_component_dir = target_dir / component
if not src_component_dir.exists():
if component in allow_missing_components:
continue
raise RuntimeError(
f"Missing native component '{component}' in vendor source: {src_component_dir}"
)
dest_component_dir = dest_target_dir / dest_dir_name
dest_component_dir = dest_target_dir / component
if dest_component_dir.exists():
shutil.rmtree(dest_component_dir)
shutil.copytree(src_component_dir, dest_component_dir)
@@ -451,45 +407,23 @@ def copy_native_binaries(
missing_list = ", ".join(missing_targets)
raise RuntimeError(f"Missing target directories in vendor source: {missing_list}")
def validate_codex_package_dir(package_dir: Path) -> None:
is_windows = "windows" in package_dir.name
required_files = [
Path("codex-package.json"),
Path("bin") / ("codex.exe" if is_windows else "codex"),
Path("codex-path") / ("rg.exe" if is_windows else "rg"),
]
if "linux" in package_dir.name:
required_files.append(Path("codex-resources") / "bwrap")
if is_windows:
required_files.extend(
[
Path("codex-resources") / "codex-command-runner.exe",
Path("codex-resources") / "codex-windows-sandbox-setup.exe",
]
)
missing_files = [
str(relative_path)
for relative_path in required_files
if not (package_dir / relative_path).is_file()
]
if missing_files:
missing = ", ".join(missing_files)
raise RuntimeError(f"Missing files in Codex package directory {package_dir}: {missing}")
def run_npm_pack(staging_dir: Path, output_path: Path) -> Path:
output_path = output_path.resolve()
output_path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str:
pack_dir = Path(pack_dir_str)
npm_cache_dir = pack_dir / "npm-cache"
npm_logs_dir = pack_dir / "npm-logs"
npm_cache_dir.mkdir()
npm_logs_dir.mkdir()
env = os.environ.copy()
env["NPM_CONFIG_CACHE"] = str(npm_cache_dir)
env["NPM_CONFIG_LOGS_DIR"] = str(npm_logs_dir)
stdout = subprocess.check_output(
["npm", "pack", "--json", "--pack-destination", str(pack_dir)],
cwd=staging_dir,
env=env,
text=True,
)
try:
-653
View File
@@ -1,653 +0,0 @@
#!/usr/bin/env python3
"""Install Codex package archives and native helper binaries."""
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
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
VENDOR_DIR_NAME = "vendor"
RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg"
BINARY_TARGETS = (
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-musl",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
"aarch64-pc-windows-msvc",
)
CODEX_PACKAGE_COMPONENT = "codex-package"
@dataclass(frozen=True)
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
# much easier to scan: groups collapse noisy sections and error annotations surface the
# failure in the UI without changing the actual exception/traceback output.
return os.environ.get("GITHUB_ACTIONS") == "true"
def _gha_escape(value: str) -> str:
# Workflow commands require percent/newline escaping.
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
def _gha_error(*, title: str, message: str) -> None:
# Emit a GitHub Actions error annotation. This does not replace stdout/stderr logs; it just
# adds a prominent summary line to the job UI so the root cause is easier to spot.
if not _gha_enabled():
return
print(
f"::error title={_gha_escape(title)}::{_gha_escape(message)}",
flush=True,
)
@contextmanager
def _gha_group(title: str):
# Wrap a block in a collapsible log group on GitHub Actions. Outside of GHA this is a no-op
# so local output remains unchanged.
if _gha_enabled():
print(f"::group::{_gha_escape(title)}", flush=True)
try:
yield
finally:
if _gha_enabled():
print("::endgroup::", flush=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Install native Codex binaries.")
parser.add_argument(
"--workflow-url",
help=(
"GitHub Actions workflow URL that produced the artifacts. Defaults to a "
"known good run when omitted."
),
)
parser.add_argument(
"--component",
dest="components",
action="append",
choices=tuple([CODEX_PACKAGE_COMPONENT, *BINARY_COMPONENTS, "rg"]),
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="?",
type=Path,
help=(
"Directory containing package.json for the staged package. If omitted, the "
"repository checkout is used."
),
)
return parser.parse_args()
def main() -> int:
args = parse_args()
codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve()
vendor_dir = codex_cli_root / VENDOR_DIR_NAME
vendor_dir.mkdir(parents=True, exist_ok=True)
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]
print(f"Downloading native artifacts from workflow {workflow_id}...")
with _gha_group(f"Download native artifacts from workflow {workflow_id}"):
with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str:
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_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
def install_codex_package_archives(
artifacts_dir: Path,
vendor_dir: Path,
targets: Sequence[str],
) -> None:
targets = list(targets)
if not targets:
return
print("Installing Codex package archives for targets: " + ", ".join(targets))
max_workers = min(len(targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_install_single_codex_package_archive,
artifacts_dir,
vendor_dir,
target,
): target
for target in targets
}
for future in as_completed(futures):
installed_path = future.result()
print(f" installed {installed_path}")
def _install_single_codex_package_archive(
artifacts_dir: Path,
vendor_dir: Path,
target: str,
) -> Path:
artifact_subdir = artifact_dir_for_target(artifacts_dir, target)
archive_path = artifact_subdir / f"codex-package-{target}.tar.gz"
if not archive_path.exists():
raise FileNotFoundError(f"Expected package archive not found: {archive_path}")
dest_dir = vendor_dir / target
if dest_dir.exists():
shutil.rmtree(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:gz") as archive:
archive.extractall(dest_dir, filter="data")
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",
"run",
"download",
"--dir",
str(dest_dir),
"--repo",
"openai/codex",
workflow_id,
]
subprocess.check_call(cmd)
def install_binary_components(
artifacts_dir: Path,
vendor_dir: Path,
selected_components: Sequence[BinaryComponent],
) -> None:
if not selected_components:
return
for component in selected_components:
component_targets = list(component.targets or BINARY_TARGETS)
print(
f"Installing {component.binary_basename} binaries for targets: "
+ ", ".join(component_targets)
)
max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(
_install_single_binary,
artifacts_dir,
vendor_dir,
target,
component,
): target
for target in component_targets
}
for future in as_completed(futures):
installed_path = future.result()
print(f" installed {installed_path}")
def _install_single_binary(
artifacts_dir: Path,
vendor_dir: Path,
target: str,
component: BinaryComponent,
) -> Path:
artifact_subdir = artifact_dir_for_target(artifacts_dir, target)
archive_path = legacy_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)
binary_name = (
f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename
)
dest = dest_dir / binary_name
dest.unlink(missing_ok=True)
extract_archive(archive_path, "zst", None, dest)
if "windows" not in target:
dest.chmod(0o755)
return dest
def _archive_name_for_target(artifact_prefix: str, target: str) -> str:
if "windows" in target:
return f"{artifact_prefix}-{target}.exe.zst"
return f"{artifact_prefix}-{target}.zst"
def legacy_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"))
for archive_name in archive_names:
archive_path = artifact_dir / archive_name
if archive_path.exists():
return archive_path
raise FileNotFoundError(f"Expected artifact not found: {artifact_dir / archive_names[0]}")
def artifact_dir_for_target(artifacts_dir: Path, target: str) -> Path:
for artifact_name in [target, f"{target}-unsigned"]:
artifact_dir = artifacts_dir / artifact_name
if artifact_dir.is_dir():
return artifact_dir
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:
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")
if __name__ == "__main__":
import sys
sys.exit(main())
+4 -10
View File
@@ -2434,6 +2434,7 @@ dependencies = [
"dunce",
"futures",
"gethostname",
"indexmap 2.13.0",
"libc",
"multimap",
"pretty_assertions",
@@ -2956,6 +2957,7 @@ dependencies = [
"anyhow",
"async-trait",
"chrono",
"codex-core",
"codex-extension-api",
"codex-protocol",
"codex-state",
@@ -3556,6 +3558,7 @@ dependencies = [
"codex-utils-path",
"codex-utils-string",
"pretty_assertions",
"regex",
"serde",
"serde_json",
"tempfile",
@@ -3742,6 +3745,7 @@ dependencies = [
"async-trait",
"chrono",
"codex-git-utils",
"codex-install-context",
"codex-protocol",
"codex-rollout",
"codex-state",
@@ -4142,7 +4146,6 @@ dependencies = [
"tokio",
"windows 0.58.0",
"windows-sys 0.52.0",
"winres",
]
[[package]]
@@ -14727,15 +14730,6 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winres"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "winsafe"
version = "0.0.19"
@@ -3628,6 +3628,7 @@ async fn turn_event_counts_completed_tool_items() {
status: McpToolCallStatus::Completed,
arguments: json!({}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: None,
duration_ms: Some(2),
@@ -3861,6 +3861,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -6540,4 +6546,4 @@
}
],
"title": "ServerNotification"
}
}
@@ -7744,6 +7744,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/v2/SandboxMode"
@@ -11346,6 +11355,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/v2/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -16311,6 +16328,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -17417,6 +17440,21 @@
"title": "ThreadRollbackResponse",
"type": "object"
},
"ThreadSearchResult": {
"properties": {
"snippet": {
"type": "string"
},
"thread": {
"$ref": "#/definitions/v2/Thread"
}
},
"required": [
"snippet",
"thread"
],
"type": "object"
},
"ThreadSetNameParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -4113,6 +4113,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/SandboxMode"
@@ -7875,6 +7884,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -14135,6 +14152,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -15241,6 +15264,21 @@
"title": "ThreadRollbackResponse",
"type": "object"
},
"ThreadSearchResult": {
"properties": {
"snippet": {
"type": "string"
},
"thread": {
"$ref": "#/definitions/Thread"
}
},
"required": [
"snippet",
"thread"
],
"type": "object"
},
"ThreadSetNameParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -88,6 +88,15 @@
"null"
]
},
"allowedPermissions": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"allowedSandboxModes": {
"items": {
"$ref": "#/definitions/SandboxMode"
@@ -800,6 +800,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -800,6 +800,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -43,6 +43,14 @@
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
"defaultServiceTier": {
"default": null,
"description": "Catalog default service tier id for this model, when one is configured.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1421,6 +1421,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -1236,6 +1236,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -944,6 +944,12 @@
"null"
]
},
"pluginId": {
"type": [
"string",
"null"
]
},
"result": {
"anyOf": [
{
@@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
+5 -1
View File
@@ -12,4 +12,8 @@ export type Model = { id: string, model: string, upgrade: string | null, upgrade
/**
* Deprecated: use `serviceTiers` instead.
*/
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>, isDefault: boolean, };
additionalSpeedTiers: Array<string>, serviceTiers: Array<ModelServiceTier>,
/**
* Catalog default service tier id for this model, when one is configured.
*/
defaultServiceTier: string | null, isDefault: boolean, };
@@ -53,7 +53,7 @@ exitCode: number | null,
/**
* The duration of the command execution in milliseconds.
*/
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null,
durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array<FileUpdateChange>, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null,
/**
* The duration of the MCP tool call in milliseconds.
*/
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Thread } from "./Thread";
export type ThreadSearchResult = { thread: Thread, snippet: string, };
@@ -401,6 +401,7 @@ export type { ThreadResumeParams } from "./ThreadResumeParams";
export type { ThreadResumeResponse } from "./ThreadResumeResponse";
export type { ThreadRollbackParams } from "./ThreadRollbackParams";
export type { ThreadRollbackResponse } from "./ThreadRollbackResponse";
export type { ThreadSearchResult } from "./ThreadSearchResult";
export type { ThreadSetNameParams } from "./ThreadSetNameParams";
export type { ThreadSetNameResponse } from "./ThreadSetNameResponse";
export type { ThreadSettings } from "./ThreadSettings";
@@ -569,6 +569,12 @@ client_request_definitions! {
serialization: None,
response: v2::ThreadListResponse,
},
#[experimental("thread/search")]
ThreadSearch => "thread/search" {
params: v2::ThreadSearchParams,
serialization: None,
response: v2::ThreadSearchResponse,
},
ThreadLoadedList => "thread/loaded/list" {
params: v2::ThreadLoadedListParams,
serialization: None,
@@ -525,6 +525,7 @@ impl ThreadHistoryBuilder {
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result: None,
error: None,
duration_ms: None,
@@ -566,6 +567,7 @@ impl ThreadHistoryBuilder {
.clone()
.unwrap_or(serde_json::Value::Null),
mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(),
plugin_id: payload.plugin_id.clone(),
result,
error,
duration_ms,
@@ -1911,6 +1913,7 @@ mod tests {
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: None,
plugin_id: None,
duration: Duration::from_millis(8),
result: Err("boom".into()),
}),
@@ -1960,6 +1963,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "boom".into(),
@@ -1986,6 +1990,7 @@ mod tests {
arguments: Some(serde_json::json!({"id":"123"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![serde_json::json!({
@@ -2016,6 +2021,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"id":"123"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".into()),
plugin_id: Some("sample@test".into()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
@@ -386,6 +386,7 @@ pub struct ConfigRequirements {
#[experimental("configRequirements/read.allowedApprovalsReviewers")]
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxMode>>,
pub allowed_permissions: Option<Vec<String>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub computer_use: Option<ComputerUseRequirements>,
@@ -286,6 +286,7 @@ pub enum ThreadItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
mcp_app_resource_uri: Option<String>,
plugin_id: Option<String>,
result: Option<Box<McpToolCallResult>>,
error: Option<McpToolCallError>,
/// The duration of the MCP tool call in milliseconds.
@@ -846,6 +847,7 @@ impl From<CoreTurnItem> for ThreadItem {
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
plugin_id: mcp.plugin_id,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
error: mcp.error.map(McpToolCallError::from),
duration_ms,
@@ -98,6 +98,9 @@ pub struct Model {
pub additional_speed_tiers: Vec<String>,
#[serde(default)]
pub service_tiers: Vec<ModelServiceTier>,
/// Catalog default service tier id for this model, when one is configured.
#[serde(default)]
pub default_service_tier: Option<String>,
// Only one model should be marked as default.
pub is_default: bool,
}
@@ -1728,6 +1728,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
}]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
@@ -2491,6 +2492,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
error: None,
@@ -2506,6 +2508,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
plugin_id: Some("sample@test".to_string()),
result: None,
error: None,
duration_ms: None,
@@ -2518,6 +2521,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
tool: "tool".to_string(),
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
plugin_id: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
@@ -2538,6 +2542,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
@@ -973,6 +973,34 @@ pub struct ThreadListParams {
pub search_term: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchParams {
/// Opaque pagination cursor returned by a previous call.
#[ts(optional = nullable)]
pub cursor: Option<String>,
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional sort key; defaults to created_at.
#[ts(optional = nullable)]
pub sort_key: Option<ThreadSortKey>,
/// Optional sort direction; defaults to descending (newest first).
#[ts(optional = nullable)]
pub sort_direction: Option<SortDirection>,
/// Optional source filter; when set, only sessions from these source kinds
/// are returned. When omitted or empty, defaults to interactive sources.
#[ts(optional = nullable)]
pub source_kinds: Option<Vec<ThreadSourceKind>>,
/// Optional archived filter; when set to true, only archived threads are returned.
/// If false or null, only non-archived threads are returned.
#[ts(optional = nullable)]
pub archived: Option<bool>,
/// Required substring/full-text query for thread search.
pub search_term: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(untagged)]
pub enum ThreadListCwdFilter {
@@ -1029,6 +1057,29 @@ pub struct ThreadListResponse {
pub backwards_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchResult {
pub thread: Thread,
pub snippet: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadSearchResponse {
pub data: Vec<ThreadSearchResult>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// if None, there are no more items to return.
pub next_cursor: Option<String>,
/// Opaque cursor to pass as `cursor` when reversing `sortDirection`.
/// This is only populated when the page contains at least one thread.
/// Use it with the opposite `sortDirection`; for timestamp sorts it anchors
/// at the start of the page timestamp so same-second updates are not skipped.
pub backwards_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1339,7 +1339,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
installation_id: TEST_INSTALLATION_ID.to_string(),
},
Some(state_db.clone()),
auth_manager,
auth_manager.clone(),
transport_event_tx,
shutdown_token.clone(),
/*app_server_client_name_rx*/ None,
@@ -1358,8 +1358,11 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
AuthCredentialsStoreMode::File,
)
.expect("auth with account id should save");
auth_manager.reload().await;
let enroll_request = accept_http_request(&listener).await;
let enroll_request = timeout(Duration::from_millis(100), accept_http_request(&listener))
.await
.expect("auth change should wake remote control before the retry delay");
assert_eq!(
enroll_request.request_line,
"POST /backend-api/wham/remote/control/server/enroll HTTP/1.1"
@@ -226,6 +226,7 @@ pub(crate) struct RemoteControlWebsocket {
reconnect_attempt: u64,
enrollment: Option<RemoteControlEnrollment>,
auth_recovery: UnauthorizedRecovery,
auth_change_rx: watch::Receiver<u64>,
client_tracker: Arc<Mutex<ClientTracker>>,
state: Arc<Mutex<WebsocketState>>,
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
@@ -240,6 +241,12 @@ pub(crate) struct RemoteControlWebsocketConfig {
pub(crate) server_name: String,
}
pub(super) struct RemoteControlAuthContext<'a> {
auth_manager: &'a Arc<AuthManager>,
auth_recovery: &'a mut UnauthorizedRecovery,
auth_change_rx: &'a mut watch::Receiver<u64>,
}
enum ConnectOutcome {
Connected(Box<WebSocketStream<MaybeTlsStream<TcpStream>>>),
Disabled,
@@ -321,6 +328,7 @@ impl RemoteControlWebsocket {
);
let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
let auth_recovery = auth_manager.unauthorized_recovery();
let auth_change_rx = auth_manager.auth_change_receiver();
Self {
remote_control_url: config.remote_control_url,
@@ -334,6 +342,7 @@ impl RemoteControlWebsocket {
reconnect_attempt: 0,
enrollment: None,
auth_recovery,
auth_change_rx,
client_tracker: Arc::new(Mutex::new(client_tracker)),
state: Arc::new(Mutex::new(WebsocketState {
outbound_buffer,
@@ -457,6 +466,11 @@ impl RemoteControlWebsocket {
subscribe_cursor: subscribe_cursor.as_deref(),
app_server_client_name,
};
let auth_context = RemoteControlAuthContext {
auth_manager: &self.auth_manager,
auth_recovery: &mut self.auth_recovery,
auth_change_rx: &mut self.auth_change_rx,
};
let connect_result = tokio::select! {
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
changed = self.enabled_rx.wait_for(|enabled| !*enabled) => {
@@ -468,8 +482,7 @@ impl RemoteControlWebsocket {
connect_result = connect_remote_control_websocket(
&remote_control_target,
self.state_db.as_deref(),
&self.auth_manager,
&mut self.auth_recovery,
auth_context,
&mut self.enrollment,
connect_options,
&self.status_publisher,
@@ -517,6 +530,14 @@ impl RemoteControlWebsocket {
}
return ConnectOutcome::Disabled;
}
changed = self.auth_change_rx.changed() => {
if changed.is_err() {
return ConnectOutcome::Shutdown;
}
self.auth_recovery = self.auth_manager.unauthorized_recovery();
self.reconnect_attempt = 0;
info!("retrying app-server remote control websocket after auth changed");
}
_ = tokio::time::sleep(reconnect_delay) => {}
}
}
@@ -1018,8 +1039,7 @@ pub(crate) async fn load_remote_control_auth(
pub(super) async fn connect_remote_control_websocket(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
auth_manager: &Arc<AuthManager>,
auth_recovery: &mut UnauthorizedRecovery,
auth_context: RemoteControlAuthContext<'_>,
enrollment: &mut Option<RemoteControlEnrollment>,
connect_options: RemoteControlConnectOptions<'_>,
status_publisher: &RemoteControlStatusPublisher,
@@ -1028,6 +1048,11 @@ pub(super) async fn connect_remote_control_websocket(
tungstenite::http::Response<()>,
)> {
ensure_rustls_crypto_provider();
let RemoteControlAuthContext {
auth_manager,
auth_recovery,
auth_change_rx,
} = auth_context;
let Some(state_db) = state_db else {
*enrollment = None;
@@ -1098,7 +1123,7 @@ pub(super) async fn connect_remote_control_websocket(
Ok(new_enrollment) => new_enrollment,
Err(err)
if err.kind() == ErrorKind::PermissionDenied
&& recover_remote_control_auth(auth_recovery).await =>
&& recover_remote_control_auth(auth_recovery, auth_change_rx).await =>
{
return Err(io::Error::other(format!(
"{err}; retrying after auth recovery"
@@ -1172,7 +1197,7 @@ pub(super) async fn connect_remote_control_websocket(
tungstenite::Error::Http(response)
if matches!(response.status().as_u16(), 401 | 403) =>
{
if recover_remote_control_auth(auth_recovery).await {
if recover_remote_control_auth(auth_recovery, auth_change_rx).await {
return Err(io::Error::other(format!(
"remote control websocket auth failed with HTTP {}; retrying after auth recovery",
response.status()
@@ -1191,15 +1216,25 @@ pub(super) async fn connect_remote_control_websocket(
}
}
async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool {
async fn recover_remote_control_auth(
auth_recovery: &mut UnauthorizedRecovery,
auth_change_rx: &mut watch::Receiver<u64>,
) -> bool {
if !auth_recovery.has_next() {
return false;
}
let mode = auth_recovery.mode_name();
let step = auth_recovery.step_name();
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
match auth_recovery.next().await {
Ok(step_result) => {
if step_result.auth_state_changed() == Some(true) {
mark_recovery_auth_change_seen(
auth_change_rx,
auth_change_revision_before_recovery,
);
}
info!(
"remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}",
step_result.auth_state_changed()
@@ -1213,6 +1248,20 @@ async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -
}
}
fn mark_recovery_auth_change_seen(
auth_change_rx: &mut watch::Receiver<u64>,
auth_change_revision_before_recovery: u64,
) {
let auth_change_revision_after_recovery = *auth_change_rx.borrow();
if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) {
// Recovery updated the same watch that wakes the outer reconnect
// loop. Mark only that single revision seen; if more revisions
// arrived while recovery was in flight, leave them pending so the
// reconnect loop still reacts to the later external auth change.
auth_change_rx.borrow_and_update();
}
}
fn format_remote_control_websocket_connect_error(
websocket_url: &str,
err: &tungstenite::Error,
@@ -1290,6 +1339,37 @@ mod tests {
(RemoteControlStatusPublisher::new(status_tx), status_rx)
}
#[test]
fn mark_recovery_auth_change_seen_marks_only_recovery_revision_seen() {
let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64);
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
auth_change_tx.send_modify(|revision| *revision += 1);
mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery);
assert!(
!auth_change_rx
.has_changed()
.expect("auth change watch should remain open")
);
}
#[test]
fn mark_recovery_auth_change_seen_preserves_racing_auth_change() {
let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64);
let auth_change_revision_before_recovery = *auth_change_rx.borrow();
auth_change_tx.send_modify(|revision| *revision += 1);
auth_change_tx.send_modify(|revision| *revision += 1);
mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery);
assert!(
auth_change_rx
.has_changed()
.expect("auth change watch should remain open")
);
}
async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
.await
@@ -1375,6 +1455,7 @@ mod tests {
let state_db = remote_control_state_runtime(&codex_home).await;
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1386,8 +1467,11 @@ mod tests {
let err = match connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1440,6 +1524,7 @@ mod tests {
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1466,8 +1551,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1503,6 +1591,12 @@ mod tests {
.expect("token should be readable"),
"fresh-token"
);
assert!(
!auth_change_rx
.has_changed()
.expect("auth change watch should remain open"),
"recovery's own auth reload should not wake the reconnect loop"
);
}
#[tokio::test]
@@ -1538,6 +1632,7 @@ mod tests {
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = None;
let (status_publisher, status_rx) = remote_control_status_channel();
save_auth(
@@ -1550,8 +1645,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1585,6 +1683,12 @@ mod tests {
.expect("token should be readable"),
"fresh-token"
);
assert!(
!auth_change_rx
.has_changed()
.expect("auth change watch should remain open"),
"recovery's own auth reload should not wake the reconnect loop"
);
}
#[tokio::test]
@@ -1593,6 +1697,7 @@ mod tests {
.expect("target should parse");
let auth_manager = remote_control_auth_manager();
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1604,8 +1709,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
/*state_db*/ None,
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
@@ -1637,6 +1745,7 @@ mod tests {
)
.await;
let mut auth_recovery = auth_manager.unauthorized_recovery();
let mut auth_change_rx = auth_manager.auth_change_receiver();
let mut enrollment = Some(RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_test".to_string(),
@@ -1653,8 +1762,11 @@ mod tests {
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
RemoteControlAuthContext {
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
auth_change_rx: &mut auth_change_rx,
},
&mut enrollment,
RemoteControlConnectOptions {
installation_id: TEST_INSTALLATION_ID,
+3 -3
View File
@@ -188,7 +188,7 @@ Example with notification opt-out:
- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`.
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
@@ -226,7 +226,7 @@ Example with notification opt-out:
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish).
- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`.
### Example: Start or resume a thread
@@ -1231,7 +1231,7 @@ Today both notifications carry an empty `items` array even when item events were
- `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models).
- `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`.
- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`.
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
@@ -696,10 +696,25 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
"Codex guidance"
);
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
CI = "false"
FOO = "bar"
MAX_RETRIES = "3"
MY_TEAM = "codex"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"),
@@ -732,10 +747,24 @@ async fn import_home_config_uses_local_settings_over_project_settings() {
.await
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n"
);
let config: TomlValue =
toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config"))
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "local"
LOCAL_ONLY = "true"
PROJECT_ONLY = "yes"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
+1 -2
View File
@@ -786,8 +786,7 @@ pub async fn run_main_with_transport_options(
});
let processor_handle = tokio::spawn({
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
let auth_manager = Arc::clone(&auth_manager);
let analytics_events_client =
analytics_events_client_from_config(Arc::clone(&auth_manager), &config);
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(
@@ -1072,6 +1072,9 @@ impl MessageProcessor {
ClientRequest::ThreadList { params, .. } => {
self.thread_processor.thread_list(params).await
}
ClientRequest::ThreadSearch { params, .. } => {
self.thread_processor.thread_search(params).await
}
ClientRequest::ThreadLoadedList { params, .. } => {
self.thread_processor.thread_loaded_list(params).await
}
+1
View File
@@ -53,6 +53,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
description: service_tier.description,
})
.collect(),
default_service_tier: preset.default_service_tier,
is_default: preset.is_default,
}
}
@@ -214,6 +214,9 @@ use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSearchParams;
use codex_app_server_protocol::ThreadSearchResponse;
use codex_app_server_protocol::ThreadSearchResult;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadSettings;
@@ -340,12 +343,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;
@@ -410,6 +412,7 @@ use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams;
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
use codex_thread_store::SearchThreadsParams as StoreSearchThreadsParams;
use codex_thread_store::SortDirection as StoreSortDirection;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
@@ -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}"));
@@ -612,15 +612,10 @@ impl CatalogRequestProcessor {
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
{
let plugin_outcome = if plugins_enabled {
let plugins_input = config.plugins_config_input();
plugins_manager
.plugins_for_layer_stack(
&config.config_layer_stack,
&plugins_input,
/*plugin_hooks_feature_enabled*/ true,
)
.plugins_for_layer_stack(&config.config_layer_stack, &plugins_input)
.await
} else {
PluginLoadOutcome::default()
@@ -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 {
@@ -419,6 +419,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_permissions: requirements.allowed_permissions,
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
@@ -638,10 +639,21 @@ mod tests {
#[test]
fn requirements_api_includes_allow_managed_hooks_only() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
allowed_permissions: Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
]),
allow_managed_hooks_only: Some(true),
..ConfigRequirementsToml::default()
});
assert_eq!(
mapped.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
);
assert_eq!(mapped.allow_managed_hooks_only, Some(true));
assert_eq!(mapped.hooks, None);
}
@@ -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();
@@ -589,6 +589,15 @@ impl ThreadRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_search(
&self,
params: ThreadSearchParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_search_response_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_loaded_list(
&self,
params: ThreadLoadedListParams,
@@ -1861,6 +1870,131 @@ impl ThreadRequestProcessor {
})
}
async fn thread_search_response_inner(
&self,
params: ThreadSearchParams,
) -> Result<ThreadSearchResponse, JSONRPCErrorError> {
let ThreadSearchParams {
cursor,
limit,
sort_key,
sort_direction,
source_kinds,
archived,
search_term,
} = params;
let search_term = search_term.trim().to_string();
let search_term = (!search_term.is_empty())
.then_some(search_term)
.ok_or_else(|| invalid_request("thread/search requires a non-empty searchTerm"))?;
let requested_page_size = limit
.map(|value| value as usize)
.unwrap_or(THREAD_LIST_DEFAULT_LIMIT)
.clamp(1, THREAD_LIST_MAX_LIMIT);
let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) {
ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt,
ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt,
};
let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc);
let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds);
let mut cursor_obj = cursor;
let mut last_cursor = cursor_obj.clone();
let mut remaining = requested_page_size;
let mut search_results = Vec::with_capacity(requested_page_size);
let mut next_cursor = None;
while remaining > 0 {
let page = self
.thread_store
.search_threads(StoreSearchThreadsParams {
page_size: remaining.min(THREAD_LIST_MAX_LIMIT),
cursor: cursor_obj.clone(),
sort_key: store_sort_key,
sort_direction: match store_sort_direction {
SortDirection::Asc => StoreSortDirection::Asc,
SortDirection::Desc => StoreSortDirection::Desc,
},
allowed_sources: allowed_sources.clone(),
archived: archived.unwrap_or(false),
search_term: search_term.clone(),
})
.await
.map_err(thread_store_list_error)?;
for result in page.items {
let source = with_thread_spawn_agent_metadata(
result.thread.source.clone(),
result.thread.agent_nickname.clone(),
result.thread.agent_role.clone(),
);
if source_kind_filter
.as_ref()
.is_none_or(|filter| source_kind_matches(&source, filter))
{
search_results.push(result);
if search_results.len() >= requested_page_size {
break;
}
}
}
remaining = requested_page_size.saturating_sub(search_results.len());
next_cursor = page.next_cursor;
if remaining == 0 {
break;
}
let Some(cursor_val) = next_cursor.clone() else {
break;
};
if last_cursor.as_ref() == Some(&cursor_val) {
next_cursor = None;
break;
}
last_cursor = Some(cursor_val.clone());
cursor_obj = Some(cursor_val);
}
let backwards_cursor = search_results.first().and_then(|result| {
thread_backwards_cursor_for_sort_key(
&result.thread,
store_sort_key,
store_sort_direction,
)
});
let fallback_provider = self.config.model_provider_id.clone();
let mut results = Vec::with_capacity(search_results.len());
let mut status_ids = Vec::with_capacity(search_results.len());
for result in search_results {
let (thread, _) = thread_from_stored_thread(
result.thread,
fallback_provider.as_str(),
&self.config.cwd,
);
status_ids.push(thread.id.clone());
results.push((thread, result.snippet));
}
let statuses = self
.thread_watch_manager
.loaded_statuses_for_threads(status_ids)
.await;
let data = results
.into_iter()
.map(|(mut thread, snippet)| {
if let Some(status) = statuses.get(&thread.id) {
thread.status = status.clone();
}
ThreadSearchResult { thread, snippet }
})
.collect();
Ok(ThreadSearchResponse {
data,
next_cursor,
backwards_cursor,
})
}
async fn thread_loaded_list_response_inner(
&self,
params: ThreadLoadedListParams,
@@ -79,6 +79,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(McpToolCallResult {
content: vec![serde_json::json!({
"type": "text",
@@ -120,6 +121,7 @@ mod tests {
status: McpToolCallStatus::Completed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: Some("sample@test".to_string()),
result: Some(Box::new(redacted_mcp_tool_call_result())),
error: None,
duration_ms: Some(8),
@@ -136,6 +138,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: serde_json::json!({"secret":"argument"}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: "secret error".to_string(),
@@ -154,6 +157,7 @@ mod tests {
status: McpToolCallStatus::Failed,
arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: Some(McpToolCallError {
message: REDACTED_PAYLOAD.to_string(),
@@ -88,6 +88,7 @@ use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSearchParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSettingsUpdateParams;
use codex_app_server_protocol::ThreadShellCommandParams;
@@ -508,6 +509,15 @@ impl McpProcess {
self.send_request("thread/list", params).await
}
/// Send a `thread/search` JSON-RPC request.
pub async fn send_thread_search_request(
&mut self,
params: ThreadSearchParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/search", params).await
}
/// Send a `thread/loaded/list` JSON-RPC request.
pub async fn send_thread_loaded_list_request(
&mut self,
@@ -30,6 +30,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
priority,
additional_speed_tiers: preset.additional_speed_tiers.clone(),
service_tiers: preset.service_tiers.clone(),
default_service_tier: preset.default_service_tier.clone(),
upgrade: preset.upgrade.as_ref().map(Into::into),
base_instructions: "base instructions".to_string(),
model_messages: None,
@@ -97,7 +97,6 @@ fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> R
codex_home.join("config.toml"),
r#"[features]
plugins = true
plugin_hooks = true
hooks = true
[plugins."demo@test"]
@@ -513,6 +513,7 @@ url = "{mcp_server_url}/mcp"
status,
arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }),
mcp_app_resource_uri: None,
plugin_id: None,
result: Some(result),
error: None,
duration_ms: None,
@@ -69,6 +69,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
description: service_tier.description.clone(),
})
.collect(),
default_service_tier: preset.default_service_tier.clone(),
is_default: preset.is_default,
}
}
@@ -1273,7 +1273,6 @@ description: Visible only for ChatGPT
codex_home.path().join("config.toml"),
r#"[features]
plugins = true
plugin_hooks = true
[[skills.config]]
name = "demo-plugin:thread-summarizer"
@@ -17,6 +17,7 @@ use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadSearchResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
@@ -660,6 +661,161 @@ sqlite = true
Ok(())
}
#[tokio::test]
async fn thread_search_returns_content_matches() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let older_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"match: needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let _non_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"no hit here",
Some("mock_provider"),
/*git_info*/ None,
)?;
let newer_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"mixed NEEDLE suffix",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: "needle".to_string(),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse {
data, next_cursor, ..
} = to_response::<ThreadSearchResponse>(resp)?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(ids, vec![newer_match, older_match]);
assert_eq!(data[0].snippet, "mixed NEEDLE suffix");
Ok(())
}
#[tokio::test]
async fn thread_search_matches_json_escaped_content() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let search_term = r#"quoted "needle" \ path"#;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
search_term,
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: None,
archived: None,
search_term: search_term.to_string(),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse { data, .. } = to_response::<ThreadSearchResponse>(resp)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].thread.id, thread_id);
assert_eq!(data[0].snippet, search_term);
Ok(())
}
#[tokio::test]
async fn thread_search_filters_by_source_kind() -> Result<()> {
let codex_home = TempDir::new()?;
create_minimal_config(codex_home.path())?;
let cli_id = create_fake_rollout(
codex_home.path(),
"2025-02-01T10-00-00",
"2025-02-01T10:00:00Z",
"shared needle",
Some("mock_provider"),
/*git_info*/ None,
)?;
let exec_id = create_fake_rollout_with_source(
codex_home.path(),
"2025-02-01T11-00-00",
"2025-02-01T11:00:00Z",
"shared needle",
Some("mock_provider"),
/*git_info*/ None,
CoreSessionSource::Exec,
)?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_search_request(codex_app_server_protocol::ThreadSearchParams {
cursor: None,
limit: Some(10),
sort_key: None,
sort_direction: None,
source_kinds: Some(vec![ThreadSourceKind::Exec]),
archived: None,
search_term: "needle".to_string(),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadSearchResponse { data, .. } = to_response::<ThreadSearchResponse>(resp)?;
let ids: Vec<_> = data
.iter()
.map(|result| result.thread.id.as_str())
.collect();
assert_eq!(ids, vec![exec_id.as_str()]);
assert_ne!(cli_id, exec_id);
Ok(())
}
#[tokio::test]
async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -679,6 +679,7 @@ fn append_resume_redaction_history(
arguments: Some(json!({"secret":"argument"})),
},
mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()),
plugin_id: None,
duration: Duration::from_millis(8),
result: Ok(CallToolResult {
content: vec![json!({
@@ -22,6 +22,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::test_support::all_model_presets;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -136,7 +137,7 @@ async fn thread_settings_update_while_turn_is_active_emits_notification() -> Res
}
#[tokio::test]
async fn thread_settings_update_clears_service_tier() -> Result<()> {
async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(vec![
create_final_assistant_message_sse_response("done")?,
])
@@ -181,7 +182,10 @@ async fn thread_settings_update_clears_service_tier() -> Result<()> {
let clear_updated = read_thread_settings_updated(&mut mcp).await?;
assert_eq!(clear_updated.thread_id, thread.id);
assert_eq!(clear_updated.thread_settings.model, model_id);
assert_eq!(clear_updated.thread_settings.service_tier, None);
assert_eq!(
clear_updated.thread_settings.service_tier.as_deref(),
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE)
);
start_text_turn(&mut mcp, thread.id).await?;
timeout(
@@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level;
use codex_exec_server::LOCAL_FS;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use pretty_assertions::assert_eq;
@@ -484,7 +485,7 @@ model_reasoning_effort = "high"
}
#[tokio::test]
async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -508,7 +509,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> {
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(service_tier, Some(service_tier_id));
// Unsupported catalog ids are dropped at session config time instead of echoed back.
assert_eq!(service_tier, None);
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_default_service_tier() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_tier: Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { service_tier, .. } = to_response::<ThreadStartResponse>(resp)?;
assert_eq!(
service_tier,
Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())
);
Ok(())
}
@@ -1004,10 +1004,14 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn()
assert!(
err.error
.message
.contains("invalid thread settings override")
.contains("`approval_policy = \"never\"` cannot be used"),
"unexpected error message: {}",
err.error.message
);
assert!(
err.error.message.contains("allowed set [ReadOnly]"),
err.error
.message
.contains("requirements do not allow `sandbox_mode = \"danger-full-access\"`"),
"unexpected error message: {}",
err.error.message
);
-1
View File
@@ -496,7 +496,6 @@ fn config_overrides_from_interactive(
};
ConfigOverrides {
model: interactive.model.clone(),
config_profile: interactive.config_profile.clone(),
approval_policy,
sandbox_mode,
cwd: interactive.cwd.clone(),
+16 -61
View File
@@ -1331,7 +1331,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"debug clear-memories",
)?;
run_debug_clear_memories_command(&root_config_overrides, &interactive).await?;
run_debug_clear_memories_command(&root_config_overrides).await?;
}
},
Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub {
@@ -1381,14 +1381,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
"exec-server",
)?;
let strict_config = cmd.strict_config || root_strict_config;
run_exec_server_command(
cmd,
&arg0_paths,
&root_config_overrides,
interactive.config_profile.clone(),
strict_config,
)
.await?;
run_exec_server_command(cmd, &arg0_paths, &root_config_overrides, strict_config)
.await?;
}
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
@@ -1397,7 +1391,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"features list",
)?;
// Respect root-level `-c` overrides plus top-level flags like `--profile`.
let mut cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
@@ -1410,15 +1403,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
));
}
// Thread through relevant top-level flags (at minimum, `--profile`).
let overrides = ConfigOverrides {
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
let mut rows = Vec::with_capacity(FEATURES.len());
@@ -1444,7 +1430,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"features enable",
)?;
enable_feature_in_config(&interactive, &feature).await?;
enable_feature_in_config(&feature).await?;
}
FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => {
reject_remote_mode_for_subcommand(
@@ -1452,7 +1438,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"features disable",
)?;
disable_feature_in_config(&interactive, &feature).await?;
disable_feature_in_config(&feature).await?;
}
},
}
@@ -1477,7 +1463,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`."
),
}
}
@@ -1486,7 +1472,6 @@ async fn run_exec_server_command(
cmd: ExecServerCommand,
arg0_paths: &Arg0DispatchPaths,
root_config_overrides: &CliConfigOverrides,
config_profile: Option<String>,
strict_config: bool,
) -> anyhow::Result<()> {
let codex_self_exe = arg0_paths
@@ -1501,8 +1486,7 @@ async fn run_exec_server_command(
let environment_id = cmd
.environment_id
.ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?;
let config =
load_exec_server_config(root_config_overrides, config_profile, strict_config).await?;
let config = load_exec_server_config(root_config_overrides, strict_config).await?;
let auth_provider =
load_exec_server_remote_auth_provider(&config, cmd.use_agent_identity_auth).await?;
let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new(
@@ -1520,8 +1504,7 @@ async fn run_exec_server_command(
// Local exec-server startup does not consume Config, but strict
// mode should still reject unknown fields before opening a listener.
let _validated_config =
load_exec_server_config(root_config_overrides, config_profile, strict_config)
.await?;
load_exec_server_config(root_config_overrides, strict_config).await?;
}
let listen_url = cmd
.listen
@@ -1564,7 +1547,6 @@ async fn load_exec_server_remote_auth_provider(
async fn load_exec_server_config(
root_config_overrides: &CliConfigOverrides,
config_profile: Option<String>,
strict_config: bool,
) -> anyhow::Result<codex_core::config::Config> {
let cli_kv_overrides = root_config_overrides
@@ -1572,10 +1554,6 @@ async fn load_exec_server_config(
.map_err(anyhow::Error::msg)?;
Ok(ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(ConfigOverrides {
config_profile,
..Default::default()
})
.strict_config(strict_config)
.build()
.await?)
@@ -1602,24 +1580,22 @@ async fn load_exec_server_remote_auth(
Ok(auth)
}
async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
async fn enable_feature_in_config(feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let codex_home = find_codex_home()?;
ConfigEditsBuilder::new(&codex_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, /*enabled*/ true)
.apply()
.await?;
println!("Enabled feature `{feature}` in config.toml.");
maybe_print_under_development_feature_warning(&codex_home, interactive, feature);
maybe_print_under_development_feature_warning(&codex_home, feature);
Ok(())
}
async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> {
async fn disable_feature_in_config(feature: &str) -> anyhow::Result<()> {
FeatureToggles::validate_feature(feature)?;
let codex_home = find_codex_home()?;
ConfigEditsBuilder::new(&codex_home)
.with_profile(interactive.config_profile.as_deref())
.set_feature_enabled(feature, /*enabled*/ false)
.apply()
.await?;
@@ -1643,15 +1619,7 @@ fn loader_overrides_for_profile(
}
}
fn maybe_print_under_development_feature_warning(
codex_home: &std::path::Path,
interactive: &TuiCli,
feature: &str,
) {
if interactive.config_profile.is_some() {
return;
}
fn maybe_print_under_development_feature_warning(codex_home: &std::path::Path, feature: &str) {
let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else {
return;
};
@@ -1709,7 +1677,6 @@ async fn run_debug_prompt_input_command(
};
let overrides = ConfigOverrides {
model: shared.model,
config_profile: shared.config_profile,
approval_policy,
sandbox_mode,
cwd: shared.cwd,
@@ -1777,18 +1744,12 @@ async fn run_debug_models_command(
async fn run_debug_clear_memories_command(
root_config_overrides: &CliConfigOverrides,
interactive: &TuiCli,
) -> anyhow::Result<()> {
let cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
let overrides = ConfigOverrides {
config_profile: interactive.config_profile.clone(),
..Default::default()
};
let config = ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await?;
@@ -2286,21 +2247,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 +2269,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 +2720,6 @@ mod tests {
"-m",
"gpt-5.1-test",
"-p",
"my-profile",
"--profile-v2",
"my-config",
"-C",
"/tmp",
@@ -2776,7 +2732,6 @@ 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_v2.as_deref(), Some("my-config"));
assert_matches!(
interactive.sandbox_mode,
+70 -31
View File
@@ -6,14 +6,15 @@ use codex_core::config::Config;
use codex_core::config::find_codex_home;
use codex_core_plugins::PluginMarketplaceUpgradeOutcome;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::installed_marketplaces::marketplace_install_root;
use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root;
use codex_core_plugins::marketplace::marketplace_root_dir;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_plugin::validate_plugin_segment;
use codex_utils_cli::CliConfigOverrides;
use std::collections::HashSet;
use crate::plugin_cmd::configured_marketplace_snapshot_issues;
#[derive(Debug, Parser)]
#[command(bin_name = "codex plugin marketplace")]
@@ -30,7 +31,7 @@ enum MarketplaceSubcommand {
/// Add a local or Git marketplace to the configured marketplace sources.
Add(AddMarketplaceArgs),
/// List configured marketplace names and their local snapshot roots.
/// List plugin marketplaces Codex is currently considering and their roots.
List,
/// Refresh configured Git marketplace snapshots.
@@ -150,39 +151,77 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> {
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let configured_marketplaces = config
.config_layer_stack
.get_active_user_layer()
.and_then(|layer| layer.config.get("marketplaces"))
.and_then(toml::Value::as_table);
let Some(configured_marketplaces) = configured_marketplaces else {
println!("No configured plugin marketplaces.");
return Ok(());
};
if configured_marketplaces.is_empty() {
println!("No configured plugin marketplaces.");
let manager = PluginsManager::new(config.codex_home.to_path_buf());
let plugins_input = config.plugins_config_input();
let marketplace_listing = manager
.discover_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces")?;
let mut load_issues = configured_marketplace_snapshot_issues(
config.codex_home.as_path(),
&plugins_input,
&marketplace_listing.errors,
/*marketplace_name*/ None,
);
let mut issue_paths = load_issues
.iter()
.map(|issue| issue.path.clone())
.collect::<HashSet<_>>();
for error in &marketplace_listing.errors {
if issue_paths.insert(error.path.to_path_buf()) {
load_issues.push(crate::plugin_cmd::ConfiguredMarketplaceSnapshotIssue {
marketplace_name: error.path.display().to_string(),
path: error.path.to_path_buf(),
message: error.message.clone(),
});
}
}
if !load_issues.is_empty() {
let issue_lines = load_issues
.iter()
.map(|issue| {
format!(
"- `{}` at {}: {}",
issue.marketplace_name,
issue.path.display(),
issue.message
)
})
.collect::<Vec<_>>()
.join("\n");
bail!("failed to load marketplace(s):\n{issue_lines}");
}
let marketplaces = marketplace_listing.marketplaces;
if marketplaces.is_empty() {
println!("No plugin marketplaces in scope.");
return Ok(());
}
let default_install_root = marketplace_install_root(config.codex_home.as_path());
for (marketplace_name, marketplace) in configured_marketplaces {
if !marketplace.is_table() {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table.");
let mut seen_roots = HashSet::new();
let mut rows = Vec::new();
for marketplace in marketplaces {
let Ok(root) = marketplace_root_dir(&marketplace.path) else {
continue;
};
if !seen_roots.insert(root.clone()) {
continue;
}
if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") {
eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}.");
continue;
}
let root = resolve_configured_marketplace_root(
rows.push((marketplace.name, root));
}
let marketplace_width = rows
.iter()
.map(|(name, _)| name.len())
.max()
.unwrap_or("MARKETPLACE".len())
.max("MARKETPLACE".len());
println!("{:<marketplace_width$} ROOT", "MARKETPLACE");
for (marketplace_name, root) in rows {
println!(
"{:<marketplace_width$} {}",
marketplace_name,
marketplace,
default_install_root.as_path(),
)
.map(|root| root.display().to_string())
.unwrap_or_else(|| "<invalid source>".to_string());
println!("{marketplace_name}\t{root}");
root.display()
);
}
Ok(())
+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,
+33 -10
View File
@@ -359,10 +359,10 @@ fn find_marketplace_for_plugin(
}
}
struct ConfiguredMarketplaceSnapshotIssue {
marketplace_name: String,
path: PathBuf,
message: String,
pub(crate) struct ConfiguredMarketplaceSnapshotIssue {
pub(crate) marketplace_name: String,
pub(crate) path: PathBuf,
pub(crate) message: String,
}
fn ensure_configured_marketplace_snapshots_loaded(
@@ -396,17 +396,16 @@ fn ensure_configured_marketplace_snapshots_loaded(
bail!("failed to load configured marketplace snapshot(s):\n{issue_lines}");
}
fn configured_marketplace_snapshot_issues(
pub(crate) fn configured_marketplace_snapshot_issues(
codex_home: &std::path::Path,
plugins_input: &PluginsConfigInput,
load_errors: &[MarketplaceListError],
marketplace_name: Option<&str>,
) -> Vec<ConfiguredMarketplaceSnapshotIssue> {
let Some(user_layer) = plugins_input.config_layer_stack.get_active_user_layer() else {
let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else {
return Vec::new();
};
let Some(configured_marketplaces) = user_layer
.config
let Some(configured_marketplaces) = user_config
.get("marketplaces")
.and_then(toml::Value::as_table)
else {
@@ -420,9 +419,33 @@ fn configured_marketplace_snapshot_issues(
if marketplace_name.is_some_and(|name| configured_name != name) {
continue;
}
if !marketplace.is_table()
|| validate_plugin_segment(configured_name, "marketplace name").is_err()
if !marketplace.is_table() {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: "configured marketplace entry must be a table".to_string(),
});
continue;
}
if let Err(err) = validate_plugin_segment(configured_name, "marketplace name") {
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid config>"),
message: err.to_string(),
});
continue;
}
if marketplace.get("source_type").and_then(toml::Value::as_str) == Some("local")
&& marketplace
.get("source")
.and_then(toml::Value::as_str)
.is_none_or(str::is_empty)
{
issues.push(ConfiguredMarketplaceSnapshotIssue {
marketplace_name: configured_name.clone(),
path: PathBuf::from("<invalid source>"),
message: "configured local marketplace source is missing or empty".to_string(),
});
continue;
}
let Some(root) = resolve_configured_marketplace_root(
+269 -36
View File
@@ -7,9 +7,21 @@ use predicates::str::contains;
use std::path::Path;
use tempfile::TempDir;
const MARKETPLACE_HEADER: &str = "MARKETPLACE";
const MARKETPLACE_LIST_HEADER: &str = "MARKETPLACE ROOT";
fn marketplace_list_row(marketplace_name: &str, root: &Path) -> String {
format!(
"{marketplace_name:<width$} {}",
root.display(),
width = MARKETPLACE_HEADER.len()
)
}
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home);
cmd.env("HOME", codex_home);
Ok(cmd)
}
@@ -40,7 +52,7 @@ plugins = true
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
fn write_marketplace_source_with_manifest(source: &Path, marketplace_manifest: &str) -> Result<()> {
std::fs::create_dir_all(source.join(".agents").join("plugins"))?;
std::fs::create_dir_all(source.join("plugins").join("sample").join(".codex-plugin"))?;
std::fs::write(
@@ -48,6 +60,22 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
.join(".agents")
.join("plugins")
.join("marketplace.json"),
marketplace_manifest,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
}
fn write_marketplace_source(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
@@ -60,16 +88,28 @@ fn write_marketplace_source(source: &Path) -> Result<()> {
}
]
}"#,
)?;
std::fs::write(
source
.join("plugins")
.join("sample")
.join(".codex-plugin")
.join("plugin.json"),
r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#,
)?;
Ok(())
)
}
fn write_marketplace_source_with_explicit_empty_products(source: &Path) -> Result<()> {
write_marketplace_source_with_manifest(
source,
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
},
"policy": {
"products": []
}
}
]
}"#,
)
}
fn setup_local_marketplace() -> Result<(TempDir, TempDir)> {
@@ -94,6 +134,20 @@ fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> {
Ok((codex_home, source))
}
fn setup_local_marketplace_with_explicit_empty_products() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
write_marketplace_source_with_explicit_empty_products(source.path())?;
let source_path = source.path().to_string_lossy().into_owned();
record_user_marketplace(
codex_home.path(),
"debug",
&configured_local_marketplace(&source_path),
)?;
Ok((codex_home, source))
}
fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> {
let codex_home = TempDir::new()?;
let source = TempDir::new()?;
@@ -207,16 +261,203 @@ fn remove_installed_plugin_config(codex_home: &Path, plugin_key: &str) -> Result
Ok(())
}
fn setup_configured_local_marketplace_with_missing_source() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
source_type = "local"
"#,
)?;
Ok(codex_home)
}
fn setup_configured_local_marketplace_with_invalid_name() -> Result<TempDir> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces."bad/name"]
source_type = "local"
source = "/tmp/debug"
"#,
)?;
Ok(codex_home)
}
fn assert_configured_marketplace_snapshot_failure(
assert: assert_cmd::assert::Assert,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
fn assert_marketplace_failure(
assert: assert_cmd::assert::Assert,
marketplace_name: &str,
source: &Path,
detail: &str,
) {
assert
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(format!("`{marketplace_name}`")))
.stderr(contains(source.display().to_string()))
.stderr(contains(detail));
}
#[tokio::test]
async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> {
let (codex_home, source) = setup_local_marketplace()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains("debug"))
.stdout(contains(source.path().display().to_string()));
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_marketplace_source(home.path())?;
write_plugins_enabled_config(codex_home.path())?;
let expected_row = marketplace_list_row("debug", home.path());
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row))
.stdout(contains("\t").not());
Ok(())
}
#[tokio::test]
async fn marketplace_list_includes_root_when_plugins_are_filtered_out() -> Result<()> {
let (codex_home, source) = setup_local_marketplace_with_explicit_empty_products()?;
let expected_row = marketplace_list_row("debug", source.path());
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.success()
.stdout(contains(MARKETPLACE_LIST_HEADER))
.stdout(contains(&expected_row));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_name_is_invalid() -> Result<()> {
let codex_home = setup_configured_local_marketplace_with_invalid_name()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"bad/name",
Path::new("<invalid config>"),
"marketplace name",
);
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_local_marketplace_source_is_missing() -> Result<()>
{
let codex_home = setup_configured_local_marketplace_with_missing_source()?;
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains("`debug`"))
.stderr(contains("<invalid source>"))
.stderr(contains(
"configured local marketplace source is missing or empty",
));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_home_marketplace_is_malformed() -> Result<()> {
let codex_home = TempDir::new()?;
let home = TempDir::new()?;
write_plugins_enabled_config(codex_home.path())?;
std::fs::create_dir_all(home.path().join(".agents/plugins"))?;
let home_marketplace_path = home
.path()
.join(".agents")
.join("plugins")
.join("marketplace.json");
std::fs::write(&home_marketplace_path, "{not valid json")?;
codex_command(codex_home.path())?
.env("HOME", home.path())
.args(["plugin", "marketplace", "list"])
.assert()
.failure()
.stderr(contains("failed to load marketplace(s):"))
.stderr(contains(home_marketplace_path.display().to_string()))
.stderr(contains("key must be a string"));
Ok(())
}
#[tokio::test]
async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
assert_marketplace_failure(
codex_command(codex_home.path())?
.args(["plugin", "marketplace", "list"])
.assert(),
"debug",
source.path(),
"key must be a string",
);
Ok(())
}
@@ -286,18 +527,13 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<(
async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> {
let (codex_home, source) = setup_configured_marketplace_without_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains(source.path().display().to_string()))
.stderr(contains(
"marketplace root does not contain a supported manifest",
));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "list"])
.assert(),
source.path(),
"marketplace root does not contain a supported manifest",
);
Ok(())
}
@@ -414,18 +650,15 @@ async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()>
#[tokio::test]
async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> {
let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?;
let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?;
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert()
.failure()
.stderr(contains(
"failed to load configured marketplace snapshot(s):",
))
.stderr(contains("`debug`"))
.stderr(contains("invalid marketplace file"))
.stderr(contains("key must be a string"));
assert_configured_marketplace_snapshot_failure(
codex_command(codex_home.path())?
.args(["plugin", "add", "sample@debug"])
.assert(),
source.path(),
"key must be a string",
);
Ok(())
}
+16
View File
@@ -1216,6 +1216,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1300,6 +1301,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1335,6 +1337,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1387,6 +1390,7 @@ mod tests {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1568,6 +1572,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1650,6 +1655,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1730,6 +1736,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1938,6 +1945,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -1980,6 +1988,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2042,6 +2051,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2100,6 +2110,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2160,6 +2171,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2221,6 +2233,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2286,6 +2299,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2377,6 +2391,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -2414,6 +2429,7 @@ command = "sample-mcp"
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
@@ -993,6 +993,22 @@ mod tests {
);
}
#[test]
fn parse_v1_input_transcript_turn_marked_event() {
let payload = json!({
"type": "conversation.input_transcript.turn_marked",
"transcript": "hello realtime"
})
.to_string();
assert_eq!(
parse_realtime_event(payload.as_str(), RealtimeEventParser::V1),
Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone {
text: "hello realtime".to_string(),
}))
);
}
#[test]
fn parse_output_transcript_delta_event() {
let payload = json!({
@@ -1581,6 +1597,10 @@ mod tests {
.expect("text");
let third_json: Value = serde_json::from_str(&third).expect("json");
assert_eq!(third_json["type"], "conversation.item.create");
assert_eq!(
third_json["item"]["content"][0]["type"],
Value::String("input_text".to_string())
);
assert_eq!(third_json["item"]["content"][0]["text"], "hello agent");
let fourth = ws
@@ -21,7 +21,7 @@ pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutbound
r#type: ConversationItemType::Message,
role: ConversationRole::User,
content: vec![ConversationItemContent {
r#type: ConversationContentType::Text,
r#type: ConversationContentType::InputText,
text,
}],
}),
@@ -199,7 +199,6 @@ pub(super) struct ConversationItemContent {
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum ConversationContentType {
Text,
InputText,
}
@@ -43,7 +43,8 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option<RealtimeEvent> {
| "conversation.item.input_audio_transcription.delta" => {
parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta)
}
"conversation.item.input_audio_transcription.completed" => {
"conversation.input_transcript.turn_marked"
| "conversation.item.input_audio_transcription.completed" => {
parse_transcript_done_event(&parsed, "transcript")
.map(RealtimeEvent::InputTranscriptDone)
}
@@ -76,6 +76,7 @@ async fn models_client_hits_models_endpoint() {
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
default_service_tier: None,
upgrade: None,
base_instructions: "base instructions".to_string(),
model_messages: None,
+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`"
);
}
}
+2 -1
View File
@@ -28,6 +28,7 @@ codex-utils-path = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
multimap = { workspace = true }
prost = "0.14.3"
schemars = { workspace = true }
@@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true }
toml = { workspace = true, features = ["preserve_order"] }
toml_edit = { workspace = true }
tonic = { workspace = true }
tonic-prost = { workspace = true }
+122 -1
View File
@@ -19,6 +19,7 @@ use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RequirementSource {
@@ -432,14 +433,58 @@ impl From<NetworkRequirementsToml> for NetworkConstraints {
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FilesystemRequirementsToml {
pub deny_read: Option<Vec<FilesystemDenyReadPattern>>,
}
#[derive(Deserialize)]
struct RawFilesystemRequirementsToml {
deny_read: Option<Vec<FilesystemDenyReadPattern>>,
description: Option<serde::de::IgnoredAny>,
extends: Option<serde::de::IgnoredAny>,
workspace_roots: Option<serde::de::IgnoredAny>,
filesystem: Option<serde::de::IgnoredAny>,
network: Option<serde::de::IgnoredAny>,
}
impl<'de> Deserialize<'de> for FilesystemRequirementsToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?;
let RawFilesystemRequirementsToml {
deny_read,
description,
extends,
workspace_roots,
filesystem,
network,
} = raw;
if description.is_some()
|| extends.is_some()
|| workspace_roots.is_some()
|| filesystem.is_some()
|| network.is_some()
{
return Err(D::Error::custom(
"`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile",
));
}
Ok(Self { deny_read })
}
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PermissionsRequirementsToml {
pub filesystem: Option<FilesystemRequirementsToml>,
// For legacy reasons, `filesystem` stays reserved for requirements-level
// filesystem constraints and cannot name a profile.
#[serde(default, flatten)]
pub profiles: BTreeMap<String, PermissionProfileToml>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -701,6 +746,7 @@ pub struct ConfigRequirementsToml {
pub allowed_approval_policies: Option<Vec<AskForApproval>>,
pub allowed_approvals_reviewers: Option<Vec<ApprovalsReviewer>>,
pub allowed_sandbox_modes: Option<Vec<SandboxModeRequirement>>,
pub allowed_permissions: Option<Vec<String>>,
pub remote_sandbox_config: Option<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
@@ -752,6 +798,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_approval_policies: Option<Sourced<Vec<AskForApproval>>>,
pub allowed_approvals_reviewers: Option<Sourced<Vec<ApprovalsReviewer>>>,
pub allowed_sandbox_modes: Option<Sourced<Vec<SandboxModeRequirement>>>,
pub allowed_permissions: Option<Sourced<Vec<String>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
@@ -789,6 +836,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: _,
allowed_approvals_reviewers: _,
allowed_sandbox_modes: _,
allowed_permissions: _,
remote_sandbox_config: _,
allowed_web_search_modes: _,
allow_managed_hooks_only: _,
@@ -821,6 +869,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -850,6 +899,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -868,6 +918,7 @@ impl ConfigRequirementsWithSources {
allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value),
allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value),
allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value),
allowed_permissions: allowed_permissions.map(|sourced| sourced.value),
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
@@ -953,6 +1004,7 @@ impl ConfigRequirementsToml {
self.allowed_approval_policies.is_none()
&& self.allowed_approvals_reviewers.is_none()
&& self.allowed_sandbox_modes.is_none()
&& self.allowed_permissions.is_none()
&& self.remote_sandbox_config.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.allow_managed_hooks_only.is_none()
@@ -992,10 +1044,14 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
type Error = ConstraintError;
fn try_from(toml: ConfigRequirementsWithSources) -> Result<Self, Self::Error> {
// Profile catalog selection remains on ConfigRequirementsToml for
// config loading and requirements API projection. The normalized
// constraints below only need the compiled PermissionProfile envelope.
let ConfigRequirementsWithSources {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions: _,
allowed_web_search_modes,
allow_managed_hooks_only,
computer_use,
@@ -1301,6 +1357,7 @@ mod tests {
allowed_approval_policies,
allowed_approvals_reviewers,
allowed_sandbox_modes,
allowed_permissions,
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
@@ -1323,6 +1380,8 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_sandbox_modes: allowed_sandbox_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_permissions: allowed_permissions
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allowed_web_search_modes: allowed_web_search_modes
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_managed_hooks_only: allow_managed_hooks_only
@@ -1370,6 +1429,61 @@ mod tests {
Ok(())
}
#[test]
fn deserialize_managed_permission_profiles() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
r#"
allowed_permissions = ["managed-standard", "managed-build"]
[permissions.managed-standard]
extends = ":workspace"
[permissions.managed-build]
extends = "managed-standard"
"#,
)?;
assert_eq!(
requirements.allowed_permissions,
Some(vec![
"managed-standard".to_string(),
"managed-build".to_string(),
])
);
let permissions = requirements
.permissions
.as_ref()
.expect("managed permission profiles");
assert!(permissions.profiles.contains_key("managed-standard"));
assert!(
permissions
.profiles
.get("managed-build")
.and_then(|profile| profile.extends.as_deref())
.is_some()
);
assert!(!requirements.is_empty());
Ok(())
}
#[test]
fn filesystem_requirements_table_cannot_define_a_permission_profile() {
let err = from_str::<ConfigRequirementsToml>(
r#"
[permissions.filesystem]
extends = ":workspace"
"#,
)
.expect_err("filesystem requirements cannot define a permission profile");
assert!(
err.to_string().contains(
"`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile"
),
"unexpected error: {err:#}"
);
}
#[test]
fn deserialize_computer_use_requirements() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
@@ -1421,6 +1535,7 @@ mod tests {
allowed_approval_policies: Some(allowed_approval_policies.clone()),
allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()),
allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()),
allowed_permissions: Some(vec!["managed".to_string()]),
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
allow_managed_hooks_only: Some(true),
@@ -1451,6 +1566,10 @@ mod tests {
source.clone(),
)),
allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)),
allowed_permissions: Some(Sourced::new(
vec!["managed".to_string()],
source.clone(),
)),
allowed_web_search_modes: Some(Sourced::new(
allowed_web_search_modes,
enforce_source.clone(),
@@ -1501,6 +1620,7 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
@@ -1550,6 +1670,7 @@ mod tests {
)),
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
computer_use: None,
+1 -1
View File
@@ -359,7 +359,7 @@ pub struct ConfigToml {
pub personality: Option<Personality>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
+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,
+206
View File
@@ -1,12 +1,18 @@
use std::collections::BTreeMap;
use crate::merge::merge_toml_values;
use codex_network_proxy::InjectedHeaderConfig;
use codex_network_proxy::MitmHookActionsConfig;
use codex_network_proxy::MitmHookBodyConfig;
use codex_network_proxy::MitmHookConfig;
use codex_network_proxy::MitmHookMatchConfig;
use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission;
use codex_network_proxy::NetworkMode;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
use codex_network_proxy::normalize_host;
use codex_protocol::permissions::FileSystemAccessMode;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -350,6 +356,38 @@ pub struct NetworkToml {
pub domains: Option<NetworkDomainPermissionsToml>,
pub unix_sockets: Option<NetworkUnixSocketPermissionsToml>,
pub allow_local_binding: Option<bool>,
pub mitm: Option<NetworkMitmToml>,
}
#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmToml {
#[schemars(with = "Option<BTreeMap<String, NetworkMitmHookToml>>")]
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
#[schemars(with = "Option<BTreeMap<String, NetworkMitmActionToml>>")]
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NetworkMitmTomlUnchecked {
pub hooks: Option<IndexMap<String, NetworkMitmHookToml>>,
pub actions: Option<IndexMap<String, NetworkMitmActionToml>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct NetworkMitmHookToml {
pub host: String,
pub methods: Vec<String>,
pub path_prefixes: Vec<String>,
#[serde(default)]
pub query: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub headers: BTreeMap<String, Vec<String>>,
#[schemars(with = "Option<MitmHookBodyConfigSchema>")]
pub body: Option<MitmHookBodyConfig>,
pub action: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -359,6 +397,114 @@ enum NetworkModeSchema {
Full,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmActionToml {
pub strip_request_headers: Vec<String>,
pub inject_request_headers: Vec<NetworkMitmInjectedHeaderToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[serde(default)]
pub struct NetworkMitmInjectedHeaderToml {
pub name: String,
pub secret_env_var: Option<String>,
pub secret_file: Option<String>,
pub prefix: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(transparent)]
struct MitmHookBodyConfigSchema(pub serde_json::Value);
impl<'de> Deserialize<'de> for NetworkMitmToml {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?;
let mitm = Self {
hooks: unchecked.hooks,
actions: unchecked.actions,
};
mitm.validate_action_definitions()
.map_err(serde::de::Error::custom)?;
Ok(mitm)
}
}
impl NetworkMitmToml {
pub fn validate_action_definitions(&self) -> Result<(), String> {
if let Some(actions) = self.actions.as_ref() {
for (action_name, action) in actions {
if action.is_empty() {
return Err(format!(
"network.mitm.actions.{action_name} must define at least one operation"
));
}
}
}
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
if hook.action.is_empty() {
return Err(format!(
"network.mitm.hooks.{hook_name}.action must not be empty"
));
}
}
Ok(())
}
pub fn validate_action_references(
&self,
actions_by_name: &IndexMap<String, NetworkMitmActionToml>,
) -> Result<(), String> {
self.validate_action_definitions()?;
let Some(hooks) = self.hooks.as_ref() else {
return Ok(());
};
for (hook_name, hook) in hooks {
for action_name in &hook.action {
if !actions_by_name.contains_key(action_name) {
return Err(format!(
"network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`"
));
}
}
}
Ok(())
}
pub fn to_runtime_hooks(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> Vec<MitmHookConfig> {
self.hooks
.as_ref()
.map(|hooks| {
hooks
.values()
.map(|hook| hook.to_runtime(actions_by_name))
.collect()
})
.unwrap_or_default()
}
}
impl NetworkMitmActionToml {
pub fn is_empty(&self) -> bool {
self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty()
}
}
impl NetworkToml {
pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) {
if let Some(enabled) = self.enabled {
@@ -411,6 +557,11 @@ impl NetworkToml {
if let Some(allow_local_binding) = self.allow_local_binding {
config.network.allow_local_binding = allow_local_binding;
}
if let Some(mitm) = self.mitm.as_ref() {
config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref());
}
config.network.mitm =
config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty();
}
pub fn to_network_proxy_config(&self) -> NetworkProxyConfig {
@@ -420,6 +571,61 @@ impl NetworkToml {
}
}
impl NetworkMitmHookToml {
fn to_runtime(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookConfig {
MitmHookConfig {
host: self.host.clone(),
matcher: MitmHookMatchConfig {
methods: self.methods.clone(),
path_prefixes: self.path_prefixes.clone(),
query: self.query.clone(),
headers: self.headers.clone(),
body: self.body.clone(),
},
actions: self.selected_actions(actions_by_name),
}
}
fn selected_actions(
&self,
actions_by_name: Option<&IndexMap<String, NetworkMitmActionToml>>,
) -> MitmHookActionsConfig {
let Some(actions_by_name) = actions_by_name else {
return MitmHookActionsConfig::default();
};
let mut selected = MitmHookActionsConfig::default();
for action_name in &self.action {
if let Some(action) = actions_by_name.get(action_name) {
selected
.strip_request_headers
.extend(action.strip_request_headers.clone());
selected.inject_request_headers.extend(
action
.inject_request_headers
.iter()
.map(NetworkMitmInjectedHeaderToml::to_runtime),
);
}
}
selected
}
}
impl NetworkMitmInjectedHeaderToml {
fn to_runtime(&self) -> InjectedHeaderConfig {
InjectedHeaderConfig {
name: self.name.clone(),
secret_env_var: self.secret_env_var.clone(),
secret_file: self.secret_file.clone(),
prefix: self.prefix.clone(),
}
}
}
pub fn overlay_network_domain_permissions(
config: &mut NetworkProxyConfig,
domains: &NetworkDomainPermissionsToml,
+1 -1
View File
@@ -24,7 +24,7 @@ use codex_protocol::protocol::AskForApproval;
pub struct ConfigProfile {
pub model: Option<String>,
/// Optional explicit service tier request id for new turns (for example
/// `priority` or `flex`; legacy `fast` also works).
/// `default`, `priority`, or `flex`; legacy `fast` also works).
pub service_tier: Option<String>,
/// The key in the `model_providers` map identifying the
/// [`ModelProviderInfo`] to use.
+8 -13
View File
@@ -112,7 +112,6 @@ pub async fn load_plugins_from_layer_stack(
extra_plugins: HashMap<String, PluginConfig>,
store: &PluginStore,
restriction_product: Option<Product>,
plugin_hooks_enabled: bool,
) -> PluginLoadOutcome<McpServerConfig> {
let skill_config_rules = skill_config_rules_from_stack(config_layer_stack);
let mut configured_plugins = configured_plugins_from_stack(config_layer_stack);
@@ -129,7 +128,6 @@ pub async fn load_plugins_from_layer_stack(
store,
restriction_product,
&skill_config_rules,
plugin_hooks_enabled,
)
.await;
for name in loaded_plugin.mcp_servers.keys() {
@@ -499,7 +497,6 @@ async fn load_plugin(
store: &PluginStore,
restriction_product: Option<Product>,
skill_config_rules: &SkillConfigRules,
plugin_hooks_enabled: bool,
) -> LoadedPlugin<McpServerConfig> {
let plugin_id = PluginId::parse(&config_name);
let active_plugin_root = plugin_id
@@ -597,16 +594,14 @@ async fn load_plugin(
}
loaded_plugin.mcp_servers = mcp_servers;
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
if plugin_hooks_enabled {
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
&plugin_root,
&loaded_plugin_id,
&store.plugin_data_root(&loaded_plugin_id),
manifest_paths,
);
loaded_plugin.hook_sources = hook_sources;
loaded_plugin.hook_load_warnings = hook_load_warnings;
}
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
&plugin_root,
&loaded_plugin_id,
&store.plugin_data_root(&loaded_plugin_id),
manifest_paths,
);
loaded_plugin.hook_sources = hook_sources;
loaded_plugin.hook_load_warnings = hook_load_warnings;
loaded_plugin
}

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