mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Merge origin/main into abhinav/spawn-agent-hook-alias
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
-1
@@ -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\":{}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
Generated
+4
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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": {
|
||||
|
||||
+38
@@ -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": {
|
||||
|
||||
+9
@@ -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": [
|
||||
{
|
||||
|
||||
+6
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user